Skip to main content

panproto_protocols/api/
openapi.rs

1//! `OpenAPI`/Swagger protocol definition.
2//!
3//! `OpenAPI` uses a constrained multigraph schema theory
4//! (`colimit(ThGraph, ThConstraint, ThMulti)`) and a W-type
5//! instance theory (`ThWType`).
6//!
7//! Vertex kinds: path, operation, parameter, request-body, response,
8//! schema-object, header, string, integer, number, boolean, array, object.
9//!
10//! Edge kinds: prop, items, variant, ref.
11
12use std::collections::HashMap;
13
14use panproto_gat::Theory;
15use panproto_schema::{EdgeRule, Protocol, Schema, SchemaBuilder};
16
17use crate::emit::{children_by_edge, constraint_value, find_roots};
18use crate::error::ProtocolError;
19use crate::theories;
20
21/// Returns the `OpenAPI` protocol definition.
22#[must_use]
23pub fn protocol() -> Protocol {
24    Protocol {
25        name: "openapi".into(),
26        schema_theory: "ThOpenAPISchema".into(),
27        instance_theory: "ThOpenAPIInstance".into(),
28        edge_rules: edge_rules(),
29        obj_kinds: vec![
30            "path".into(),
31            "operation".into(),
32            "parameter".into(),
33            "request-body".into(),
34            "response".into(),
35            "schema-object".into(),
36            "header".into(),
37            "string".into(),
38            "integer".into(),
39            "number".into(),
40            "boolean".into(),
41            "array".into(),
42            "object".into(),
43        ],
44        constraint_sorts: vec![
45            "required".into(),
46            "format".into(),
47            "enum".into(),
48            "default".into(),
49            "minimum".into(),
50            "maximum".into(),
51            "pattern".into(),
52            "minLength".into(),
53            "maxLength".into(),
54            "minItems".into(),
55            "maxItems".into(),
56            "deprecated".into(),
57        ],
58        has_order: true,
59        has_coproducts: true,
60        has_recursion: true,
61        nominal_identity: true,
62        ..Protocol::default()
63    }
64}
65
66/// Register the component GATs for `OpenAPI` with a theory registry.
67pub fn register_theories<S: ::std::hash::BuildHasher>(registry: &mut HashMap<String, Theory, S>) {
68    theories::register_constrained_multigraph_wtype(
69        registry,
70        "ThOpenAPISchema",
71        "ThOpenAPIInstance",
72    );
73}
74
75/// Parse an `OpenAPI` JSON document into a [`Schema`].
76///
77/// Walks paths, operations, parameters, request bodies, responses,
78/// and schemas to produce a flat vertex/edge graph.
79///
80/// # Errors
81///
82/// Returns [`ProtocolError`] if parsing or schema construction fails.
83#[allow(clippy::too_many_lines)]
84pub fn parse_openapi(json: &serde_json::Value) -> Result<Schema, ProtocolError> {
85    let proto = protocol();
86    let mut builder = SchemaBuilder::new(&proto);
87    let mut counter: usize = 0;
88
89    // Pre-walk components/schemas so $ref resolution can find them.
90    let mut defs_map: HashMap<String, String> = HashMap::new();
91    if let Some(schemas) = json
92        .pointer("/components/schemas")
93        .and_then(serde_json::Value::as_object)
94    {
95        for (name, schema_val) in schemas {
96            let schema_id = format!("components/schemas/{name}");
97            builder = walk_schema(builder, schema_val, &schema_id, &mut counter)?;
98            let ref_path = format!("#/components/schemas/{name}");
99            defs_map.insert(ref_path, schema_id);
100        }
101    }
102
103    // Walk paths.
104    if let Some(paths) = json.get("paths").and_then(serde_json::Value::as_object) {
105        for (path_str, path_item) in paths {
106            let path_id = format!("path:{path_str}");
107            builder = builder.vertex(&path_id, "path", None)?;
108            builder = parse_path_item(builder, path_item, &path_id, &mut counter, &defs_map)?;
109        }
110    }
111
112    let schema = builder.build()?;
113    Ok(schema)
114}
115
116/// Parse a single path item, walking HTTP methods.
117fn parse_path_item(
118    mut builder: SchemaBuilder,
119    path_item: &serde_json::Value,
120    path_id: &str,
121    counter: &mut usize,
122    defs_map: &HashMap<String, String>,
123) -> Result<SchemaBuilder, ProtocolError> {
124    for method in &[
125        "get", "post", "put", "delete", "patch", "options", "head", "trace",
126    ] {
127        if let Some(op) = path_item.get(*method) {
128            let op_id = format!("{path_id}:{method}");
129            builder = builder.vertex(&op_id, "operation", None)?;
130            builder = builder.edge(path_id, &op_id, "prop", Some(method))?;
131
132            if op.get("deprecated").and_then(serde_json::Value::as_bool) == Some(true) {
133                builder = builder.constraint(&op_id, "deprecated", "true");
134            }
135
136            builder = parse_operation(builder, op, &op_id, counter, defs_map)?;
137        }
138    }
139    Ok(builder)
140}
141
142/// Parse an operation's parameters, request body, and responses.
143fn parse_operation(
144    mut builder: SchemaBuilder,
145    op: &serde_json::Value,
146    op_id: &str,
147    counter: &mut usize,
148    defs_map: &HashMap<String, String>,
149) -> Result<SchemaBuilder, ProtocolError> {
150    // Parameters.
151    if let Some(params) = op.get("parameters").and_then(serde_json::Value::as_array) {
152        for (i, param) in params.iter().enumerate() {
153            let param_name = param
154                .get("name")
155                .and_then(serde_json::Value::as_str)
156                .unwrap_or("unknown");
157            let param_id = format!("{op_id}:param{i}");
158            builder = builder.vertex(&param_id, "parameter", None)?;
159            builder = builder.edge(op_id, &param_id, "prop", Some(param_name))?;
160
161            if param.get("required").and_then(serde_json::Value::as_bool) == Some(true) {
162                builder = builder.constraint(&param_id, "required", "true");
163            }
164
165            if let Some(schema_val) = param.get("schema") {
166                let s_id = format!("{param_id}:schema");
167                builder = walk_schema_or_ref(builder, schema_val, &s_id, counter, defs_map)?;
168                builder = builder.edge(&param_id, &s_id, "prop", Some("schema"))?;
169            }
170        }
171    }
172
173    // Request body.
174    if let Some(req_body) = op.get("requestBody") {
175        let rb_id = format!("{op_id}:requestBody");
176        builder = builder.vertex(&rb_id, "request-body", None)?;
177        builder = builder.edge(op_id, &rb_id, "prop", Some("requestBody"))?;
178
179        if let Some(content) = req_body
180            .get("content")
181            .and_then(serde_json::Value::as_object)
182        {
183            for (media_type, media_obj) in content {
184                if let Some(schema_val) = media_obj.get("schema") {
185                    let s_id = format!("{rb_id}:{media_type}");
186                    builder = walk_schema_or_ref(builder, schema_val, &s_id, counter, defs_map)?;
187                    builder = builder.edge(&rb_id, &s_id, "prop", Some(media_type))?;
188                }
189            }
190        }
191    }
192
193    // Responses.
194    if let Some(responses) = op.get("responses").and_then(serde_json::Value::as_object) {
195        for (status, resp) in responses {
196            let resp_id = format!("{op_id}:resp{status}");
197            builder = builder.vertex(&resp_id, "response", None)?;
198            builder = builder.edge(op_id, &resp_id, "prop", Some(status))?;
199
200            if let Some(content) = resp.get("content").and_then(serde_json::Value::as_object) {
201                for (media_type, media_obj) in content {
202                    if let Some(schema_val) = media_obj.get("schema") {
203                        let s_id = format!("{resp_id}:{media_type}");
204                        builder =
205                            walk_schema_or_ref(builder, schema_val, &s_id, counter, defs_map)?;
206                        builder = builder.edge(&resp_id, &s_id, "prop", Some(media_type))?;
207                    }
208                }
209            }
210
211            if let Some(headers) = resp.get("headers").and_then(serde_json::Value::as_object) {
212                for (hdr_name, _hdr_obj) in headers {
213                    let hdr_id = format!("{resp_id}:hdr:{hdr_name}");
214                    builder = builder.vertex(&hdr_id, "header", None)?;
215                    builder = builder.edge(&resp_id, &hdr_id, "prop", Some(hdr_name))?;
216                }
217            }
218        }
219    }
220
221    Ok(builder)
222}
223
224/// Walk a schema value, resolving `$ref` if present.
225fn walk_schema_or_ref(
226    builder: SchemaBuilder,
227    schema: &serde_json::Value,
228    current_id: &str,
229    counter: &mut usize,
230    defs_map: &HashMap<String, String>,
231) -> Result<SchemaBuilder, ProtocolError> {
232    if let Some(ref_str) = schema.get("$ref").and_then(serde_json::Value::as_str) {
233        let mut b = builder.vertex(current_id, "schema-object", None)?;
234        if let Some(def_id) = defs_map.get(ref_str) {
235            b = b.edge(current_id, def_id, "ref", Some(ref_str))?;
236        }
237        Ok(b)
238    } else {
239        walk_schema(builder, schema, current_id, counter)
240    }
241}
242
243/// Recursively walk a JSON Schema object within an `OpenAPI` spec.
244fn walk_schema(
245    mut builder: SchemaBuilder,
246    schema: &serde_json::Value,
247    current_id: &str,
248    counter: &mut usize,
249) -> Result<SchemaBuilder, ProtocolError> {
250    let type_str = schema
251        .get("type")
252        .and_then(serde_json::Value::as_str)
253        .unwrap_or("object");
254
255    let kind = match type_str {
256        "string" => "string",
257        "integer" => "integer",
258        "number" => "number",
259        "boolean" => "boolean",
260        "array" => "array",
261        _ => "object",
262    };
263
264    builder = builder.vertex(current_id, kind, None)?;
265
266    // Add constraints.
267    for field in &[
268        "format",
269        "minimum",
270        "maximum",
271        "pattern",
272        "minLength",
273        "maxLength",
274        "minItems",
275        "maxItems",
276    ] {
277        if let Some(val) = schema.get(field) {
278            let val_str = match val {
279                serde_json::Value::String(s) => s.clone(),
280                serde_json::Value::Number(n) => n.to_string(),
281                _ => val.to_string(),
282            };
283            builder = builder.constraint(current_id, field, &val_str);
284        }
285    }
286
287    if let Some(enum_val) = schema.get("enum").and_then(serde_json::Value::as_array) {
288        let vals: Vec<String> = enum_val
289            .iter()
290            .map(|v| v.as_str().map_or_else(|| v.to_string(), String::from))
291            .collect();
292        builder = builder.constraint(current_id, "enum", &vals.join(","));
293    }
294
295    if let Some(default_val) = schema.get("default") {
296        let val_str = match default_val {
297            serde_json::Value::String(s) => s.clone(),
298            _ => default_val.to_string(),
299        };
300        builder = builder.constraint(current_id, "default", &val_str);
301    }
302
303    // Properties.
304    if let Some(properties) = schema
305        .get("properties")
306        .and_then(serde_json::Value::as_object)
307    {
308        let required_fields: Vec<&str> = schema
309            .get("required")
310            .and_then(serde_json::Value::as_array)
311            .map(|arr| arr.iter().filter_map(serde_json::Value::as_str).collect())
312            .unwrap_or_default();
313
314        for (prop_name, prop_schema) in properties {
315            let prop_id = format!("{current_id}.{prop_name}");
316            builder = walk_schema(builder, prop_schema, &prop_id, counter)?;
317            builder = builder.edge(current_id, &prop_id, "prop", Some(prop_name))?;
318            if required_fields.contains(&prop_name.as_str()) {
319                builder = builder.constraint(&prop_id, "required", "true");
320            }
321        }
322    }
323
324    // Items.
325    if let Some(items) = schema.get("items") {
326        let items_id = format!("{current_id}:items");
327        builder = walk_schema(builder, items, &items_id, counter)?;
328        builder = builder.edge(current_id, &items_id, "items", None)?;
329    }
330
331    // oneOf / anyOf / allOf.
332    for combiner in &["oneOf", "anyOf", "allOf"] {
333        if let Some(arr) = schema.get(*combiner).and_then(serde_json::Value::as_array) {
334            for (i, sub_schema) in arr.iter().enumerate() {
335                *counter += 1;
336                let sub_id = format!("{current_id}:{combiner}{i}_{counter}");
337                builder = walk_schema(builder, sub_schema, &sub_id, counter)?;
338                builder = builder.edge(current_id, &sub_id, "variant", Some(combiner))?;
339            }
340        }
341    }
342
343    Ok(builder)
344}
345
346/// Emit a [`Schema`] as an `OpenAPI` JSON document.
347///
348/// # Errors
349///
350/// Returns [`ProtocolError`] if emission fails.
351pub fn emit_openapi(schema: &Schema) -> Result<serde_json::Value, ProtocolError> {
352    let mut paths = serde_json::Map::new();
353    let mut component_schemas = serde_json::Map::new();
354
355    let roots = find_roots(schema, &["prop", "items", "variant", "ref"]);
356
357    for root in &roots {
358        if root.kind == "path" {
359            let path_name = root.id.strip_prefix("path:").unwrap_or(&root.id);
360            let mut path_obj = serde_json::Map::new();
361
362            for (edge, op_vertex) in children_by_edge(schema, &root.id, "prop") {
363                if op_vertex.kind == "operation" {
364                    let method = edge.name.as_deref().unwrap_or("get");
365                    let op_obj = emit_operation(schema, &op_vertex.id);
366                    path_obj.insert(method.to_string(), op_obj);
367                }
368            }
369
370            paths.insert(path_name.to_string(), serde_json::Value::Object(path_obj));
371        } else {
372            let schema_obj = emit_schema_value(schema, &root.id);
373            let name = root
374                .id
375                .strip_prefix("components/schemas/")
376                .unwrap_or(&root.id);
377            component_schemas.insert(name.to_string(), schema_obj);
378        }
379    }
380
381    let mut result = serde_json::Map::new();
382    result.insert("openapi".into(), serde_json::Value::String("3.0.0".into()));
383    result.insert(
384        "info".into(),
385        serde_json::json!({"title": "Generated", "version": "1.0.0"}),
386    );
387    result.insert("paths".into(), serde_json::Value::Object(paths));
388
389    if !component_schemas.is_empty() {
390        let mut components = serde_json::Map::new();
391        components.insert(
392            "schemas".into(),
393            serde_json::Value::Object(component_schemas),
394        );
395        result.insert("components".into(), serde_json::Value::Object(components));
396    }
397
398    Ok(serde_json::Value::Object(result))
399}
400
401/// Emit an operation vertex as a JSON object.
402fn emit_operation(schema: &Schema, op_id: &str) -> serde_json::Value {
403    let mut obj = serde_json::Map::new();
404
405    if constraint_value(schema, op_id, "deprecated") == Some("true") {
406        obj.insert("deprecated".into(), serde_json::Value::Bool(true));
407    }
408
409    let children = children_by_edge(schema, op_id, "prop");
410
411    // Parameters.
412    let params: Vec<serde_json::Value> = children
413        .iter()
414        .filter(|(_, v)| v.kind == "parameter")
415        .map(|(edge, v)| {
416            let mut p = serde_json::Map::new();
417            p.insert(
418                "name".into(),
419                serde_json::Value::String(edge.name.as_deref().unwrap_or("unknown").to_string()),
420            );
421            p.insert("in".into(), serde_json::Value::String("query".into()));
422            if constraint_value(schema, &v.id, "required") == Some("true") {
423                p.insert("required".into(), serde_json::Value::Bool(true));
424            }
425            serde_json::Value::Object(p)
426        })
427        .collect();
428    if !params.is_empty() {
429        obj.insert("parameters".into(), serde_json::Value::Array(params));
430    }
431
432    // Responses.
433    let responses: Vec<_> = children
434        .iter()
435        .filter(|(_, v)| v.kind == "response")
436        .collect();
437    if !responses.is_empty() {
438        let mut resp_obj = serde_json::Map::new();
439        for (edge, _v) in &responses {
440            let status = edge.name.as_deref().unwrap_or("200");
441            let mut r = serde_json::Map::new();
442            r.insert(
443                "description".into(),
444                serde_json::Value::String(String::new()),
445            );
446            resp_obj.insert(status.to_string(), serde_json::Value::Object(r));
447        }
448        obj.insert("responses".into(), serde_json::Value::Object(resp_obj));
449    }
450
451    serde_json::Value::Object(obj)
452}
453
454/// Emit a schema vertex as a JSON Schema value.
455fn emit_schema_value(schema: &Schema, vertex_id: &str) -> serde_json::Value {
456    let Some(vertex) = schema.vertices.get(vertex_id) else {
457        return serde_json::Value::Object(serde_json::Map::new());
458    };
459
460    let mut obj = serde_json::Map::new();
461
462    let type_str = match vertex.kind.as_str() {
463        "string" => Some("string"),
464        "integer" => Some("integer"),
465        "number" => Some("number"),
466        "boolean" => Some("boolean"),
467        "array" => Some("array"),
468        "object" | "schema-object" => Some("object"),
469        _ => None,
470    };
471
472    if let Some(t) = type_str {
473        obj.insert("type".into(), serde_json::Value::String(t.into()));
474    }
475
476    for field in &[
477        "format",
478        "minimum",
479        "maximum",
480        "pattern",
481        "minLength",
482        "maxLength",
483        "minItems",
484        "maxItems",
485    ] {
486        if let Some(val) = constraint_value(schema, vertex_id, field) {
487            if let Ok(n) = val.parse::<f64>() {
488                obj.insert((*field).into(), serde_json::json!(n));
489            } else {
490                obj.insert((*field).into(), serde_json::Value::String(val.to_string()));
491            }
492        }
493    }
494
495    // Properties.
496    let props = children_by_edge(schema, vertex_id, "prop");
497    if !props.is_empty() {
498        let mut properties = serde_json::Map::new();
499        let mut required_list = Vec::new();
500        for (edge, _child) in &props {
501            let name = edge.name.as_deref().unwrap_or("");
502            let child_schema = emit_schema_value(schema, &edge.tgt);
503            properties.insert(name.to_string(), child_schema);
504            if constraint_value(schema, &edge.tgt, "required") == Some("true") {
505                required_list.push(serde_json::Value::String(name.to_string()));
506            }
507        }
508        obj.insert("properties".into(), serde_json::Value::Object(properties));
509        if !required_list.is_empty() {
510            obj.insert("required".into(), serde_json::Value::Array(required_list));
511        }
512    }
513
514    // Items.
515    let items = children_by_edge(schema, vertex_id, "items");
516    if let Some((edge, _)) = items.first() {
517        let items_schema = emit_schema_value(schema, &edge.tgt);
518        obj.insert("items".into(), items_schema);
519    }
520
521    serde_json::Value::Object(obj)
522}
523
524/// Well-formedness rules for `OpenAPI` edges.
525fn edge_rules() -> Vec<EdgeRule> {
526    vec![
527        EdgeRule {
528            edge_kind: "prop".into(),
529            src_kinds: vec![
530                "path".into(),
531                "operation".into(),
532                "parameter".into(),
533                "request-body".into(),
534                "response".into(),
535                "object".into(),
536                "schema-object".into(),
537            ],
538            tgt_kinds: vec![],
539        },
540        EdgeRule {
541            edge_kind: "items".into(),
542            src_kinds: vec!["array".into()],
543            tgt_kinds: vec![],
544        },
545        EdgeRule {
546            edge_kind: "variant".into(),
547            src_kinds: vec![],
548            tgt_kinds: vec![],
549        },
550        EdgeRule {
551            edge_kind: "ref".into(),
552            src_kinds: vec![],
553            tgt_kinds: vec![],
554        },
555    ]
556}
557
558#[cfg(test)]
559#[allow(clippy::expect_used, clippy::unwrap_used)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn protocol_def() {
565        let p = protocol();
566        assert_eq!(p.name, "openapi");
567        assert_eq!(p.schema_theory, "ThOpenAPISchema");
568        assert_eq!(p.instance_theory, "ThOpenAPIInstance");
569    }
570
571    #[test]
572    fn register_theories_works() {
573        let mut registry = HashMap::new();
574        register_theories(&mut registry);
575        assert!(registry.contains_key("ThOpenAPISchema"));
576        assert!(registry.contains_key("ThOpenAPIInstance"));
577    }
578
579    #[test]
580    fn parse_minimal() {
581        let doc = serde_json::json!({
582            "openapi": "3.0.0",
583            "info": {"title": "Test", "version": "1.0.0"},
584            "paths": {
585                "/users": {
586                    "get": {
587                        "parameters": [
588                            {"name": "limit", "in": "query", "schema": {"type": "integer"}}
589                        ],
590                        "responses": {
591                            "200": {
592                                "description": "OK",
593                                "content": {
594                                    "application/json": {
595                                        "schema": {
596                                            "type": "array",
597                                            "items": {"type": "string"}
598                                        }
599                                    }
600                                }
601                            }
602                        }
603                    }
604                }
605            }
606        });
607        let schema = parse_openapi(&doc).expect("should parse");
608        assert!(schema.has_vertex("path:/users"));
609        assert!(schema.has_vertex("path:/users:get"));
610    }
611
612    #[test]
613    fn emit_minimal() {
614        let doc = serde_json::json!({
615            "openapi": "3.0.0",
616            "info": {"title": "Test", "version": "1.0.0"},
617            "paths": {
618                "/pets": {
619                    "get": {
620                        "responses": {
621                            "200": {"description": "OK"}
622                        }
623                    }
624                }
625            }
626        });
627        let schema = parse_openapi(&doc).expect("should parse");
628        let emitted = emit_openapi(&schema).expect("should emit");
629        assert!(emitted.get("paths").is_some());
630    }
631
632    #[test]
633    fn roundtrip() {
634        let doc = serde_json::json!({
635            "openapi": "3.0.0",
636            "info": {"title": "Test", "version": "1.0.0"},
637            "paths": {
638                "/items": {
639                    "get": {
640                        "responses": {
641                            "200": {"description": "OK"}
642                        }
643                    }
644                }
645            }
646        });
647        let schema = parse_openapi(&doc).expect("parse");
648        let emitted = emit_openapi(&schema).expect("emit");
649        let schema2 = parse_openapi(&emitted).expect("re-parse");
650        assert_eq!(schema.vertices.len(), schema2.vertices.len());
651    }
652}