Skip to main content

apcore_toolkit/
openapi.rs

1// OpenAPI $ref resolution and schema extraction utilities.
2//
3// Standalone functions for resolving JSON $ref pointers and extracting
4// input/output schemas from OpenAPI operation objects.
5
6use serde_json::{json, Value};
7
8/// Resolve a JSON `$ref` pointer like `#/components/schemas/Foo`.
9///
10/// Returns the resolved schema, or an empty object on failure.
11pub fn resolve_ref(ref_string: &str, openapi_doc: &Value) -> Value {
12    if !ref_string.starts_with("#/") {
13        return json!({});
14    }
15
16    let parts: Vec<&str> = ref_string[2..].split('/').collect();
17    let mut current = openapi_doc;
18
19    for part in parts {
20        match current.get(part) {
21            Some(next) => current = next,
22            None => return json!({}),
23        }
24    }
25
26    if current.is_object() {
27        current.clone()
28    } else {
29        json!({})
30    }
31}
32
33/// If `schema` contains a `$ref`, resolve it; otherwise return as-is.
34pub fn resolve_schema(schema: &Value, openapi_doc: Option<&Value>) -> Value {
35    if let (Some(doc), Some(ref_str)) = (openapi_doc, schema.get("$ref").and_then(|v| v.as_str())) {
36        resolve_ref(ref_str, doc)
37    } else {
38        schema.clone()
39    }
40}
41
42/// Recursively resolve all `$ref` pointers in a schema.
43///
44/// Handles nested `$ref`, `allOf`, `anyOf`, `oneOf`, `items`, and `properties`.
45/// Depth-limited to 16 levels to prevent infinite recursion.
46pub fn deep_resolve_refs(schema: &Value, openapi_doc: &Value, depth: usize) -> Value {
47    if depth > 16 {
48        return schema.clone();
49    }
50
51    // Direct $ref resolution
52    if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) {
53        let resolved = resolve_ref(ref_str, openapi_doc);
54        return deep_resolve_refs(&resolved, openapi_doc, depth + 1);
55    }
56
57    let mut result = schema.clone();
58
59    if let Some(obj) = result.as_object_mut() {
60        // Resolve inside allOf/anyOf/oneOf
61        for key in &["allOf", "anyOf", "oneOf"] {
62            if let Some(Value::Array(items)) = obj.get(*key).cloned() {
63                let resolved: Vec<Value> = items
64                    .iter()
65                    .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
66                    .collect();
67                obj.insert(key.to_string(), Value::Array(resolved));
68            }
69        }
70
71        // Resolve array items
72        if let Some(items) = obj.get("items").cloned() {
73            if items.is_object() {
74                obj.insert(
75                    "items".to_string(),
76                    deep_resolve_refs(&items, openapi_doc, depth + 1),
77                );
78            }
79        }
80
81        // Resolve nested properties
82        if let Some(Value::Object(props)) = obj.get("properties").cloned() {
83            let resolved: serde_json::Map<String, Value> = props
84                .into_iter()
85                .map(|(k, v)| (k, deep_resolve_refs(&v, openapi_doc, depth + 1)))
86                .collect();
87            obj.insert("properties".to_string(), Value::Object(resolved));
88        }
89    }
90
91    result
92}
93
94/// Extract input schema from an OpenAPI operation.
95///
96/// Combines query/path parameters and request body properties into a
97/// single `{"type": "object", "properties": ..., "required": ...}` schema.
98pub fn extract_input_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
99    let mut properties = serde_json::Map::new();
100    let mut required: Vec<Value> = Vec::new();
101
102    // Query/path parameters
103    if let Some(Value::Array(params)) = operation.get("parameters") {
104        for param in params {
105            let in_value = param.get("in").and_then(|v| v.as_str()).unwrap_or("");
106            if in_value == "query" || in_value == "path" {
107                if let Some(name) = param.get("name").and_then(|v| v.as_str()) {
108                    let param_schema = param
109                        .get("schema")
110                        .cloned()
111                        .unwrap_or_else(|| json!({"type": "string"}));
112                    let resolved = resolve_schema(&param_schema, openapi_doc);
113                    properties.insert(name.to_string(), resolved);
114
115                    if param
116                        .get("required")
117                        .and_then(|v| v.as_bool())
118                        .unwrap_or(false)
119                    {
120                        required.push(Value::String(name.to_string()));
121                    }
122                }
123            }
124        }
125    }
126
127    // Request body
128    if let Some(body_schema) = operation
129        .get("requestBody")
130        .and_then(|rb| rb.get("content"))
131        .and_then(|c| c.get("application/json"))
132        .and_then(|jc| jc.get("schema"))
133    {
134        let resolved = resolve_schema(body_schema, openapi_doc);
135        if let Some(props) = resolved.get("properties").and_then(|p| p.as_object()) {
136            for (k, v) in props {
137                properties.insert(k.clone(), v.clone());
138            }
139        }
140        if let Some(req) = resolved.get("required").and_then(|r| r.as_array()) {
141            required.extend(req.iter().cloned());
142        }
143    }
144
145    // Recursively resolve $ref inside individual properties
146    if let Some(doc) = openapi_doc {
147        let resolved_props: serde_json::Map<String, Value> = properties
148            .into_iter()
149            .map(|(k, v)| (k, deep_resolve_refs(&v, doc, 0)))
150            .collect();
151        properties = resolved_props;
152    }
153
154    json!({
155        "type": "object",
156        "properties": Value::Object(properties),
157        "required": Value::Array(required),
158    })
159}
160
161/// Extract output schema from OpenAPI operation responses (200/201).
162///
163/// Returns the output JSON Schema, or a default empty object schema.
164pub fn extract_output_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
165    let responses = match operation.get("responses") {
166        Some(r) => r,
167        None => return json!({"type": "object", "properties": {}}),
168    };
169
170    for status_code in &["200", "201"] {
171        if let Some(schema) = responses
172            .get(*status_code)
173            .and_then(|r| r.get("content"))
174            .and_then(|c| c.get("application/json"))
175            .and_then(|jc| jc.get("schema"))
176        {
177            let mut resolved = resolve_schema(schema, openapi_doc);
178            if let Some(doc) = openapi_doc {
179                resolved = deep_resolve_refs(&resolved, doc, 0);
180            }
181            return resolved;
182        }
183    }
184
185    json!({"type": "object", "properties": {}})
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn test_resolve_ref_basic() {
194        let doc = json!({
195            "components": {
196                "schemas": {
197                    "User": {"type": "object", "properties": {"name": {"type": "string"}}}
198                }
199            }
200        });
201        let result = resolve_ref("#/components/schemas/User", &doc);
202        assert_eq!(result["type"], "object");
203        assert!(result["properties"]["name"].is_object());
204    }
205
206    #[test]
207    fn test_resolve_ref_not_found() {
208        let doc = json!({});
209        let result = resolve_ref("#/components/schemas/Missing", &doc);
210        assert_eq!(result, json!({}));
211    }
212
213    #[test]
214    fn test_resolve_ref_non_hash() {
215        let doc = json!({});
216        let result = resolve_ref("external.json#/foo", &doc);
217        assert_eq!(result, json!({}));
218    }
219
220    #[test]
221    fn test_resolve_schema_with_ref() {
222        let doc = json!({
223            "components": {"schemas": {"Foo": {"type": "string"}}}
224        });
225        let schema = json!({"$ref": "#/components/schemas/Foo"});
226        let result = resolve_schema(&schema, Some(&doc));
227        assert_eq!(result["type"], "string");
228    }
229
230    #[test]
231    fn test_resolve_schema_no_ref() {
232        let schema = json!({"type": "integer"});
233        let result = resolve_schema(&schema, None);
234        assert_eq!(result["type"], "integer");
235    }
236
237    #[test]
238    fn test_extract_input_schema_parameters() {
239        let op = json!({
240            "parameters": [
241                {"name": "user_id", "in": "path", "required": true, "schema": {"type": "integer"}},
242                {"name": "limit", "in": "query", "schema": {"type": "integer"}}
243            ]
244        });
245        let result = extract_input_schema(&op, None);
246        assert!(result["properties"]["user_id"].is_object());
247        assert!(result["properties"]["limit"].is_object());
248        let req = result["required"].as_array().unwrap();
249        assert!(req.contains(&Value::String("user_id".into())));
250        assert!(!req.contains(&Value::String("limit".into())));
251    }
252
253    #[test]
254    fn test_extract_input_schema_request_body() {
255        let op = json!({
256            "requestBody": {
257                "content": {
258                    "application/json": {
259                        "schema": {
260                            "type": "object",
261                            "properties": {"title": {"type": "string"}},
262                            "required": ["title"]
263                        }
264                    }
265                }
266            }
267        });
268        let result = extract_input_schema(&op, None);
269        assert_eq!(result["properties"]["title"]["type"], "string");
270        let req = result["required"].as_array().unwrap();
271        assert!(req.contains(&Value::String("title".into())));
272    }
273
274    #[test]
275    fn test_extract_input_schema_with_ref() {
276        let doc = json!({
277            "components": {
278                "schemas": {
279                    "TaskInput": {
280                        "type": "object",
281                        "properties": {"name": {"type": "string"}},
282                        "required": ["name"]
283                    }
284                }
285            }
286        });
287        let op = json!({
288            "requestBody": {
289                "content": {
290                    "application/json": {
291                        "schema": {"$ref": "#/components/schemas/TaskInput"}
292                    }
293                }
294            }
295        });
296        let result = extract_input_schema(&op, Some(&doc));
297        assert_eq!(result["properties"]["name"]["type"], "string");
298    }
299
300    #[test]
301    fn test_extract_output_schema_200() {
302        let op = json!({
303            "responses": {
304                "200": {
305                    "content": {
306                        "application/json": {
307                            "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}
308                        }
309                    }
310                }
311            }
312        });
313        let result = extract_output_schema(&op, None);
314        assert_eq!(result["properties"]["id"]["type"], "integer");
315    }
316
317    #[test]
318    fn test_extract_output_schema_fallback() {
319        let op = json!({"responses": {"404": {}}});
320        let result = extract_output_schema(&op, None);
321        assert_eq!(result["type"], "object");
322    }
323
324    #[test]
325    fn test_deep_resolve_nested_ref() {
326        let doc = json!({
327            "components": {
328                "schemas": {
329                    "Address": {"type": "object", "properties": {"city": {"type": "string"}}},
330                    "User": {
331                        "type": "object",
332                        "properties": {
333                            "address": {"$ref": "#/components/schemas/Address"}
334                        }
335                    }
336                }
337            }
338        });
339        let schema = json!({"$ref": "#/components/schemas/User"});
340        let result = deep_resolve_refs(&schema, &doc, 0);
341        assert_eq!(
342            result["properties"]["address"]["properties"]["city"]["type"],
343            "string"
344        );
345    }
346
347    #[test]
348    fn test_deep_resolve_depth_limit() {
349        // Self-referencing schema should not cause stack overflow
350        let doc = json!({
351            "components": {
352                "schemas": {
353                    "Recursive": {
354                        "type": "object",
355                        "properties": {
356                            "child": {"$ref": "#/components/schemas/Recursive"}
357                        }
358                    }
359                }
360            }
361        });
362        let schema = json!({"$ref": "#/components/schemas/Recursive"});
363        // Should terminate without panic
364        let _ = deep_resolve_refs(&schema, &doc, 0);
365    }
366
367    #[test]
368    fn test_resolve_ref_to_non_dict() {
369        // $ref pointing to a string value returns {}
370        let doc = json!({
371            "components": {
372                "schemas": {
373                    "JustAString": "hello"
374                }
375            }
376        });
377        let result = resolve_ref("#/components/schemas/JustAString", &doc);
378        assert_eq!(result, json!({}));
379
380        // $ref pointing to a number value returns {}
381        let doc2 = json!({
382            "components": {
383                "schemas": {
384                    "JustANumber": 42
385                }
386            }
387        });
388        let result2 = resolve_ref("#/components/schemas/JustANumber", &doc2);
389        assert_eq!(result2, json!({}));
390    }
391
392    #[test]
393    fn test_resolve_ref_through_missing_path() {
394        // $ref with intermediate missing keys returns {}
395        let doc = json!({
396            "components": {}
397        });
398        let result = resolve_ref("#/components/schemas/Missing", &doc);
399        assert_eq!(result, json!({}));
400    }
401
402    #[test]
403    fn test_resolve_schema_no_openapi_doc() {
404        // None openapi_doc returns schema as-is even if it has a $ref
405        let schema = json!({"$ref": "#/components/schemas/Foo", "type": "string"});
406        let result = resolve_schema(&schema, None);
407        assert_eq!(result, schema);
408    }
409
410    #[test]
411    fn test_deep_resolve_refs_in_allof() {
412        let doc = json!({
413            "components": {
414                "schemas": {
415                    "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
416                    "Extra": {"type": "object", "properties": {"tag": {"type": "string"}}}
417                }
418            }
419        });
420        let schema = json!({
421            "allOf": [
422                {"$ref": "#/components/schemas/Base"},
423                {"$ref": "#/components/schemas/Extra"}
424            ]
425        });
426        let result = deep_resolve_refs(&schema, &doc, 0);
427        let all_of = result["allOf"].as_array().unwrap();
428        assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
429        assert_eq!(all_of[1]["properties"]["tag"]["type"], "string");
430    }
431
432    #[test]
433    fn test_deep_resolve_refs_in_anyof() {
434        let doc = json!({
435            "components": {
436                "schemas": {
437                    "Cat": {"type": "object", "properties": {"purrs": {"type": "boolean"}}},
438                    "Dog": {"type": "object", "properties": {"barks": {"type": "boolean"}}}
439                }
440            }
441        });
442        let schema = json!({
443            "anyOf": [
444                {"$ref": "#/components/schemas/Cat"},
445                {"$ref": "#/components/schemas/Dog"}
446            ]
447        });
448        let result = deep_resolve_refs(&schema, &doc, 0);
449        let any_of = result["anyOf"].as_array().unwrap();
450        assert_eq!(any_of[0]["properties"]["purrs"]["type"], "boolean");
451        assert_eq!(any_of[1]["properties"]["barks"]["type"], "boolean");
452    }
453
454    #[test]
455    fn test_deep_resolve_refs_in_items() {
456        let doc = json!({
457            "components": {
458                "schemas": {
459                    "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
460                }
461            }
462        });
463        let schema = json!({
464            "type": "array",
465            "items": {"$ref": "#/components/schemas/Item"}
466        });
467        let result = deep_resolve_refs(&schema, &doc, 0);
468        assert_eq!(result["items"]["properties"]["name"]["type"], "string");
469    }
470
471    #[test]
472    fn test_deep_resolve_no_mutation() {
473        let doc = json!({
474            "components": {
475                "schemas": {
476                    "Addr": {"type": "object", "properties": {"city": {"type": "string"}}}
477                }
478            }
479        });
480        let doc_before = doc.clone();
481        let schema = json!({
482            "type": "object",
483            "properties": {
484                "address": {"$ref": "#/components/schemas/Addr"}
485            }
486        });
487        let _result = deep_resolve_refs(&schema, &doc, 0);
488        assert_eq!(doc, doc_before, "openapi_doc must not be mutated");
489    }
490
491    #[test]
492    fn test_extract_input_schema_empty_operation() {
493        let op = json!({});
494        let result = extract_input_schema(&op, None);
495        assert_eq!(result["type"], "object");
496        assert!(result["properties"].as_object().unwrap().is_empty());
497        assert!(result["required"].as_array().unwrap().is_empty());
498    }
499
500    #[test]
501    fn test_extract_input_schema_ref_in_param() {
502        let doc = json!({
503            "components": {
504                "schemas": {
505                    "IdType": {"type": "integer", "format": "int64"}
506                }
507            }
508        });
509        let op = json!({
510            "parameters": [
511                {
512                    "name": "user_id",
513                    "in": "path",
514                    "required": true,
515                    "schema": {"$ref": "#/components/schemas/IdType"}
516                }
517            ]
518        });
519        let result = extract_input_schema(&op, Some(&doc));
520        assert_eq!(result["properties"]["user_id"]["type"], "integer");
521        assert_eq!(result["properties"]["user_id"]["format"], "int64");
522    }
523
524    #[test]
525    fn test_extract_input_schema_nested_ref_in_body() {
526        let doc = json!({
527            "components": {
528                "schemas": {
529                    "Address": {"type": "object", "properties": {"zip": {"type": "string"}}}
530                }
531            }
532        });
533        let op = json!({
534            "requestBody": {
535                "content": {
536                    "application/json": {
537                        "schema": {
538                            "type": "object",
539                            "properties": {
540                                "address": {"$ref": "#/components/schemas/Address"}
541                            }
542                        }
543                    }
544                }
545            }
546        });
547        let result = extract_input_schema(&op, Some(&doc));
548        assert_eq!(
549            result["properties"]["address"]["properties"]["zip"]["type"],
550            "string"
551        );
552    }
553
554    #[test]
555    fn test_extract_output_schema_201() {
556        let op = json!({
557            "responses": {
558                "201": {
559                    "content": {
560                        "application/json": {
561                            "schema": {
562                                "type": "object",
563                                "properties": {"id": {"type": "integer"}}
564                            }
565                        }
566                    }
567                }
568            }
569        });
570        let result = extract_output_schema(&op, None);
571        assert_eq!(result["properties"]["id"]["type"], "integer");
572    }
573
574    #[test]
575    fn test_extract_output_schema_200_preferred() {
576        let op = json!({
577            "responses": {
578                "200": {
579                    "content": {
580                        "application/json": {
581                            "schema": {
582                                "type": "object",
583                                "properties": {"from200": {"type": "string"}}
584                            }
585                        }
586                    }
587                },
588                "201": {
589                    "content": {
590                        "application/json": {
591                            "schema": {
592                                "type": "object",
593                                "properties": {"from201": {"type": "string"}}
594                            }
595                        }
596                    }
597                }
598            }
599        });
600        let result = extract_output_schema(&op, None);
601        assert!(
602            result["properties"]
603                .as_object()
604                .unwrap()
605                .contains_key("from200"),
606            "200 should be preferred over 201"
607        );
608        assert!(
609            !result["properties"]
610                .as_object()
611                .unwrap()
612                .contains_key("from201"),
613            "201 should not be used when 200 exists"
614        );
615    }
616
617    #[test]
618    fn test_extract_output_schema_array_with_ref_items() {
619        let doc = json!({
620            "components": {
621                "schemas": {
622                    "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
623                }
624            }
625        });
626        let op = json!({
627            "responses": {
628                "200": {
629                    "content": {
630                        "application/json": {
631                            "schema": {
632                                "type": "array",
633                                "items": {"$ref": "#/components/schemas/Item"}
634                            }
635                        }
636                    }
637                }
638            }
639        });
640        let result = extract_output_schema(&op, Some(&doc));
641        assert_eq!(result["type"], "array");
642        assert_eq!(result["items"]["properties"]["name"]["type"], "string");
643    }
644
645    #[test]
646    fn test_extract_output_schema_allof() {
647        let doc = json!({
648            "components": {
649                "schemas": {
650                    "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
651                    "Meta": {"type": "object", "properties": {"created": {"type": "string"}}}
652                }
653            }
654        });
655        let op = json!({
656            "responses": {
657                "200": {
658                    "content": {
659                        "application/json": {
660                            "schema": {
661                                "allOf": [
662                                    {"$ref": "#/components/schemas/Base"},
663                                    {"$ref": "#/components/schemas/Meta"}
664                                ]
665                            }
666                        }
667                    }
668                }
669            }
670        });
671        let result = extract_output_schema(&op, Some(&doc));
672        let all_of = result["allOf"].as_array().unwrap();
673        assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
674        assert_eq!(all_of[1]["properties"]["created"]["type"], "string");
675    }
676
677    #[test]
678    fn test_extract_output_schema_nested_ref() {
679        let doc = json!({
680            "components": {
681                "schemas": {
682                    "Inner": {"type": "object", "properties": {"val": {"type": "number"}}}
683                }
684            }
685        });
686        let op = json!({
687            "responses": {
688                "200": {
689                    "content": {
690                        "application/json": {
691                            "schema": {
692                                "type": "object",
693                                "properties": {
694                                    "nested": {"$ref": "#/components/schemas/Inner"}
695                                }
696                            }
697                        }
698                    }
699                }
700            }
701        });
702        let result = extract_output_schema(&op, Some(&doc));
703        assert_eq!(
704            result["properties"]["nested"]["properties"]["val"]["type"],
705            "number"
706        );
707    }
708
709    #[test]
710    fn test_extract_output_schema_empty_responses() {
711        // No responses key at all returns default schema
712        let op = json!({"operationId": "noResponses"});
713        let result = extract_output_schema(&op, None);
714        assert_eq!(result["type"], "object");
715        assert!(result["properties"].as_object().unwrap().is_empty());
716    }
717}