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