Skip to main content

soothfast_spec/openapi/
mod.rs

1//! OpenAPI 3.1 documents assembled from inferred route shapes.
2//!
3//! 3.1 rather than 3.0 because the extractor emits JSON Schema 2020-12
4//! constructs (`const` for enum tags, `prefixItems` for tuples) that 3.0
5//! has no way to express.
6
7pub 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
17/// The verbs a path item may key an operation by.
18const HTTP_METHODS: &[&str] = &[
19    "GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE",
20];
21
22/// Assemble an OpenAPI 3.1 document.
23pub 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        // OpenAPI requires a description on every response object.
131        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
160/// The description OpenAPI insists on, from the status code itself.
161fn 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        // The first declaration stands rather than being replaced.
262        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}