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: resolves through depth 16 (cuts off at depth > 16),
77/// matching the Python and TypeScript implementations.
78pub fn deep_resolve_refs(schema: &Value, openapi_doc: &Value, depth: usize) -> Value {
79    if depth > 16 {
80        warn!(depth, "deep_resolve_refs: depth limit reached — returning schema as-is to prevent infinite recursion");
81        return schema.clone();
82    }
83
84    // Direct $ref resolution
85    if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) {
86        let resolved = resolve_ref(ref_str, openapi_doc);
87        return deep_resolve_refs(&resolved, openapi_doc, depth + 1);
88    }
89
90    let mut result = schema.clone();
91
92    if let Some(obj) = result.as_object_mut() {
93        // Resolve inside allOf/anyOf/oneOf
94        for key in &["allOf", "anyOf", "oneOf"] {
95            if let Some(Value::Array(items)) = obj.get(*key).cloned() {
96                let resolved: Vec<Value> = items
97                    .iter()
98                    .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
99                    .collect();
100                obj.insert(key.to_string(), Value::Array(resolved));
101            }
102        }
103
104        // Resolve array items (single schema) and tuple items (array of schemas)
105        if let Some(items) = obj.get("items").cloned() {
106            if items.is_object() {
107                obj.insert(
108                    "items".to_string(),
109                    deep_resolve_refs(&items, openapi_doc, depth + 1),
110                );
111            } else if let Value::Array(arr) = items {
112                let resolved: Vec<Value> = arr
113                    .iter()
114                    .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
115                    .collect();
116                obj.insert("items".to_string(), Value::Array(resolved));
117            }
118        }
119
120        // Resolve prefixItems (JSON Schema 2020-12 tuple items)
121        if let Some(Value::Array(prefix)) = obj.get("prefixItems").cloned() {
122            let resolved: Vec<Value> = prefix
123                .iter()
124                .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
125                .collect();
126            obj.insert("prefixItems".to_string(), Value::Array(resolved));
127        }
128
129        // Resolve nested properties
130        if let Some(Value::Object(props)) = obj.get("properties").cloned() {
131            let resolved: serde_json::Map<String, Value> = props
132                .into_iter()
133                .map(|(k, v)| (k, deep_resolve_refs(&v, openapi_doc, depth + 1)))
134                .collect();
135            obj.insert("properties".to_string(), Value::Object(resolved));
136        }
137
138        // Resolve patternProperties (same shape as properties but keyed by regex)
139        if let Some(Value::Object(pat_props)) = obj.get("patternProperties").cloned() {
140            let resolved: serde_json::Map<String, Value> = pat_props
141                .into_iter()
142                .map(|(k, v)| (k, deep_resolve_refs(&v, openapi_doc, depth + 1)))
143                .collect();
144            obj.insert("patternProperties".to_string(), Value::Object(resolved));
145        }
146
147        // Resolve additionalProperties when it is a schema (not a boolean)
148        if let Some(add_props) = obj.get("additionalProperties").cloned() {
149            if add_props.is_object() {
150                obj.insert(
151                    "additionalProperties".to_string(),
152                    deep_resolve_refs(&add_props, openapi_doc, depth + 1),
153                );
154            }
155        }
156
157        // Resolve not / if / then / else (applicator keywords)
158        for key in &["not", "if", "then", "else"] {
159            if let Some(sub) = obj.get(*key).cloned() {
160                if sub.is_object() {
161                    obj.insert(
162                        key.to_string(),
163                        deep_resolve_refs(&sub, openapi_doc, depth + 1),
164                    );
165                }
166            }
167        }
168    }
169
170    result
171}
172
173/// Extract input schema from an OpenAPI operation.
174///
175/// Combines query/path parameters and request body properties into a
176/// single `{"type": "object", "properties": ..., "required": ...}` schema.
177pub fn extract_input_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
178    let mut properties = serde_json::Map::new();
179    let mut required: Vec<Value> = Vec::new();
180
181    // Query/path parameters
182    if let Some(Value::Array(params)) = operation.get("parameters") {
183        for param in params {
184            let in_value = param.get("in").and_then(|v| v.as_str()).unwrap_or("");
185            if in_value == "query" || in_value == "path" {
186                if let Some(name) = param.get("name").and_then(|v| v.as_str()) {
187                    let param_schema = param
188                        .get("schema")
189                        .cloned()
190                        .unwrap_or_else(|| json!({"type": "string"}));
191                    let resolved = resolve_schema(&param_schema, openapi_doc);
192                    properties.insert(name.to_string(), resolved);
193
194                    if param
195                        .get("required")
196                        .and_then(|v| v.as_bool())
197                        .unwrap_or(false)
198                    {
199                        required.push(Value::String(name.to_string()));
200                    }
201                }
202            }
203        }
204    }
205
206    // Request body — try "application/json" first, then "application/vnd.api+json"
207    // as a fallback. This matches the Python implementation which iterates all
208    // content-type keys and accepts both.
209    let body_content = operation
210        .get("requestBody")
211        .and_then(|rb| rb.get("content"));
212    let body_schema_opt = body_content.and_then(|c| c.as_object()).and_then(|m| {
213        m.iter()
214            .find(|(k, _)| {
215                k.starts_with("application/json") || k.as_str() == "application/vnd.api+json"
216            })
217            .and_then(|(_, v)| v.get("schema"))
218    });
219    if let Some(body_schema) = body_schema_opt {
220        let resolved = resolve_schema(body_schema, openapi_doc);
221        if let Some(props) = resolved.get("properties").and_then(|p| p.as_object()) {
222            for (k, v) in props {
223                properties.insert(k.clone(), v.clone());
224            }
225        }
226        if let Some(req) = resolved.get("required").and_then(|r| r.as_array()) {
227            required.extend(req.iter().cloned());
228        }
229    }
230
231    // Recursively resolve $ref inside individual properties
232    if let Some(doc) = openapi_doc {
233        let resolved_props: serde_json::Map<String, Value> = properties
234            .into_iter()
235            .map(|(k, v)| (k, deep_resolve_refs(&v, doc, 0)))
236            .collect();
237        properties = resolved_props;
238    }
239
240    // Deduplicate required list while preserving order (params + body can overlap)
241    let mut seen = std::collections::HashSet::new();
242    required.retain(|v| {
243        let key = v.as_str().unwrap_or("").to_string();
244        seen.insert(key)
245    });
246
247    json!({
248        "type": "object",
249        "properties": Value::Object(properties),
250        "required": Value::Array(required),
251    })
252}
253
254/// Extract output schema from OpenAPI operation responses.
255///
256/// Returns the output JSON Schema, or a default empty object schema.
257///
258/// Accepts any 2xx status code (200–299), matching the TypeScript implementation
259/// which filters on /^2\d\d$/. Codes are checked in lexicographic order so
260/// 200 is preferred over 201, 201 over 202, and so on.
261pub fn extract_output_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
262    let responses = match operation.get("responses") {
263        Some(r) => r,
264        None => return json!({"type": "object", "properties": {}}),
265    };
266
267    // Collect all 2xx response keys and sort them so lower codes take priority.
268    let responses_obj = match responses.as_object() {
269        Some(obj) => obj,
270        None => return json!({"type": "object", "properties": {}}),
271    };
272    let mut success_codes: Vec<&str> = responses_obj
273        .keys()
274        .filter_map(|k| {
275            let k_str = k.as_str();
276            if k_str.len() == 3
277                && k_str.starts_with('2')
278                && k_str.chars().skip(1).all(|c| c.is_ascii_digit())
279            {
280                Some(k_str)
281            } else {
282                None
283            }
284        })
285        .collect();
286    success_codes.sort();
287
288    for status_code in &success_codes {
289        let json_content = responses
290            .get(*status_code)
291            .and_then(|r| r.get("content"))
292            .and_then(|c| c.as_object())
293            .and_then(|m| {
294                m.iter()
295                    .find(|(k, _)| {
296                        k.starts_with("application/json")
297                            || k.as_str() == "application/vnd.api+json"
298                    })
299                    .map(|(_, v)| v)
300            });
301        if let Some(schema) = json_content.and_then(|jc| jc.get("schema")) {
302            let mut resolved = resolve_schema(schema, openapi_doc);
303            if let Some(doc) = openapi_doc {
304                resolved = deep_resolve_refs(&resolved, doc, 0);
305            }
306            return resolved;
307        }
308    }
309
310    json!({"type": "object", "properties": {}})
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn test_resolve_ref_rfc6901_slash_in_key() {
319        // Key name "schemas/v2" encoded as "schemas~1v2" in the pointer.
320        let doc = json!({
321            "schemas/v2": {"type": "string"}
322        });
323        let result = resolve_ref("#/schemas~1v2", &doc);
324        assert_eq!(result["type"], "string");
325    }
326
327    #[test]
328    fn test_resolve_ref_rfc6901_tilde_in_key() {
329        // Key name "a~b" encoded as "a~0b" in the pointer.
330        let doc = json!({
331            "a~b": {"type": "number"}
332        });
333        let result = resolve_ref("#/a~0b", &doc);
334        assert_eq!(result["type"], "number");
335    }
336
337    #[test]
338    fn test_resolve_ref_rfc6901_combined_escapes() {
339        // "~01" should decode to "~1" (not "/"), per RFC 6901 §3.
340        let doc = json!({
341            "~1": {"type": "boolean"}
342        });
343        let result = resolve_ref("#/~01", &doc);
344        assert_eq!(result["type"], "boolean");
345    }
346
347    #[test]
348    fn test_deep_resolve_refs_additional_properties() {
349        let doc = json!({
350            "components": {
351                "schemas": {
352                    "Tag": {"type": "string"}
353                }
354            }
355        });
356        let schema = json!({
357            "type": "object",
358            "additionalProperties": {"$ref": "#/components/schemas/Tag"}
359        });
360        let result = deep_resolve_refs(&schema, &doc, 0);
361        assert_eq!(result["additionalProperties"]["type"], "string");
362    }
363
364    #[test]
365    fn test_deep_resolve_refs_not_keyword() {
366        let doc = json!({
367            "components": {
368                "schemas": {
369                    "Forbidden": {"type": "string"}
370                }
371            }
372        });
373        let schema = json!({
374            "not": {"$ref": "#/components/schemas/Forbidden"}
375        });
376        let result = deep_resolve_refs(&schema, &doc, 0);
377        assert_eq!(result["not"]["type"], "string");
378    }
379
380    #[test]
381    fn test_deep_resolve_refs_if_then_else() {
382        let doc = json!({
383            "components": {
384                "schemas": {
385                    "Condition": {"type": "boolean"},
386                    "TrueCase": {"type": "string"},
387                    "FalseCase": {"type": "number"}
388                }
389            }
390        });
391        let schema = json!({
392            "if": {"$ref": "#/components/schemas/Condition"},
393            "then": {"$ref": "#/components/schemas/TrueCase"},
394            "else": {"$ref": "#/components/schemas/FalseCase"}
395        });
396        let result = deep_resolve_refs(&schema, &doc, 0);
397        assert_eq!(result["if"]["type"], "boolean");
398        assert_eq!(result["then"]["type"], "string");
399        assert_eq!(result["else"]["type"], "number");
400    }
401
402    #[test]
403    fn test_extract_input_schema_deduplicates_required() {
404        // Both params and body declare the same field as required — should dedup.
405        let doc = json!({
406            "components": {
407                "schemas": {
408                    "Body": {
409                        "type": "object",
410                        "properties": {"id": {"type": "integer"}},
411                        "required": ["id"]
412                    }
413                }
414            }
415        });
416        let op = json!({
417            "parameters": [
418                {"name": "id", "in": "path", "required": true, "schema": {"type": "integer"}}
419            ],
420            "requestBody": {
421                "content": {
422                    "application/json": {
423                        "schema": {"$ref": "#/components/schemas/Body"}
424                    }
425                }
426            }
427        });
428        let result = extract_input_schema(&op, Some(&doc));
429        let req = result["required"].as_array().unwrap();
430        let id_count = req.iter().filter(|v| v.as_str() == Some("id")).count();
431        assert_eq!(id_count, 1, "required list should deduplicate; got {req:?}");
432    }
433
434    #[test]
435    fn test_resolve_ref_basic() {
436        let doc = json!({
437            "components": {
438                "schemas": {
439                    "User": {"type": "object", "properties": {"name": {"type": "string"}}}
440                }
441            }
442        });
443        let result = resolve_ref("#/components/schemas/User", &doc);
444        assert_eq!(result["type"], "object");
445        assert!(result["properties"]["name"].is_object());
446    }
447
448    #[test]
449    fn test_resolve_ref_not_found() {
450        let doc = json!({});
451        let result = resolve_ref("#/components/schemas/Missing", &doc);
452        assert_eq!(result, json!({}));
453    }
454
455    #[test]
456    fn test_resolve_ref_non_hash() {
457        let doc = json!({});
458        let result = resolve_ref("external.json#/foo", &doc);
459        assert_eq!(result, json!({}));
460    }
461
462    #[test]
463    fn test_resolve_schema_with_ref() {
464        let doc = json!({
465            "components": {"schemas": {"Foo": {"type": "string"}}}
466        });
467        let schema = json!({"$ref": "#/components/schemas/Foo"});
468        let result = resolve_schema(&schema, Some(&doc));
469        assert_eq!(result["type"], "string");
470    }
471
472    #[test]
473    fn test_resolve_schema_no_ref() {
474        let schema = json!({"type": "integer"});
475        let result = resolve_schema(&schema, None);
476        assert_eq!(result["type"], "integer");
477    }
478
479    #[test]
480    fn test_extract_input_schema_parameters() {
481        let op = json!({
482            "parameters": [
483                {"name": "user_id", "in": "path", "required": true, "schema": {"type": "integer"}},
484                {"name": "limit", "in": "query", "schema": {"type": "integer"}}
485            ]
486        });
487        let result = extract_input_schema(&op, None);
488        assert!(result["properties"]["user_id"].is_object());
489        assert!(result["properties"]["limit"].is_object());
490        let req = result["required"].as_array().unwrap();
491        assert!(req.contains(&Value::String("user_id".into())));
492        assert!(!req.contains(&Value::String("limit".into())));
493    }
494
495    #[test]
496    fn test_extract_input_schema_request_body() {
497        let op = json!({
498            "requestBody": {
499                "content": {
500                    "application/json": {
501                        "schema": {
502                            "type": "object",
503                            "properties": {"title": {"type": "string"}},
504                            "required": ["title"]
505                        }
506                    }
507                }
508            }
509        });
510        let result = extract_input_schema(&op, None);
511        assert_eq!(result["properties"]["title"]["type"], "string");
512        let req = result["required"].as_array().unwrap();
513        assert!(req.contains(&Value::String("title".into())));
514    }
515
516    #[test]
517    fn test_extract_input_schema_with_ref() {
518        let doc = json!({
519            "components": {
520                "schemas": {
521                    "TaskInput": {
522                        "type": "object",
523                        "properties": {"name": {"type": "string"}},
524                        "required": ["name"]
525                    }
526                }
527            }
528        });
529        let op = json!({
530            "requestBody": {
531                "content": {
532                    "application/json": {
533                        "schema": {"$ref": "#/components/schemas/TaskInput"}
534                    }
535                }
536            }
537        });
538        let result = extract_input_schema(&op, Some(&doc));
539        assert_eq!(result["properties"]["name"]["type"], "string");
540    }
541
542    #[test]
543    fn test_extract_output_schema_200() {
544        let op = json!({
545            "responses": {
546                "200": {
547                    "content": {
548                        "application/json": {
549                            "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}
550                        }
551                    }
552                }
553            }
554        });
555        let result = extract_output_schema(&op, None);
556        assert_eq!(result["properties"]["id"]["type"], "integer");
557    }
558
559    #[test]
560    fn test_extract_output_schema_fallback() {
561        let op = json!({"responses": {"404": {}}});
562        let result = extract_output_schema(&op, None);
563        assert_eq!(result["type"], "object");
564    }
565
566    #[test]
567    fn test_deep_resolve_nested_ref() {
568        let doc = json!({
569            "components": {
570                "schemas": {
571                    "Address": {"type": "object", "properties": {"city": {"type": "string"}}},
572                    "User": {
573                        "type": "object",
574                        "properties": {
575                            "address": {"$ref": "#/components/schemas/Address"}
576                        }
577                    }
578                }
579            }
580        });
581        let schema = json!({"$ref": "#/components/schemas/User"});
582        let result = deep_resolve_refs(&schema, &doc, 0);
583        assert_eq!(
584            result["properties"]["address"]["properties"]["city"]["type"],
585            "string"
586        );
587    }
588
589    #[test]
590    fn test_deep_resolve_depth_limit() {
591        // Self-referencing schema should not cause stack overflow
592        let doc = json!({
593            "components": {
594                "schemas": {
595                    "Recursive": {
596                        "type": "object",
597                        "properties": {
598                            "child": {"$ref": "#/components/schemas/Recursive"}
599                        }
600                    }
601                }
602            }
603        });
604        let schema = json!({"$ref": "#/components/schemas/Recursive"});
605        // Should terminate without panic
606        let _ = deep_resolve_refs(&schema, &doc, 0);
607    }
608
609    #[test]
610    fn test_resolve_ref_to_non_dict() {
611        // $ref pointing to a string value returns {}
612        let doc = json!({
613            "components": {
614                "schemas": {
615                    "JustAString": "hello"
616                }
617            }
618        });
619        let result = resolve_ref("#/components/schemas/JustAString", &doc);
620        assert_eq!(result, json!({}));
621
622        // $ref pointing to a number value returns {}
623        let doc2 = json!({
624            "components": {
625                "schemas": {
626                    "JustANumber": 42
627                }
628            }
629        });
630        let result2 = resolve_ref("#/components/schemas/JustANumber", &doc2);
631        assert_eq!(result2, json!({}));
632    }
633
634    #[test]
635    fn test_resolve_ref_through_missing_path() {
636        // $ref with intermediate missing keys returns {}
637        let doc = json!({
638            "components": {}
639        });
640        let result = resolve_ref("#/components/schemas/Missing", &doc);
641        assert_eq!(result, json!({}));
642    }
643
644    #[test]
645    fn test_resolve_schema_no_openapi_doc() {
646        // None openapi_doc returns schema as-is even if it has a $ref
647        let schema = json!({"$ref": "#/components/schemas/Foo", "type": "string"});
648        let result = resolve_schema(&schema, None);
649        assert_eq!(result, schema);
650    }
651
652    #[test]
653    fn test_deep_resolve_refs_in_allof() {
654        let doc = json!({
655            "components": {
656                "schemas": {
657                    "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
658                    "Extra": {"type": "object", "properties": {"tag": {"type": "string"}}}
659                }
660            }
661        });
662        let schema = json!({
663            "allOf": [
664                {"$ref": "#/components/schemas/Base"},
665                {"$ref": "#/components/schemas/Extra"}
666            ]
667        });
668        let result = deep_resolve_refs(&schema, &doc, 0);
669        let all_of = result["allOf"].as_array().unwrap();
670        assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
671        assert_eq!(all_of[1]["properties"]["tag"]["type"], "string");
672    }
673
674    #[test]
675    fn test_deep_resolve_refs_in_anyof() {
676        let doc = json!({
677            "components": {
678                "schemas": {
679                    "Cat": {"type": "object", "properties": {"purrs": {"type": "boolean"}}},
680                    "Dog": {"type": "object", "properties": {"barks": {"type": "boolean"}}}
681                }
682            }
683        });
684        let schema = json!({
685            "anyOf": [
686                {"$ref": "#/components/schemas/Cat"},
687                {"$ref": "#/components/schemas/Dog"}
688            ]
689        });
690        let result = deep_resolve_refs(&schema, &doc, 0);
691        let any_of = result["anyOf"].as_array().unwrap();
692        assert_eq!(any_of[0]["properties"]["purrs"]["type"], "boolean");
693        assert_eq!(any_of[1]["properties"]["barks"]["type"], "boolean");
694    }
695
696    #[test]
697    fn test_deep_resolve_refs_in_items() {
698        let doc = json!({
699            "components": {
700                "schemas": {
701                    "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
702                }
703            }
704        });
705        let schema = json!({
706            "type": "array",
707            "items": {"$ref": "#/components/schemas/Item"}
708        });
709        let result = deep_resolve_refs(&schema, &doc, 0);
710        assert_eq!(result["items"]["properties"]["name"]["type"], "string");
711    }
712
713    #[test]
714    fn test_deep_resolve_no_mutation() {
715        let doc = json!({
716            "components": {
717                "schemas": {
718                    "Addr": {"type": "object", "properties": {"city": {"type": "string"}}}
719                }
720            }
721        });
722        let doc_before = doc.clone();
723        let schema = json!({
724            "type": "object",
725            "properties": {
726                "address": {"$ref": "#/components/schemas/Addr"}
727            }
728        });
729        let _result = deep_resolve_refs(&schema, &doc, 0);
730        assert_eq!(doc, doc_before, "openapi_doc must not be mutated");
731    }
732
733    #[test]
734    fn test_extract_input_schema_empty_operation() {
735        let op = json!({});
736        let result = extract_input_schema(&op, None);
737        assert_eq!(result["type"], "object");
738        assert!(result["properties"].as_object().unwrap().is_empty());
739        assert!(result["required"].as_array().unwrap().is_empty());
740    }
741
742    #[test]
743    fn test_extract_input_schema_ref_in_param() {
744        let doc = json!({
745            "components": {
746                "schemas": {
747                    "IdType": {"type": "integer", "format": "int64"}
748                }
749            }
750        });
751        let op = json!({
752            "parameters": [
753                {
754                    "name": "user_id",
755                    "in": "path",
756                    "required": true,
757                    "schema": {"$ref": "#/components/schemas/IdType"}
758                }
759            ]
760        });
761        let result = extract_input_schema(&op, Some(&doc));
762        assert_eq!(result["properties"]["user_id"]["type"], "integer");
763        assert_eq!(result["properties"]["user_id"]["format"], "int64");
764    }
765
766    #[test]
767    fn test_extract_input_schema_nested_ref_in_body() {
768        let doc = json!({
769            "components": {
770                "schemas": {
771                    "Address": {"type": "object", "properties": {"zip": {"type": "string"}}}
772                }
773            }
774        });
775        let op = json!({
776            "requestBody": {
777                "content": {
778                    "application/json": {
779                        "schema": {
780                            "type": "object",
781                            "properties": {
782                                "address": {"$ref": "#/components/schemas/Address"}
783                            }
784                        }
785                    }
786                }
787            }
788        });
789        let result = extract_input_schema(&op, Some(&doc));
790        assert_eq!(
791            result["properties"]["address"]["properties"]["zip"]["type"],
792            "string"
793        );
794    }
795
796    #[test]
797    fn test_extract_output_schema_201() {
798        let op = json!({
799            "responses": {
800                "201": {
801                    "content": {
802                        "application/json": {
803                            "schema": {
804                                "type": "object",
805                                "properties": {"id": {"type": "integer"}}
806                            }
807                        }
808                    }
809                }
810            }
811        });
812        let result = extract_output_schema(&op, None);
813        assert_eq!(result["properties"]["id"]["type"], "integer");
814    }
815
816    #[test]
817    fn test_extract_output_schema_200_preferred() {
818        let op = json!({
819            "responses": {
820                "200": {
821                    "content": {
822                        "application/json": {
823                            "schema": {
824                                "type": "object",
825                                "properties": {"from200": {"type": "string"}}
826                            }
827                        }
828                    }
829                },
830                "201": {
831                    "content": {
832                        "application/json": {
833                            "schema": {
834                                "type": "object",
835                                "properties": {"from201": {"type": "string"}}
836                            }
837                        }
838                    }
839                }
840            }
841        });
842        let result = extract_output_schema(&op, None);
843        assert!(
844            result["properties"]
845                .as_object()
846                .unwrap()
847                .contains_key("from200"),
848            "200 should be preferred over 201"
849        );
850        assert!(
851            !result["properties"]
852                .as_object()
853                .unwrap()
854                .contains_key("from201"),
855            "201 should not be used when 200 exists"
856        );
857    }
858
859    #[test]
860    fn test_extract_output_schema_array_with_ref_items() {
861        let doc = json!({
862            "components": {
863                "schemas": {
864                    "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
865                }
866            }
867        });
868        let op = json!({
869            "responses": {
870                "200": {
871                    "content": {
872                        "application/json": {
873                            "schema": {
874                                "type": "array",
875                                "items": {"$ref": "#/components/schemas/Item"}
876                            }
877                        }
878                    }
879                }
880            }
881        });
882        let result = extract_output_schema(&op, Some(&doc));
883        assert_eq!(result["type"], "array");
884        assert_eq!(result["items"]["properties"]["name"]["type"], "string");
885    }
886
887    #[test]
888    fn test_extract_output_schema_allof() {
889        let doc = json!({
890            "components": {
891                "schemas": {
892                    "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
893                    "Meta": {"type": "object", "properties": {"created": {"type": "string"}}}
894                }
895            }
896        });
897        let op = json!({
898            "responses": {
899                "200": {
900                    "content": {
901                        "application/json": {
902                            "schema": {
903                                "allOf": [
904                                    {"$ref": "#/components/schemas/Base"},
905                                    {"$ref": "#/components/schemas/Meta"}
906                                ]
907                            }
908                        }
909                    }
910                }
911            }
912        });
913        let result = extract_output_schema(&op, Some(&doc));
914        let all_of = result["allOf"].as_array().unwrap();
915        assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
916        assert_eq!(all_of[1]["properties"]["created"]["type"], "string");
917    }
918
919    #[test]
920    fn test_extract_output_schema_nested_ref() {
921        let doc = json!({
922            "components": {
923                "schemas": {
924                    "Inner": {"type": "object", "properties": {"val": {"type": "number"}}}
925                }
926            }
927        });
928        let op = json!({
929            "responses": {
930                "200": {
931                    "content": {
932                        "application/json": {
933                            "schema": {
934                                "type": "object",
935                                "properties": {
936                                    "nested": {"$ref": "#/components/schemas/Inner"}
937                                }
938                            }
939                        }
940                    }
941                }
942            }
943        });
944        let result = extract_output_schema(&op, Some(&doc));
945        assert_eq!(
946            result["properties"]["nested"]["properties"]["val"]["type"],
947            "number"
948        );
949    }
950
951    #[test]
952    fn test_extract_output_schema_empty_responses() {
953        // No responses key at all returns default schema
954        let op = json!({"operationId": "noResponses"});
955        let result = extract_output_schema(&op, None);
956        assert_eq!(result["type"], "object");
957        assert!(result["properties"].as_object().unwrap().is_empty());
958    }
959
960    #[test]
961    fn test_extract_output_schema_202() {
962        // Regression test (D10-002): 202 Accepted must be recognised.
963        // Previously only 200/201 were checked; 202/203 were silently ignored.
964        let op = json!({
965            "responses": {
966                "202": {
967                    "content": {
968                        "application/json": {
969                            "schema": {
970                                "type": "object",
971                                "properties": {"job_id": {"type": "string"}}
972                            }
973                        }
974                    }
975                }
976            }
977        });
978        let result = extract_output_schema(&op, None);
979        assert_eq!(
980            result["properties"]["job_id"]["type"], "string",
981            "202 response schema should be extracted; got: {result:?}"
982        );
983    }
984
985    #[test]
986    fn test_extract_output_schema_203() {
987        // Regression test (D10-002): 203 Non-Authoritative Information must be recognised.
988        let op = json!({
989            "responses": {
990                "203": {
991                    "content": {
992                        "application/json": {
993                            "schema": {
994                                "type": "object",
995                                "properties": {"cached": {"type": "boolean"}}
996                            }
997                        }
998                    }
999                }
1000            }
1001        });
1002        let result = extract_output_schema(&op, None);
1003        assert_eq!(
1004            result["properties"]["cached"]["type"], "boolean",
1005            "203 response schema should be extracted; got: {result:?}"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_extract_input_schema_vnd_api_json() {
1011        // Regression test (Issue #32): application/vnd.api+json must be accepted
1012        // as a fallback when application/json is absent, matching Python behavior.
1013        let op = json!({
1014            "requestBody": {
1015                "content": {
1016                    "application/vnd.api+json": {
1017                        "schema": {
1018                            "type": "object",
1019                            "properties": {"data": {"type": "object"}},
1020                            "required": ["data"]
1021                        }
1022                    }
1023                }
1024            }
1025        });
1026        let result = extract_input_schema(&op, None);
1027        assert!(
1028            result["properties"]["data"].is_object(),
1029            "vnd.api+json schema properties should be extracted; got: {result:?}"
1030        );
1031        let req = result["required"].as_array().unwrap();
1032        assert!(
1033            req.contains(&Value::String("data".into())),
1034            "required field from vnd.api+json schema should be present; got: {req:?}"
1035        );
1036    }
1037
1038    #[test]
1039    fn test_extract_input_schema_json_preferred_over_vnd_api_json() {
1040        // application/json takes priority over application/vnd.api+json.
1041        let op = json!({
1042            "requestBody": {
1043                "content": {
1044                    "application/json": {
1045                        "schema": {
1046                            "type": "object",
1047                            "properties": {"from_json": {"type": "string"}}
1048                        }
1049                    },
1050                    "application/vnd.api+json": {
1051                        "schema": {
1052                            "type": "object",
1053                            "properties": {"from_vnd": {"type": "string"}}
1054                        }
1055                    }
1056                }
1057            }
1058        });
1059        let result = extract_input_schema(&op, None);
1060        assert!(result["properties"]["from_json"].is_object());
1061        assert!(!result["properties"]
1062            .as_object()
1063            .unwrap()
1064            .contains_key("from_vnd"));
1065    }
1066
1067    #[test]
1068    fn test_extract_output_schema_200_preferred_over_202() {
1069        // 200 should be picked over 202 when both exist.
1070        let op = json!({
1071            "responses": {
1072                "200": {
1073                    "content": {
1074                        "application/json": {
1075                            "schema": {
1076                                "type": "object",
1077                                "properties": {"from200": {"type": "string"}}
1078                            }
1079                        }
1080                    }
1081                },
1082                "202": {
1083                    "content": {
1084                        "application/json": {
1085                            "schema": {
1086                                "type": "object",
1087                                "properties": {"from202": {"type": "string"}}
1088                            }
1089                        }
1090                    }
1091                }
1092            }
1093        });
1094        let result = extract_output_schema(&op, None);
1095        assert!(result["properties"]
1096            .as_object()
1097            .unwrap()
1098            .contains_key("from200"));
1099        assert!(!result["properties"]
1100            .as_object()
1101            .unwrap()
1102            .contains_key("from202"));
1103    }
1104
1105    #[test]
1106    fn test_deep_resolve_depth_limit_at_exactly_16() {
1107        // Regression test (D10-004): depth boundary must be > 16 (cut off AT depth 17),
1108        // not >= 16. Python and TypeScript resolve through depth 16; Rust must match.
1109        let doc = json!({
1110            "components": {
1111                "schemas": {
1112                    "Leaf": {"type": "string"}
1113                }
1114            }
1115        });
1116        let schema = json!({"$ref": "#/components/schemas/Leaf"});
1117        // At depth 15 the ref IS resolved
1118        let at_15 = deep_resolve_refs(&schema, &doc, 15);
1119        assert_eq!(at_15["type"], "string", "depth 15 should resolve the $ref");
1120        // At depth 16 the ref IS ALSO resolved (>16 is the cut-off, not >=16)
1121        let at_16 = deep_resolve_refs(&schema, &doc, 16);
1122        assert_eq!(
1123            at_16["type"], "string",
1124            "depth 16 should resolve the $ref (boundary fix)"
1125        );
1126        // At depth 17 the schema is returned unchanged (cut-off)
1127        let at_17 = deep_resolve_refs(&schema, &doc, 17);
1128        assert!(
1129            at_17.get("$ref").is_some(),
1130            "depth 17 must return schema unchanged"
1131        );
1132    }
1133
1134    #[test]
1135    fn test_extract_output_schema_204() {
1136        // D11-001: any 2xx code should be accepted. 204 No Content (with a schema)
1137        // must be extracted, not silently ignored.
1138        let op = json!({
1139            "responses": {
1140                "204": {
1141                    "content": {
1142                        "application/json": {
1143                            "schema": {
1144                                "type": "object",
1145                                "properties": {"accepted": {"type": "boolean"}}
1146                            }
1147                        }
1148                    }
1149                }
1150            }
1151        });
1152        let result = extract_output_schema(&op, None);
1153        assert_eq!(
1154            result["properties"]["accepted"]["type"], "boolean",
1155            "204 response schema should be extracted; got: {result:?}"
1156        );
1157    }
1158
1159    #[test]
1160    fn test_extract_output_schema_vnd_api_json_output() {
1161        // D11-002: application/vnd.api+json should be accepted as a fallback for
1162        // output schemas, matching Python behaviour.
1163        let op = json!({
1164            "responses": {
1165                "200": {
1166                    "content": {
1167                        "application/vnd.api+json": {
1168                            "schema": {
1169                                "type": "object",
1170                                "properties": {"data": {"type": "object"}}
1171                            }
1172                        }
1173                    }
1174                }
1175            }
1176        });
1177        let result = extract_output_schema(&op, None);
1178        assert!(
1179            result["properties"]["data"].is_object(),
1180            "vnd.api+json output schema properties should be extracted; got: {result:?}"
1181        );
1182    }
1183
1184    #[test]
1185    fn test_extract_output_schema_json_preferred_over_vnd_api_json_output() {
1186        // application/json takes priority over application/vnd.api+json for outputs.
1187        let op = json!({
1188            "responses": {
1189                "200": {
1190                    "content": {
1191                        "application/json": {
1192                            "schema": {
1193                                "type": "object",
1194                                "properties": {"from_json": {"type": "string"}}
1195                            }
1196                        },
1197                        "application/vnd.api+json": {
1198                            "schema": {
1199                                "type": "object",
1200                                "properties": {"from_vnd": {"type": "string"}}
1201                            }
1202                        }
1203                    }
1204                }
1205            }
1206        });
1207        let result = extract_output_schema(&op, None);
1208        assert!(result["properties"]["from_json"].is_object());
1209        assert!(!result["properties"]
1210            .as_object()
1211            .unwrap()
1212            .contains_key("from_vnd"));
1213    }
1214
1215    #[test]
1216    fn test_deep_resolve_16_levels_of_nesting() {
1217        // Regression test (D10-004): a chain of exactly 16 $ref levels must
1218        // be fully resolved. With the old >= 16 boundary, level 16 was cut off.
1219        //
1220        // Build: L0 -> L1 -> L2 -> ... -> L15 -> Leaf
1221        // That is 16 hops (depth 0 enters L0, depth 1 enters L1, ...,
1222        // depth 15 enters L15, depth 16 resolves the $ref inside L15 to Leaf).
1223        let mut schemas = serde_json::Map::new();
1224        schemas.insert("Leaf".into(), json!({"type": "string"}));
1225        // L15 references Leaf; L14 references L15; ... L0 references L1.
1226        for i in (0..16usize).rev() {
1227            let target = if i == 15 {
1228                "Leaf".to_string()
1229            } else {
1230                format!("L{}", i + 1)
1231            };
1232            schemas.insert(
1233                format!("L{i}"),
1234                json!({"$ref": format!("#/components/schemas/{target}")}),
1235            );
1236        }
1237        let doc = json!({"components": {"schemas": schemas}});
1238        let schema = json!({"$ref": "#/components/schemas/L0"});
1239        let result = deep_resolve_refs(&schema, &doc, 0);
1240        assert_eq!(
1241            result["type"], "string",
1242            "16-level deep $ref chain should be fully resolved; got: {result:?}"
1243        );
1244    }
1245
1246    #[test]
1247    fn test_extract_input_schema_json_with_charset_param() {
1248        // Issue 5: prefix matching — "application/json; charset=utf-8" must be
1249        // treated as application/json for schema extraction.
1250        let op = json!({
1251            "requestBody": {
1252                "content": {
1253                    "application/json; charset=utf-8": {
1254                        "schema": {
1255                            "type": "object",
1256                            "properties": {"name": {"type": "string"}},
1257                            "required": ["name"]
1258                        }
1259                    }
1260                }
1261            }
1262        });
1263        let result = extract_input_schema(&op, None);
1264        assert!(
1265            result["properties"]["name"].is_object(),
1266            "application/json; charset=utf-8 input schema should be extracted; got: {result:?}"
1267        );
1268        let req = result["required"].as_array().unwrap();
1269        assert!(
1270            req.contains(&serde_json::Value::String("name".into())),
1271            "required field from charset-parameterized content-type should be present"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_extract_output_schema_json_with_charset_param() {
1277        // Issue 5: prefix matching in extract_output_schema.
1278        let op = json!({
1279            "responses": {
1280                "200": {
1281                    "content": {
1282                        "application/json; charset=utf-8": {
1283                            "schema": {
1284                                "type": "object",
1285                                "properties": {"result": {"type": "boolean"}}
1286                            }
1287                        }
1288                    }
1289                }
1290            }
1291        });
1292        let result = extract_output_schema(&op, None);
1293        assert!(
1294            result["properties"]["result"].is_object(),
1295            "application/json; charset=utf-8 output schema should be extracted; got: {result:?}"
1296        );
1297    }
1298}