1pub mod diff;
8#[cfg(test)]
9mod diff_tests;
10
11use std::collections::BTreeMap;
12
13use serde_json::{Value, json};
14
15use crate::dialect::{Document, Info, Operation, unknown_method};
16
17const HTTP_METHODS: &[&str] = &[
19 "GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE",
20];
21
22pub fn document(info: &Info, ops: &[Operation]) -> Document {
24 let mut conflicts = Vec::new();
25 let mut paths: BTreeMap<String, serde_json::Map<String, Value>> = BTreeMap::new();
26 let mut components: BTreeMap<String, Value> = BTreeMap::new();
27
28 for op in ops {
29 if !HTTP_METHODS.contains(&op.method.to_ascii_uppercase().as_str()) {
30 conflicts.push(unknown_method(op, "OpenAPI", "GET, POST, PUT, DELETE, ..."));
31 continue;
32 }
33 for (name, schema) in &op.shape.components {
34 if let Some(existing) = components.get(name)
35 && existing != schema
36 {
37 conflicts.push(format!(
38 "component `{name}` has two different definitions \
39 (second seen via operation `{}`)",
40 op.operation_id
41 ));
42 continue;
43 }
44 components.insert(name.clone(), schema.clone());
45 }
46
47 let verb = op.method.to_ascii_lowercase();
48 let item = paths.entry(op.path.clone()).or_default();
49 if item.contains_key(&verb) {
50 conflicts.push(format!(
51 "{} {} is declared by more than one operation (`{}`)",
52 op.method, op.path, op.operation_id
53 ));
54 continue;
55 }
56 item.insert(verb, operation_object(op));
57 }
58
59 let mut doc = serde_json::Map::new();
60 doc.insert("openapi".into(), json!("3.1.0"));
61 doc.insert("info".into(), info_object(info));
62 if !info.servers.is_empty() {
63 let servers: Vec<Value> = info.servers.iter().map(|u| json!({ "url": u })).collect();
64 doc.insert("servers".into(), json!(servers));
65 }
66 let path_items: serde_json::Map<String, Value> = paths
67 .into_iter()
68 .map(|(p, item)| (p, Value::Object(item)))
69 .collect();
70 doc.insert("paths".into(), Value::Object(path_items));
71 if !components.is_empty() {
72 let schemas: serde_json::Map<String, Value> = components.into_iter().collect();
73 doc.insert("components".into(), json!({ "schemas": schemas }));
74 }
75
76 Document {
77 value: Value::Object(doc),
78 conflicts,
79 notes: Vec::new(),
80 }
81}
82
83fn info_object(info: &Info) -> Value {
84 let mut m = serde_json::Map::new();
85 m.insert("title".into(), json!(info.title));
86 m.insert("version".into(), json!(info.version));
87 if let Some(d) = &info.description {
88 m.insert("description".into(), json!(d));
89 }
90 Value::Object(m)
91}
92
93fn operation_object(op: &Operation) -> Value {
94 let mut m = serde_json::Map::new();
95 m.insert("operationId".into(), json!(op.operation_id));
96 if let Some(s) = &op.summary {
97 m.insert("summary".into(), json!(s));
98 }
99
100 if !op.shape.parameters.is_empty() {
101 let params: Vec<Value> = op
102 .shape
103 .parameters
104 .iter()
105 .map(|p| {
106 json!({
107 "name": p.name,
108 "in": p.location,
109 "required": p.required,
110 "schema": p.schema,
111 })
112 })
113 .collect();
114 m.insert("parameters".into(), json!(params));
115 }
116
117 if let Some(body) = &op.shape.request {
118 m.insert(
119 "requestBody".into(),
120 json!({
121 "required": body.required,
122 "content": { body.content_type.clone(): { "schema": body.schema } },
123 }),
124 );
125 }
126
127 let mut responses = serde_json::Map::new();
128 for (code, resp) in &op.shape.responses {
129 let mut r = serde_json::Map::new();
130 let description = resp
132 .description
133 .clone()
134 .unwrap_or_else(|| describe_status(code).to_string());
135 r.insert("description".into(), json!(description));
136 if !resp.headers.is_empty() {
137 let headers: serde_json::Map<String, Value> = resp
138 .headers
139 .iter()
140 .map(|h| (h.clone(), json!({ "schema": { "type": "string" } })))
141 .collect();
142 r.insert("headers".into(), Value::Object(headers));
143 }
144 if !resp.content_type.is_empty() {
145 r.insert(
146 "content".into(),
147 json!({ resp.content_type.clone(): { "schema": resp.schema } }),
148 );
149 }
150 responses.insert(code.clone(), Value::Object(r));
151 }
152 if responses.is_empty() {
153 responses.insert("200".into(), json!({ "description": "OK" }));
154 }
155 m.insert("responses".into(), Value::Object(responses));
156
157 Value::Object(m)
158}
159
160fn describe_status(code: &str) -> &'static str {
162 match code {
163 "200" => "OK",
164 "201" => "Created",
165 "202" => "Accepted",
166 "204" => "No Content",
167 "301" => "Moved Permanently",
168 "302" => "Found",
169 "304" => "Not Modified",
170 "400" => "Bad Request",
171 "401" => "Unauthorized",
172 "403" => "Forbidden",
173 "404" => "Not Found",
174 "409" => "Conflict",
175 "422" => "Unprocessable Content",
176 "429" => "Too Many Requests",
177 "500" => "Internal Server Error",
178 "503" => "Service Unavailable",
179 "default" => "Unexpected error",
180 _ => "Response",
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use crate::schema::route_sig::{Parameter, RequestBody, Response, RouteShape};
188 use crate::serialize::to_yaml;
189
190 fn info() -> Info {
191 Info {
192 title: "Items API".into(),
193 version: "2.1".into(),
194 description: None,
195 servers: vec!["https://api.example.com".into()],
196 }
197 }
198
199 fn shape() -> RouteShape {
200 RouteShape::default()
201 }
202
203 fn op(operation_id: &str, method: &str, path: &str, shape: RouteShape) -> Operation {
204 Operation {
205 operation_id: operation_id.into(),
206 method: method.into(),
207 path: path.into(),
208 summary: None,
209 shape,
210 }
211 }
212
213 fn json_response(schema: Value) -> Response {
214 Response::json(schema)
215 }
216
217 #[test]
218 fn emits_a_three_one_document_with_info_and_servers() {
219 let d = document(&info(), &[]);
220 assert_eq!(d.value["openapi"], "3.1.0");
221 assert_eq!(d.value["info"]["title"], "Items API");
222 assert_eq!(d.value["info"]["version"], "2.1");
223 assert_eq!(d.value["servers"][0]["url"], "https://api.example.com");
224 assert!(d.conflicts.is_empty());
225 }
226
227 #[test]
228 fn the_verb_becomes_a_lowercased_path_item_key() {
229 let d = document(&info(), &[op("createItem", "POST", "/items", shape())]);
230 assert_eq!(
231 d.value["paths"]["/items"]["post"]["operationId"],
232 "createItem"
233 );
234 }
235
236 #[test]
237 fn two_verbs_on_one_path_merge_into_a_single_path_item() {
238 let d = document(
239 &info(),
240 &[
241 op("listItems", "GET", "/items", shape()),
242 op("createItem", "POST", "/items", shape()),
243 ],
244 );
245 let item = &d.value["paths"]["/items"];
246 assert_eq!(item["get"]["operationId"], "listItems");
247 assert_eq!(item["post"]["operationId"], "createItem");
248 assert!(d.conflicts.is_empty());
249 }
250
251 #[test]
252 fn a_duplicated_verb_and_path_is_a_conflict_not_a_silent_overwrite() {
253 let d = document(
254 &info(),
255 &[
256 op("listItems", "GET", "/items", shape()),
257 op("otherList", "GET", "/items", shape()),
258 ],
259 );
260 assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
261 assert_eq!(
263 d.value["paths"]["/items"]["get"]["operationId"],
264 "listItems"
265 );
266 }
267
268 #[test]
269 fn a_method_from_another_dialect_is_a_conflict() {
270 let d = document(
271 &info(),
272 &[op("streamPrices", "SUBSCRIBE", "prices", shape())],
273 );
274 assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
275 assert!(d.conflicts[0].contains("SUBSCRIBE"), "{:?}", d.conflicts);
276 assert!(
277 d.value["paths"].as_object().is_some_and(|p| p.is_empty()),
278 "the operation is not emitted under a guessed verb"
279 );
280 }
281
282 #[test]
283 fn components_shared_between_operations_merge_cleanly() {
284 let mut a = shape();
285 a.components
286 .insert("Item".into(), json!({ "type": "object" }));
287 let mut b = shape();
288 b.components
289 .insert("Item".into(), json!({ "type": "object" }));
290 let d = document(&info(), &[op("a", "GET", "/a", a), op("b", "GET", "/b", b)]);
291 assert!(d.conflicts.is_empty(), "identical definitions agree");
292 assert_eq!(d.value["components"]["schemas"]["Item"]["type"], "object");
293 }
294
295 #[test]
296 fn components_that_disagree_are_reported() {
297 let mut a = shape();
298 a.components
299 .insert("Item".into(), json!({ "type": "object" }));
300 let mut b = shape();
301 b.components
302 .insert("Item".into(), json!({ "type": "string" }));
303 let d = document(&info(), &[op("a", "GET", "/a", a), op("b", "GET", "/b", b)]);
304 assert_eq!(d.conflicts.len(), 1, "got {:?}", d.conflicts);
305 assert!(d.conflicts[0].contains("Item"));
306 }
307
308 #[test]
309 fn every_response_carries_the_description_openapi_requires() {
310 let mut s = shape();
311 s.responses
312 .insert("201".into(), json_response(json!({ "type": "object" })));
313 s.responses
314 .insert("default".into(), json_response(json!({})));
315 let d = document(&info(), &[op("createItem", "POST", "/items", s)]);
316 let responses = &d.value["paths"]["/items"]["post"]["responses"];
317 assert_eq!(responses["201"]["description"], "Created");
318 assert_eq!(responses["default"]["description"], "Unexpected error");
319 }
320
321 #[test]
322 fn a_no_content_response_has_no_content_block() {
323 let mut s = shape();
324 s.responses.insert("204".into(), Response::empty());
325 let d = document(&info(), &[op("deleteItem", "DELETE", "/items/{id}", s)]);
326 let r = &d.value["paths"]["/items/{id}"]["delete"]["responses"]["204"];
327 assert_eq!(r["description"], "No Content");
328 assert!(r.get("content").is_none(), "204 carries no body");
329 }
330
331 #[test]
332 fn an_operation_with_no_responses_still_emits_one() {
333 let d = document(&info(), &[op("ping", "GET", "/ping", shape())]);
334 assert_eq!(
335 d.value["paths"]["/ping"]["get"]["responses"]["200"]["description"],
336 "OK"
337 );
338 }
339
340 #[test]
341 fn request_body_uses_the_content_type_the_extractor_implied() {
342 let mut s = shape();
343 s.request = Some(RequestBody {
344 content_type: "application/x-www-form-urlencoded".into(),
345 schema: json!({ "$ref": "#/components/schemas/NewItem" }),
346 required: true,
347 });
348 let d = document(&info(), &[op("createItem", "POST", "/items", s)]);
349 let body = &d.value["paths"]["/items"]["post"]["requestBody"];
350 assert_eq!(body["required"], true);
351 assert_eq!(
352 body["content"]["application/x-www-form-urlencoded"]["schema"]["$ref"],
353 "#/components/schemas/NewItem"
354 );
355 }
356
357 #[test]
358 fn parameters_render_with_location_and_requiredness() {
359 let mut s = shape();
360 s.parameters.push(Parameter {
361 name: "id".into(),
362 location: "path".into(),
363 required: true,
364 schema: json!({ "type": "integer" }),
365 });
366 s.parameters.push(Parameter {
367 name: "verbose".into(),
368 location: "query".into(),
369 required: false,
370 schema: json!({ "type": "boolean" }),
371 });
372 let d = document(&info(), &[op("getItem", "GET", "/items/{id}", s)]);
373 let params = d.value["paths"]["/items/{id}"]["get"]["parameters"]
374 .as_array()
375 .expect("parameters");
376 assert_eq!(params[0]["name"], "id");
377 assert_eq!(params[0]["in"], "path");
378 assert_eq!(params[0]["required"], true);
379 assert_eq!(params[1]["required"], false);
380 }
381
382 #[test]
383 fn yaml_output_is_stable_and_leads_with_the_version() {
384 let mut s = shape();
385 s.responses
386 .insert("200".into(), json_response(json!({ "type": "object" })));
387 s.components
388 .insert("Item".into(), json!({ "type": "object" }));
389 let d = document(&info(), &[op("getItem", "GET", "/items/{id}", s)]);
390 let a = to_yaml(&d.value);
391 let b = to_yaml(&d.value);
392 assert_eq!(a, b, "regeneration must be byte-identical");
393 assert!(a.starts_with("openapi: 3.1.0"), "got:\n{a}");
394 assert!(a.ends_with('\n'), "file ends with a newline");
395 }
396}