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