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 {
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 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 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 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 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 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 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 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 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
173pub 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 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(¶m_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 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 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 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
255pub 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 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 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 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 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 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 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 let _ = deep_resolve_refs(&schema, &doc, 0);
603 }
604
605 #[test]
606 fn test_resolve_ref_to_non_dict() {
607 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 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 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 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 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 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 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 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 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 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 let doc = json!({
1106 "components": {
1107 "schemas": {
1108 "Leaf": {"type": "string"}
1109 }
1110 }
1111 });
1112 let schema = json!({"$ref": "#/components/schemas/Leaf"});
1113 let at_15 = deep_resolve_refs(&schema, &doc, 15);
1115 assert_eq!(at_15["type"], "string", "depth 15 should resolve the $ref");
1116 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 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 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 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 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 let mut schemas = serde_json::Map::new();
1220 schemas.insert("Leaf".into(), json!({"type": "string"}));
1221 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}