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