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};
7use tracing::warn;
8
9/// Decode a JSON Pointer token per RFC 6901.
10///
11/// `~1` → `/` and `~0` → `~` (order matters — `~1` must be decoded before `~0`
12/// to prevent `~01` from becoming `/` instead of `~1`).
13fn decode_pointer_token(token: &str) -> std::borrow::Cow<'_, str> {
14    if token.contains('~') {
15        std::borrow::Cow::Owned(token.replace("~1", "/").replace("~0", "~"))
16    } else {
17        std::borrow::Cow::Borrowed(token)
18    }
19}
20
21/// Resolve a JSON `$ref` pointer like `#/components/schemas/Foo`.
22///
23/// Decodes RFC 6901 escape sequences in path segments (`~1` → `/`, `~0` → `~`).
24/// Returns the resolved schema, or an empty object on failure.
25pub fn resolve_ref(ref_string: &str, openapi_doc: &Value) -> Value {
26    if !ref_string.starts_with("#/") {
27        warn!(
28            ref_string,
29            "resolve_ref: ignoring non-local $ref (must start with '#/')"
30        );
31        return json!({});
32    }
33
34    let parts: Vec<&str> = ref_string[2..].split('/').collect();
35    let mut current = openapi_doc;
36
37    for part in parts {
38        let decoded = decode_pointer_token(part);
39        match current.get(decoded.as_ref()) {
40            Some(next) => current = next,
41            None => {
42                warn!(
43                    ref_string,
44                    part, "resolve_ref: path segment not found in document"
45                );
46                return json!({});
47            }
48        }
49    }
50
51    if current.is_object() {
52        current.clone()
53    } else {
54        warn!(
55            ref_string,
56            "resolve_ref: resolved value is not an object — returning empty schema"
57        );
58        json!({})
59    }
60}
61
62/// If `schema` contains a `$ref`, resolve it; otherwise return as-is.
63pub fn resolve_schema(schema: &Value, openapi_doc: Option<&Value>) -> Value {
64    if let (Some(doc), Some(ref_str)) = (openapi_doc, schema.get("$ref").and_then(|v| v.as_str())) {
65        resolve_ref(ref_str, doc)
66    } else {
67        schema.clone()
68    }
69}
70
71/// Recursively resolve all `$ref` pointers in a schema.
72///
73/// Handles `$ref`, `allOf`, `anyOf`, `oneOf`, `items`, `prefixItems`,
74/// `properties`, `patternProperties`, `additionalProperties`, `not`,
75/// and `if`/`then`/`else`.
76/// Depth-limited to 16 levels to prevent infinite recursion.
77pub fn deep_resolve_refs(schema: &Value, openapi_doc: &Value, depth: usize) -> Value {
78    if depth >= 16 {
79        warn!(depth, "deep_resolve_refs: depth limit reached — returning schema as-is to prevent infinite recursion");
80        return schema.clone();
81    }
82
83    // Direct $ref resolution
84    if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) {
85        let resolved = resolve_ref(ref_str, openapi_doc);
86        return deep_resolve_refs(&resolved, openapi_doc, depth + 1);
87    }
88
89    let mut result = schema.clone();
90
91    if let Some(obj) = result.as_object_mut() {
92        // Resolve inside allOf/anyOf/oneOf
93        for key in &["allOf", "anyOf", "oneOf"] {
94            if let Some(Value::Array(items)) = obj.get(*key).cloned() {
95                let resolved: Vec<Value> = items
96                    .iter()
97                    .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
98                    .collect();
99                obj.insert(key.to_string(), Value::Array(resolved));
100            }
101        }
102
103        // Resolve array items (single schema) and tuple items (array of schemas)
104        if let Some(items) = obj.get("items").cloned() {
105            if items.is_object() {
106                obj.insert(
107                    "items".to_string(),
108                    deep_resolve_refs(&items, openapi_doc, depth + 1),
109                );
110            } else if let Value::Array(arr) = items {
111                let resolved: Vec<Value> = arr
112                    .iter()
113                    .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
114                    .collect();
115                obj.insert("items".to_string(), Value::Array(resolved));
116            }
117        }
118
119        // Resolve prefixItems (JSON Schema 2020-12 tuple items)
120        if let Some(Value::Array(prefix)) = obj.get("prefixItems").cloned() {
121            let resolved: Vec<Value> = prefix
122                .iter()
123                .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
124                .collect();
125            obj.insert("prefixItems".to_string(), Value::Array(resolved));
126        }
127
128        // Resolve nested properties
129        if let Some(Value::Object(props)) = obj.get("properties").cloned() {
130            let resolved: serde_json::Map<String, Value> = props
131                .into_iter()
132                .map(|(k, v)| (k, deep_resolve_refs(&v, openapi_doc, depth + 1)))
133                .collect();
134            obj.insert("properties".to_string(), Value::Object(resolved));
135        }
136
137        // Resolve patternProperties (same shape as properties but keyed by regex)
138        if let Some(Value::Object(pat_props)) = obj.get("patternProperties").cloned() {
139            let resolved: serde_json::Map<String, Value> = pat_props
140                .into_iter()
141                .map(|(k, v)| (k, deep_resolve_refs(&v, openapi_doc, depth + 1)))
142                .collect();
143            obj.insert("patternProperties".to_string(), Value::Object(resolved));
144        }
145
146        // Resolve additionalProperties when it is a schema (not a boolean)
147        if let Some(add_props) = obj.get("additionalProperties").cloned() {
148            if add_props.is_object() {
149                obj.insert(
150                    "additionalProperties".to_string(),
151                    deep_resolve_refs(&add_props, openapi_doc, depth + 1),
152                );
153            }
154        }
155
156        // Resolve not / if / then / else (applicator keywords)
157        for key in &["not", "if", "then", "else"] {
158            if let Some(sub) = obj.get(*key).cloned() {
159                if sub.is_object() {
160                    obj.insert(
161                        key.to_string(),
162                        deep_resolve_refs(&sub, openapi_doc, depth + 1),
163                    );
164                }
165            }
166        }
167    }
168
169    result
170}
171
172/// Extract input schema from an OpenAPI operation.
173///
174/// Combines query/path parameters and request body properties into a
175/// single `{"type": "object", "properties": ..., "required": ...}` schema.
176pub fn extract_input_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
177    let mut properties = serde_json::Map::new();
178    let mut required: Vec<Value> = Vec::new();
179
180    // Query/path parameters
181    if let Some(Value::Array(params)) = operation.get("parameters") {
182        for param in params {
183            let in_value = param.get("in").and_then(|v| v.as_str()).unwrap_or("");
184            if in_value == "query" || in_value == "path" {
185                if let Some(name) = param.get("name").and_then(|v| v.as_str()) {
186                    let param_schema = param
187                        .get("schema")
188                        .cloned()
189                        .unwrap_or_else(|| json!({"type": "string"}));
190                    let resolved = resolve_schema(&param_schema, openapi_doc);
191                    properties.insert(name.to_string(), resolved);
192
193                    if param
194                        .get("required")
195                        .and_then(|v| v.as_bool())
196                        .unwrap_or(false)
197                    {
198                        required.push(Value::String(name.to_string()));
199                    }
200                }
201            }
202        }
203    }
204
205    // Request body
206    if let Some(body_schema) = operation
207        .get("requestBody")
208        .and_then(|rb| rb.get("content"))
209        .and_then(|c| c.get("application/json"))
210        .and_then(|jc| jc.get("schema"))
211    {
212        let resolved = resolve_schema(body_schema, openapi_doc);
213        if let Some(props) = resolved.get("properties").and_then(|p| p.as_object()) {
214            for (k, v) in props {
215                properties.insert(k.clone(), v.clone());
216            }
217        }
218        if let Some(req) = resolved.get("required").and_then(|r| r.as_array()) {
219            required.extend(req.iter().cloned());
220        }
221    }
222
223    // Recursively resolve $ref inside individual properties
224    if let Some(doc) = openapi_doc {
225        let resolved_props: serde_json::Map<String, Value> = properties
226            .into_iter()
227            .map(|(k, v)| (k, deep_resolve_refs(&v, doc, 0)))
228            .collect();
229        properties = resolved_props;
230    }
231
232    // Deduplicate required list while preserving order (params + body can overlap)
233    let mut seen = std::collections::HashSet::new();
234    required.retain(|v| {
235        let key = v.as_str().unwrap_or("").to_string();
236        seen.insert(key)
237    });
238
239    json!({
240        "type": "object",
241        "properties": Value::Object(properties),
242        "required": Value::Array(required),
243    })
244}
245
246/// Extract output schema from OpenAPI operation responses (200/201).
247///
248/// Returns the output JSON Schema, or a default empty object schema.
249pub fn extract_output_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
250    let responses = match operation.get("responses") {
251        Some(r) => r,
252        None => return json!({"type": "object", "properties": {}}),
253    };
254
255    for status_code in &["200", "201"] {
256        if let Some(schema) = responses
257            .get(*status_code)
258            .and_then(|r| r.get("content"))
259            .and_then(|c| c.get("application/json"))
260            .and_then(|jc| jc.get("schema"))
261        {
262            let mut resolved = resolve_schema(schema, openapi_doc);
263            if let Some(doc) = openapi_doc {
264                resolved = deep_resolve_refs(&resolved, doc, 0);
265            }
266            return resolved;
267        }
268    }
269
270    json!({"type": "object", "properties": {}})
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_resolve_ref_rfc6901_slash_in_key() {
279        // Key name "schemas/v2" encoded as "schemas~1v2" in the pointer.
280        let doc = json!({
281            "schemas/v2": {"type": "string"}
282        });
283        let result = resolve_ref("#/schemas~1v2", &doc);
284        assert_eq!(result["type"], "string");
285    }
286
287    #[test]
288    fn test_resolve_ref_rfc6901_tilde_in_key() {
289        // Key name "a~b" encoded as "a~0b" in the pointer.
290        let doc = json!({
291            "a~b": {"type": "number"}
292        });
293        let result = resolve_ref("#/a~0b", &doc);
294        assert_eq!(result["type"], "number");
295    }
296
297    #[test]
298    fn test_resolve_ref_rfc6901_combined_escapes() {
299        // "~01" should decode to "~1" (not "/"), per RFC 6901 §3.
300        let doc = json!({
301            "~1": {"type": "boolean"}
302        });
303        let result = resolve_ref("#/~01", &doc);
304        assert_eq!(result["type"], "boolean");
305    }
306
307    #[test]
308    fn test_deep_resolve_refs_additional_properties() {
309        let doc = json!({
310            "components": {
311                "schemas": {
312                    "Tag": {"type": "string"}
313                }
314            }
315        });
316        let schema = json!({
317            "type": "object",
318            "additionalProperties": {"$ref": "#/components/schemas/Tag"}
319        });
320        let result = deep_resolve_refs(&schema, &doc, 0);
321        assert_eq!(result["additionalProperties"]["type"], "string");
322    }
323
324    #[test]
325    fn test_deep_resolve_refs_not_keyword() {
326        let doc = json!({
327            "components": {
328                "schemas": {
329                    "Forbidden": {"type": "string"}
330                }
331            }
332        });
333        let schema = json!({
334            "not": {"$ref": "#/components/schemas/Forbidden"}
335        });
336        let result = deep_resolve_refs(&schema, &doc, 0);
337        assert_eq!(result["not"]["type"], "string");
338    }
339
340    #[test]
341    fn test_deep_resolve_refs_if_then_else() {
342        let doc = json!({
343            "components": {
344                "schemas": {
345                    "Condition": {"type": "boolean"},
346                    "TrueCase": {"type": "string"},
347                    "FalseCase": {"type": "number"}
348                }
349            }
350        });
351        let schema = json!({
352            "if": {"$ref": "#/components/schemas/Condition"},
353            "then": {"$ref": "#/components/schemas/TrueCase"},
354            "else": {"$ref": "#/components/schemas/FalseCase"}
355        });
356        let result = deep_resolve_refs(&schema, &doc, 0);
357        assert_eq!(result["if"]["type"], "boolean");
358        assert_eq!(result["then"]["type"], "string");
359        assert_eq!(result["else"]["type"], "number");
360    }
361
362    #[test]
363    fn test_extract_input_schema_deduplicates_required() {
364        // Both params and body declare the same field as required — should dedup.
365        let doc = json!({
366            "components": {
367                "schemas": {
368                    "Body": {
369                        "type": "object",
370                        "properties": {"id": {"type": "integer"}},
371                        "required": ["id"]
372                    }
373                }
374            }
375        });
376        let op = json!({
377            "parameters": [
378                {"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}
379            ],
380            "requestBody": {
381                "content": {
382                    "application/json": {
383                        "schema": {"$ref": "#/components/schemas/Body"}
384                    }
385                }
386            }
387        });
388        let result = extract_input_schema(&op, Some(&doc));
389        let req = result["required"].as_array().unwrap();
390        let id_count = req.iter().filter(|v| v.as_str() == Some("id")).count();
391        assert_eq!(id_count, 1, "required list should deduplicate; got {req:?}");
392    }
393
394    #[test]
395    fn test_resolve_ref_basic() {
396        let doc = json!({
397            "components": {
398                "schemas": {
399                    "User": {"type": "object", "properties": {"name": {"type": "string"}}}
400                }
401            }
402        });
403        let result = resolve_ref("#/components/schemas/User", &doc);
404        assert_eq!(result["type"], "object");
405        assert!(result["properties"]["name"].is_object());
406    }
407
408    #[test]
409    fn test_resolve_ref_not_found() {
410        let doc = json!({});
411        let result = resolve_ref("#/components/schemas/Missing", &doc);
412        assert_eq!(result, json!({}));
413    }
414
415    #[test]
416    fn test_resolve_ref_non_hash() {
417        let doc = json!({});
418        let result = resolve_ref("external.json#/foo", &doc);
419        assert_eq!(result, json!({}));
420    }
421
422    #[test]
423    fn test_resolve_schema_with_ref() {
424        let doc = json!({
425            "components": {"schemas": {"Foo": {"type": "string"}}}
426        });
427        let schema = json!({"$ref": "#/components/schemas/Foo"});
428        let result = resolve_schema(&schema, Some(&doc));
429        assert_eq!(result["type"], "string");
430    }
431
432    #[test]
433    fn test_resolve_schema_no_ref() {
434        let schema = json!({"type": "integer"});
435        let result = resolve_schema(&schema, None);
436        assert_eq!(result["type"], "integer");
437    }
438
439    #[test]
440    fn test_extract_input_schema_parameters() {
441        let op = json!({
442            "parameters": [
443                {"name": "user_id", "in": "path", "required": true, "schema": {"type": "integer"}},
444                {"name": "limit", "in": "query", "schema": {"type": "integer"}}
445            ]
446        });
447        let result = extract_input_schema(&op, None);
448        assert!(result["properties"]["user_id"].is_object());
449        assert!(result["properties"]["limit"].is_object());
450        let req = result["required"].as_array().unwrap();
451        assert!(req.contains(&Value::String("user_id".into())));
452        assert!(!req.contains(&Value::String("limit".into())));
453    }
454
455    #[test]
456    fn test_extract_input_schema_request_body() {
457        let op = json!({
458            "requestBody": {
459                "content": {
460                    "application/json": {
461                        "schema": {
462                            "type": "object",
463                            "properties": {"title": {"type": "string"}},
464                            "required": ["title"]
465                        }
466                    }
467                }
468            }
469        });
470        let result = extract_input_schema(&op, None);
471        assert_eq!(result["properties"]["title"]["type"], "string");
472        let req = result["required"].as_array().unwrap();
473        assert!(req.contains(&Value::String("title".into())));
474    }
475
476    #[test]
477    fn test_extract_input_schema_with_ref() {
478        let doc = json!({
479            "components": {
480                "schemas": {
481                    "TaskInput": {
482                        "type": "object",
483                        "properties": {"name": {"type": "string"}},
484                        "required": ["name"]
485                    }
486                }
487            }
488        });
489        let op = json!({
490            "requestBody": {
491                "content": {
492                    "application/json": {
493                        "schema": {"$ref": "#/components/schemas/TaskInput"}
494                    }
495                }
496            }
497        });
498        let result = extract_input_schema(&op, Some(&doc));
499        assert_eq!(result["properties"]["name"]["type"], "string");
500    }
501
502    #[test]
503    fn test_extract_output_schema_200() {
504        let op = json!({
505            "responses": {
506                "200": {
507                    "content": {
508                        "application/json": {
509                            "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}
510                        }
511                    }
512                }
513            }
514        });
515        let result = extract_output_schema(&op, None);
516        assert_eq!(result["properties"]["id"]["type"], "integer");
517    }
518
519    #[test]
520    fn test_extract_output_schema_fallback() {
521        let op = json!({"responses": {"404": {}}});
522        let result = extract_output_schema(&op, None);
523        assert_eq!(result["type"], "object");
524    }
525
526    #[test]
527    fn test_deep_resolve_nested_ref() {
528        let doc = json!({
529            "components": {
530                "schemas": {
531                    "Address": {"type": "object", "properties": {"city": {"type": "string"}}},
532                    "User": {
533                        "type": "object",
534                        "properties": {
535                            "address": {"$ref": "#/components/schemas/Address"}
536                        }
537                    }
538                }
539            }
540        });
541        let schema = json!({"$ref": "#/components/schemas/User"});
542        let result = deep_resolve_refs(&schema, &doc, 0);
543        assert_eq!(
544            result["properties"]["address"]["properties"]["city"]["type"],
545            "string"
546        );
547    }
548
549    #[test]
550    fn test_deep_resolve_depth_limit() {
551        // Self-referencing schema should not cause stack overflow
552        let doc = json!({
553            "components": {
554                "schemas": {
555                    "Recursive": {
556                        "type": "object",
557                        "properties": {
558                            "child": {"$ref": "#/components/schemas/Recursive"}
559                        }
560                    }
561                }
562            }
563        });
564        let schema = json!({"$ref": "#/components/schemas/Recursive"});
565        // Should terminate without panic
566        let _ = deep_resolve_refs(&schema, &doc, 0);
567    }
568
569    #[test]
570    fn test_resolve_ref_to_non_dict() {
571        // $ref pointing to a string value returns {}
572        let doc = json!({
573            "components": {
574                "schemas": {
575                    "JustAString": "hello"
576                }
577            }
578        });
579        let result = resolve_ref("#/components/schemas/JustAString", &doc);
580        assert_eq!(result, json!({}));
581
582        // $ref pointing to a number value returns {}
583        let doc2 = json!({
584            "components": {
585                "schemas": {
586                    "JustANumber": 42
587                }
588            }
589        });
590        let result2 = resolve_ref("#/components/schemas/JustANumber", &doc2);
591        assert_eq!(result2, json!({}));
592    }
593
594    #[test]
595    fn test_resolve_ref_through_missing_path() {
596        // $ref with intermediate missing keys returns {}
597        let doc = json!({
598            "components": {}
599        });
600        let result = resolve_ref("#/components/schemas/Missing", &doc);
601        assert_eq!(result, json!({}));
602    }
603
604    #[test]
605    fn test_resolve_schema_no_openapi_doc() {
606        // None openapi_doc returns schema as-is even if it has a $ref
607        let schema = json!({"$ref": "#/components/schemas/Foo", "type": "string"});
608        let result = resolve_schema(&schema, None);
609        assert_eq!(result, schema);
610    }
611
612    #[test]
613    fn test_deep_resolve_refs_in_allof() {
614        let doc = json!({
615            "components": {
616                "schemas": {
617                    "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
618                    "Extra": {"type": "object", "properties": {"tag": {"type": "string"}}}
619                }
620            }
621        });
622        let schema = json!({
623            "allOf": [
624                {"$ref": "#/components/schemas/Base"},
625                {"$ref": "#/components/schemas/Extra"}
626            ]
627        });
628        let result = deep_resolve_refs(&schema, &doc, 0);
629        let all_of = result["allOf"].as_array().unwrap();
630        assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
631        assert_eq!(all_of[1]["properties"]["tag"]["type"], "string");
632    }
633
634    #[test]
635    fn test_deep_resolve_refs_in_anyof() {
636        let doc = json!({
637            "components": {
638                "schemas": {
639                    "Cat": {"type": "object", "properties": {"purrs": {"type": "boolean"}}},
640                    "Dog": {"type": "object", "properties": {"barks": {"type": "boolean"}}}
641                }
642            }
643        });
644        let schema = json!({
645            "anyOf": [
646                {"$ref": "#/components/schemas/Cat"},
647                {"$ref": "#/components/schemas/Dog"}
648            ]
649        });
650        let result = deep_resolve_refs(&schema, &doc, 0);
651        let any_of = result["anyOf"].as_array().unwrap();
652        assert_eq!(any_of[0]["properties"]["purrs"]["type"], "boolean");
653        assert_eq!(any_of[1]["properties"]["barks"]["type"], "boolean");
654    }
655
656    #[test]
657    fn test_deep_resolve_refs_in_items() {
658        let doc = json!({
659            "components": {
660                "schemas": {
661                    "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
662                }
663            }
664        });
665        let schema = json!({
666            "type": "array",
667            "items": {"$ref": "#/components/schemas/Item"}
668        });
669        let result = deep_resolve_refs(&schema, &doc, 0);
670        assert_eq!(result["items"]["properties"]["name"]["type"], "string");
671    }
672
673    #[test]
674    fn test_deep_resolve_no_mutation() {
675        let doc = json!({
676            "components": {
677                "schemas": {
678                    "Addr": {"type": "object", "properties": {"city": {"type": "string"}}}
679                }
680            }
681        });
682        let doc_before = doc.clone();
683        let schema = json!({
684            "type": "object",
685            "properties": {
686                "address": {"$ref": "#/components/schemas/Addr"}
687            }
688        });
689        let _result = deep_resolve_refs(&schema, &doc, 0);
690        assert_eq!(doc, doc_before, "openapi_doc must not be mutated");
691    }
692
693    #[test]
694    fn test_extract_input_schema_empty_operation() {
695        let op = json!({});
696        let result = extract_input_schema(&op, None);
697        assert_eq!(result["type"], "object");
698        assert!(result["properties"].as_object().unwrap().is_empty());
699        assert!(result["required"].as_array().unwrap().is_empty());
700    }
701
702    #[test]
703    fn test_extract_input_schema_ref_in_param() {
704        let doc = json!({
705            "components": {
706                "schemas": {
707                    "IdType": {"type": "integer", "format": "int64"}
708                }
709            }
710        });
711        let op = json!({
712            "parameters": [
713                {
714                    "name": "user_id",
715                    "in": "path",
716                    "required": true,
717                    "schema": {"$ref": "#/components/schemas/IdType"}
718                }
719            ]
720        });
721        let result = extract_input_schema(&op, Some(&doc));
722        assert_eq!(result["properties"]["user_id"]["type"], "integer");
723        assert_eq!(result["properties"]["user_id"]["format"], "int64");
724    }
725
726    #[test]
727    fn test_extract_input_schema_nested_ref_in_body() {
728        let doc = json!({
729            "components": {
730                "schemas": {
731                    "Address": {"type": "object", "properties": {"zip": {"type": "string"}}}
732                }
733            }
734        });
735        let op = json!({
736            "requestBody": {
737                "content": {
738                    "application/json": {
739                        "schema": {
740                            "type": "object",
741                            "properties": {
742                                "address": {"$ref": "#/components/schemas/Address"}
743                            }
744                        }
745                    }
746                }
747            }
748        });
749        let result = extract_input_schema(&op, Some(&doc));
750        assert_eq!(
751            result["properties"]["address"]["properties"]["zip"]["type"],
752            "string"
753        );
754    }
755
756    #[test]
757    fn test_extract_output_schema_201() {
758        let op = json!({
759            "responses": {
760                "201": {
761                    "content": {
762                        "application/json": {
763                            "schema": {
764                                "type": "object",
765                                "properties": {"id": {"type": "integer"}}
766                            }
767                        }
768                    }
769                }
770            }
771        });
772        let result = extract_output_schema(&op, None);
773        assert_eq!(result["properties"]["id"]["type"], "integer");
774    }
775
776    #[test]
777    fn test_extract_output_schema_200_preferred() {
778        let op = json!({
779            "responses": {
780                "200": {
781                    "content": {
782                        "application/json": {
783                            "schema": {
784                                "type": "object",
785                                "properties": {"from200": {"type": "string"}}
786                            }
787                        }
788                    }
789                },
790                "201": {
791                    "content": {
792                        "application/json": {
793                            "schema": {
794                                "type": "object",
795                                "properties": {"from201": {"type": "string"}}
796                            }
797                        }
798                    }
799                }
800            }
801        });
802        let result = extract_output_schema(&op, None);
803        assert!(
804            result["properties"]
805                .as_object()
806                .unwrap()
807                .contains_key("from200"),
808            "200 should be preferred over 201"
809        );
810        assert!(
811            !result["properties"]
812                .as_object()
813                .unwrap()
814                .contains_key("from201"),
815            "201 should not be used when 200 exists"
816        );
817    }
818
819    #[test]
820    fn test_extract_output_schema_array_with_ref_items() {
821        let doc = json!({
822            "components": {
823                "schemas": {
824                    "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
825                }
826            }
827        });
828        let op = json!({
829            "responses": {
830                "200": {
831                    "content": {
832                        "application/json": {
833                            "schema": {
834                                "type": "array",
835                                "items": {"$ref": "#/components/schemas/Item"}
836                            }
837                        }
838                    }
839                }
840            }
841        });
842        let result = extract_output_schema(&op, Some(&doc));
843        assert_eq!(result["type"], "array");
844        assert_eq!(result["items"]["properties"]["name"]["type"], "string");
845    }
846
847    #[test]
848    fn test_extract_output_schema_allof() {
849        let doc = json!({
850            "components": {
851                "schemas": {
852                    "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
853                    "Meta": {"type": "object", "properties": {"created": {"type": "string"}}}
854                }
855            }
856        });
857        let op = json!({
858            "responses": {
859                "200": {
860                    "content": {
861                        "application/json": {
862                            "schema": {
863                                "allOf": [
864                                    {"$ref": "#/components/schemas/Base"},
865                                    {"$ref": "#/components/schemas/Meta"}
866                                ]
867                            }
868                        }
869                    }
870                }
871            }
872        });
873        let result = extract_output_schema(&op, Some(&doc));
874        let all_of = result["allOf"].as_array().unwrap();
875        assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
876        assert_eq!(all_of[1]["properties"]["created"]["type"], "string");
877    }
878
879    #[test]
880    fn test_extract_output_schema_nested_ref() {
881        let doc = json!({
882            "components": {
883                "schemas": {
884                    "Inner": {"type": "object", "properties": {"val": {"type": "number"}}}
885                }
886            }
887        });
888        let op = json!({
889            "responses": {
890                "200": {
891                    "content": {
892                        "application/json": {
893                            "schema": {
894                                "type": "object",
895                                "properties": {
896                                    "nested": {"$ref": "#/components/schemas/Inner"}
897                                }
898                            }
899                        }
900                    }
901                }
902            }
903        });
904        let result = extract_output_schema(&op, Some(&doc));
905        assert_eq!(
906            result["properties"]["nested"]["properties"]["val"]["type"],
907            "number"
908        );
909    }
910
911    #[test]
912    fn test_extract_output_schema_empty_responses() {
913        // No responses key at all returns default schema
914        let op = json!({"operationId": "noResponses"});
915        let result = extract_output_schema(&op, None);
916        assert_eq!(result["type"], "object");
917        assert!(result["properties"].as_object().unwrap().is_empty());
918    }
919
920    #[test]
921    fn test_deep_resolve_depth_limit_at_exactly_16() {
922        // Depth 15 should still resolve; depth 16 should be the cut-off.
923        let doc = json!({
924            "components": {
925                "schemas": {
926                    "Leaf": {"type": "string"}
927                }
928            }
929        });
930        let schema = json!({"$ref": "#/components/schemas/Leaf"});
931        // At depth 15 the ref IS resolved (< 16)
932        let at_15 = deep_resolve_refs(&schema, &doc, 15);
933        assert_eq!(at_15["type"], "string", "depth 15 should resolve the $ref");
934        // At depth 16 the schema is returned unchanged (cut-off)
935        let at_16 = deep_resolve_refs(&schema, &doc, 16);
936        assert!(
937            at_16.get("$ref").is_some(),
938            "depth 16 must return schema unchanged"
939        );
940    }
941}