1use serde_json::{json, Value};
7
8pub fn resolve_ref(ref_string: &str, openapi_doc: &Value) -> Value {
12 if !ref_string.starts_with("#/") {
13 return json!({});
14 }
15
16 let parts: Vec<&str> = ref_string[2..].split('/').collect();
17 let mut current = openapi_doc;
18
19 for part in parts {
20 match current.get(part) {
21 Some(next) => current = next,
22 None => return json!({}),
23 }
24 }
25
26 if current.is_object() {
27 current.clone()
28 } else {
29 json!({})
30 }
31}
32
33pub fn resolve_schema(schema: &Value, openapi_doc: Option<&Value>) -> Value {
35 if let (Some(doc), Some(ref_str)) = (openapi_doc, schema.get("$ref").and_then(|v| v.as_str())) {
36 resolve_ref(ref_str, doc)
37 } else {
38 schema.clone()
39 }
40}
41
42pub fn deep_resolve_refs(schema: &Value, openapi_doc: &Value, depth: usize) -> Value {
47 if depth > 16 {
48 return schema.clone();
49 }
50
51 if let Some(ref_str) = schema.get("$ref").and_then(|v| v.as_str()) {
53 let resolved = resolve_ref(ref_str, openapi_doc);
54 return deep_resolve_refs(&resolved, openapi_doc, depth + 1);
55 }
56
57 let mut result = schema.clone();
58
59 if let Some(obj) = result.as_object_mut() {
60 for key in &["allOf", "anyOf", "oneOf"] {
62 if let Some(Value::Array(items)) = obj.get(*key).cloned() {
63 let resolved: Vec<Value> = items
64 .iter()
65 .map(|item| deep_resolve_refs(item, openapi_doc, depth + 1))
66 .collect();
67 obj.insert(key.to_string(), Value::Array(resolved));
68 }
69 }
70
71 if let Some(items) = obj.get("items").cloned() {
73 if items.is_object() {
74 obj.insert(
75 "items".to_string(),
76 deep_resolve_refs(&items, openapi_doc, depth + 1),
77 );
78 }
79 }
80
81 if let Some(Value::Object(props)) = obj.get("properties").cloned() {
83 let resolved: serde_json::Map<String, Value> = props
84 .into_iter()
85 .map(|(k, v)| (k, deep_resolve_refs(&v, openapi_doc, depth + 1)))
86 .collect();
87 obj.insert("properties".to_string(), Value::Object(resolved));
88 }
89 }
90
91 result
92}
93
94pub fn extract_input_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
99 let mut properties = serde_json::Map::new();
100 let mut required: Vec<Value> = Vec::new();
101
102 if let Some(Value::Array(params)) = operation.get("parameters") {
104 for param in params {
105 let in_value = param.get("in").and_then(|v| v.as_str()).unwrap_or("");
106 if in_value == "query" || in_value == "path" {
107 if let Some(name) = param.get("name").and_then(|v| v.as_str()) {
108 let param_schema = param
109 .get("schema")
110 .cloned()
111 .unwrap_or_else(|| json!({"type": "string"}));
112 let resolved = resolve_schema(¶m_schema, openapi_doc);
113 properties.insert(name.to_string(), resolved);
114
115 if param
116 .get("required")
117 .and_then(|v| v.as_bool())
118 .unwrap_or(false)
119 {
120 required.push(Value::String(name.to_string()));
121 }
122 }
123 }
124 }
125 }
126
127 if let Some(body_schema) = operation
129 .get("requestBody")
130 .and_then(|rb| rb.get("content"))
131 .and_then(|c| c.get("application/json"))
132 .and_then(|jc| jc.get("schema"))
133 {
134 let resolved = resolve_schema(body_schema, openapi_doc);
135 if let Some(props) = resolved.get("properties").and_then(|p| p.as_object()) {
136 for (k, v) in props {
137 properties.insert(k.clone(), v.clone());
138 }
139 }
140 if let Some(req) = resolved.get("required").and_then(|r| r.as_array()) {
141 required.extend(req.iter().cloned());
142 }
143 }
144
145 if let Some(doc) = openapi_doc {
147 let resolved_props: serde_json::Map<String, Value> = properties
148 .into_iter()
149 .map(|(k, v)| (k, deep_resolve_refs(&v, doc, 0)))
150 .collect();
151 properties = resolved_props;
152 }
153
154 json!({
155 "type": "object",
156 "properties": Value::Object(properties),
157 "required": Value::Array(required),
158 })
159}
160
161pub fn extract_output_schema(operation: &Value, openapi_doc: Option<&Value>) -> Value {
165 let responses = match operation.get("responses") {
166 Some(r) => r,
167 None => return json!({"type": "object", "properties": {}}),
168 };
169
170 for status_code in &["200", "201"] {
171 if let Some(schema) = responses
172 .get(*status_code)
173 .and_then(|r| r.get("content"))
174 .and_then(|c| c.get("application/json"))
175 .and_then(|jc| jc.get("schema"))
176 {
177 let mut resolved = resolve_schema(schema, openapi_doc);
178 if let Some(doc) = openapi_doc {
179 resolved = deep_resolve_refs(&resolved, doc, 0);
180 }
181 return resolved;
182 }
183 }
184
185 json!({"type": "object", "properties": {}})
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_resolve_ref_basic() {
194 let doc = json!({
195 "components": {
196 "schemas": {
197 "User": {"type": "object", "properties": {"name": {"type": "string"}}}
198 }
199 }
200 });
201 let result = resolve_ref("#/components/schemas/User", &doc);
202 assert_eq!(result["type"], "object");
203 assert!(result["properties"]["name"].is_object());
204 }
205
206 #[test]
207 fn test_resolve_ref_not_found() {
208 let doc = json!({});
209 let result = resolve_ref("#/components/schemas/Missing", &doc);
210 assert_eq!(result, json!({}));
211 }
212
213 #[test]
214 fn test_resolve_ref_non_hash() {
215 let doc = json!({});
216 let result = resolve_ref("external.json#/foo", &doc);
217 assert_eq!(result, json!({}));
218 }
219
220 #[test]
221 fn test_resolve_schema_with_ref() {
222 let doc = json!({
223 "components": {"schemas": {"Foo": {"type": "string"}}}
224 });
225 let schema = json!({"$ref": "#/components/schemas/Foo"});
226 let result = resolve_schema(&schema, Some(&doc));
227 assert_eq!(result["type"], "string");
228 }
229
230 #[test]
231 fn test_resolve_schema_no_ref() {
232 let schema = json!({"type": "integer"});
233 let result = resolve_schema(&schema, None);
234 assert_eq!(result["type"], "integer");
235 }
236
237 #[test]
238 fn test_extract_input_schema_parameters() {
239 let op = json!({
240 "parameters": [
241 {"name": "user_id", "in": "path", "required": true, "schema": {"type": "integer"}},
242 {"name": "limit", "in": "query", "schema": {"type": "integer"}}
243 ]
244 });
245 let result = extract_input_schema(&op, None);
246 assert!(result["properties"]["user_id"].is_object());
247 assert!(result["properties"]["limit"].is_object());
248 let req = result["required"].as_array().unwrap();
249 assert!(req.contains(&Value::String("user_id".into())));
250 assert!(!req.contains(&Value::String("limit".into())));
251 }
252
253 #[test]
254 fn test_extract_input_schema_request_body() {
255 let op = json!({
256 "requestBody": {
257 "content": {
258 "application/json": {
259 "schema": {
260 "type": "object",
261 "properties": {"title": {"type": "string"}},
262 "required": ["title"]
263 }
264 }
265 }
266 }
267 });
268 let result = extract_input_schema(&op, None);
269 assert_eq!(result["properties"]["title"]["type"], "string");
270 let req = result["required"].as_array().unwrap();
271 assert!(req.contains(&Value::String("title".into())));
272 }
273
274 #[test]
275 fn test_extract_input_schema_with_ref() {
276 let doc = json!({
277 "components": {
278 "schemas": {
279 "TaskInput": {
280 "type": "object",
281 "properties": {"name": {"type": "string"}},
282 "required": ["name"]
283 }
284 }
285 }
286 });
287 let op = json!({
288 "requestBody": {
289 "content": {
290 "application/json": {
291 "schema": {"$ref": "#/components/schemas/TaskInput"}
292 }
293 }
294 }
295 });
296 let result = extract_input_schema(&op, Some(&doc));
297 assert_eq!(result["properties"]["name"]["type"], "string");
298 }
299
300 #[test]
301 fn test_extract_output_schema_200() {
302 let op = json!({
303 "responses": {
304 "200": {
305 "content": {
306 "application/json": {
307 "schema": {"type": "object", "properties": {"id": {"type": "integer"}}}
308 }
309 }
310 }
311 }
312 });
313 let result = extract_output_schema(&op, None);
314 assert_eq!(result["properties"]["id"]["type"], "integer");
315 }
316
317 #[test]
318 fn test_extract_output_schema_fallback() {
319 let op = json!({"responses": {"404": {}}});
320 let result = extract_output_schema(&op, None);
321 assert_eq!(result["type"], "object");
322 }
323
324 #[test]
325 fn test_deep_resolve_nested_ref() {
326 let doc = json!({
327 "components": {
328 "schemas": {
329 "Address": {"type": "object", "properties": {"city": {"type": "string"}}},
330 "User": {
331 "type": "object",
332 "properties": {
333 "address": {"$ref": "#/components/schemas/Address"}
334 }
335 }
336 }
337 }
338 });
339 let schema = json!({"$ref": "#/components/schemas/User"});
340 let result = deep_resolve_refs(&schema, &doc, 0);
341 assert_eq!(
342 result["properties"]["address"]["properties"]["city"]["type"],
343 "string"
344 );
345 }
346
347 #[test]
348 fn test_deep_resolve_depth_limit() {
349 let doc = json!({
351 "components": {
352 "schemas": {
353 "Recursive": {
354 "type": "object",
355 "properties": {
356 "child": {"$ref": "#/components/schemas/Recursive"}
357 }
358 }
359 }
360 }
361 });
362 let schema = json!({"$ref": "#/components/schemas/Recursive"});
363 let _ = deep_resolve_refs(&schema, &doc, 0);
365 }
366
367 #[test]
368 fn test_resolve_ref_to_non_dict() {
369 let doc = json!({
371 "components": {
372 "schemas": {
373 "JustAString": "hello"
374 }
375 }
376 });
377 let result = resolve_ref("#/components/schemas/JustAString", &doc);
378 assert_eq!(result, json!({}));
379
380 let doc2 = json!({
382 "components": {
383 "schemas": {
384 "JustANumber": 42
385 }
386 }
387 });
388 let result2 = resolve_ref("#/components/schemas/JustANumber", &doc2);
389 assert_eq!(result2, json!({}));
390 }
391
392 #[test]
393 fn test_resolve_ref_through_missing_path() {
394 let doc = json!({
396 "components": {}
397 });
398 let result = resolve_ref("#/components/schemas/Missing", &doc);
399 assert_eq!(result, json!({}));
400 }
401
402 #[test]
403 fn test_resolve_schema_no_openapi_doc() {
404 let schema = json!({"$ref": "#/components/schemas/Foo", "type": "string"});
406 let result = resolve_schema(&schema, None);
407 assert_eq!(result, schema);
408 }
409
410 #[test]
411 fn test_deep_resolve_refs_in_allof() {
412 let doc = json!({
413 "components": {
414 "schemas": {
415 "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
416 "Extra": {"type": "object", "properties": {"tag": {"type": "string"}}}
417 }
418 }
419 });
420 let schema = json!({
421 "allOf": [
422 {"$ref": "#/components/schemas/Base"},
423 {"$ref": "#/components/schemas/Extra"}
424 ]
425 });
426 let result = deep_resolve_refs(&schema, &doc, 0);
427 let all_of = result["allOf"].as_array().unwrap();
428 assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
429 assert_eq!(all_of[1]["properties"]["tag"]["type"], "string");
430 }
431
432 #[test]
433 fn test_deep_resolve_refs_in_anyof() {
434 let doc = json!({
435 "components": {
436 "schemas": {
437 "Cat": {"type": "object", "properties": {"purrs": {"type": "boolean"}}},
438 "Dog": {"type": "object", "properties": {"barks": {"type": "boolean"}}}
439 }
440 }
441 });
442 let schema = json!({
443 "anyOf": [
444 {"$ref": "#/components/schemas/Cat"},
445 {"$ref": "#/components/schemas/Dog"}
446 ]
447 });
448 let result = deep_resolve_refs(&schema, &doc, 0);
449 let any_of = result["anyOf"].as_array().unwrap();
450 assert_eq!(any_of[0]["properties"]["purrs"]["type"], "boolean");
451 assert_eq!(any_of[1]["properties"]["barks"]["type"], "boolean");
452 }
453
454 #[test]
455 fn test_deep_resolve_refs_in_items() {
456 let doc = json!({
457 "components": {
458 "schemas": {
459 "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
460 }
461 }
462 });
463 let schema = json!({
464 "type": "array",
465 "items": {"$ref": "#/components/schemas/Item"}
466 });
467 let result = deep_resolve_refs(&schema, &doc, 0);
468 assert_eq!(result["items"]["properties"]["name"]["type"], "string");
469 }
470
471 #[test]
472 fn test_deep_resolve_no_mutation() {
473 let doc = json!({
474 "components": {
475 "schemas": {
476 "Addr": {"type": "object", "properties": {"city": {"type": "string"}}}
477 }
478 }
479 });
480 let doc_before = doc.clone();
481 let schema = json!({
482 "type": "object",
483 "properties": {
484 "address": {"$ref": "#/components/schemas/Addr"}
485 }
486 });
487 let _result = deep_resolve_refs(&schema, &doc, 0);
488 assert_eq!(doc, doc_before, "openapi_doc must not be mutated");
489 }
490
491 #[test]
492 fn test_extract_input_schema_empty_operation() {
493 let op = json!({});
494 let result = extract_input_schema(&op, None);
495 assert_eq!(result["type"], "object");
496 assert!(result["properties"].as_object().unwrap().is_empty());
497 assert!(result["required"].as_array().unwrap().is_empty());
498 }
499
500 #[test]
501 fn test_extract_input_schema_ref_in_param() {
502 let doc = json!({
503 "components": {
504 "schemas": {
505 "IdType": {"type": "integer", "format": "int64"}
506 }
507 }
508 });
509 let op = json!({
510 "parameters": [
511 {
512 "name": "user_id",
513 "in": "path",
514 "required": true,
515 "schema": {"$ref": "#/components/schemas/IdType"}
516 }
517 ]
518 });
519 let result = extract_input_schema(&op, Some(&doc));
520 assert_eq!(result["properties"]["user_id"]["type"], "integer");
521 assert_eq!(result["properties"]["user_id"]["format"], "int64");
522 }
523
524 #[test]
525 fn test_extract_input_schema_nested_ref_in_body() {
526 let doc = json!({
527 "components": {
528 "schemas": {
529 "Address": {"type": "object", "properties": {"zip": {"type": "string"}}}
530 }
531 }
532 });
533 let op = json!({
534 "requestBody": {
535 "content": {
536 "application/json": {
537 "schema": {
538 "type": "object",
539 "properties": {
540 "address": {"$ref": "#/components/schemas/Address"}
541 }
542 }
543 }
544 }
545 }
546 });
547 let result = extract_input_schema(&op, Some(&doc));
548 assert_eq!(
549 result["properties"]["address"]["properties"]["zip"]["type"],
550 "string"
551 );
552 }
553
554 #[test]
555 fn test_extract_output_schema_201() {
556 let op = json!({
557 "responses": {
558 "201": {
559 "content": {
560 "application/json": {
561 "schema": {
562 "type": "object",
563 "properties": {"id": {"type": "integer"}}
564 }
565 }
566 }
567 }
568 }
569 });
570 let result = extract_output_schema(&op, None);
571 assert_eq!(result["properties"]["id"]["type"], "integer");
572 }
573
574 #[test]
575 fn test_extract_output_schema_200_preferred() {
576 let op = json!({
577 "responses": {
578 "200": {
579 "content": {
580 "application/json": {
581 "schema": {
582 "type": "object",
583 "properties": {"from200": {"type": "string"}}
584 }
585 }
586 }
587 },
588 "201": {
589 "content": {
590 "application/json": {
591 "schema": {
592 "type": "object",
593 "properties": {"from201": {"type": "string"}}
594 }
595 }
596 }
597 }
598 }
599 });
600 let result = extract_output_schema(&op, None);
601 assert!(
602 result["properties"]
603 .as_object()
604 .unwrap()
605 .contains_key("from200"),
606 "200 should be preferred over 201"
607 );
608 assert!(
609 !result["properties"]
610 .as_object()
611 .unwrap()
612 .contains_key("from201"),
613 "201 should not be used when 200 exists"
614 );
615 }
616
617 #[test]
618 fn test_extract_output_schema_array_with_ref_items() {
619 let doc = json!({
620 "components": {
621 "schemas": {
622 "Item": {"type": "object", "properties": {"name": {"type": "string"}}}
623 }
624 }
625 });
626 let op = json!({
627 "responses": {
628 "200": {
629 "content": {
630 "application/json": {
631 "schema": {
632 "type": "array",
633 "items": {"$ref": "#/components/schemas/Item"}
634 }
635 }
636 }
637 }
638 }
639 });
640 let result = extract_output_schema(&op, Some(&doc));
641 assert_eq!(result["type"], "array");
642 assert_eq!(result["items"]["properties"]["name"]["type"], "string");
643 }
644
645 #[test]
646 fn test_extract_output_schema_allof() {
647 let doc = json!({
648 "components": {
649 "schemas": {
650 "Base": {"type": "object", "properties": {"id": {"type": "integer"}}},
651 "Meta": {"type": "object", "properties": {"created": {"type": "string"}}}
652 }
653 }
654 });
655 let op = json!({
656 "responses": {
657 "200": {
658 "content": {
659 "application/json": {
660 "schema": {
661 "allOf": [
662 {"$ref": "#/components/schemas/Base"},
663 {"$ref": "#/components/schemas/Meta"}
664 ]
665 }
666 }
667 }
668 }
669 }
670 });
671 let result = extract_output_schema(&op, Some(&doc));
672 let all_of = result["allOf"].as_array().unwrap();
673 assert_eq!(all_of[0]["properties"]["id"]["type"], "integer");
674 assert_eq!(all_of[1]["properties"]["created"]["type"], "string");
675 }
676
677 #[test]
678 fn test_extract_output_schema_nested_ref() {
679 let doc = json!({
680 "components": {
681 "schemas": {
682 "Inner": {"type": "object", "properties": {"val": {"type": "number"}}}
683 }
684 }
685 });
686 let op = json!({
687 "responses": {
688 "200": {
689 "content": {
690 "application/json": {
691 "schema": {
692 "type": "object",
693 "properties": {
694 "nested": {"$ref": "#/components/schemas/Inner"}
695 }
696 }
697 }
698 }
699 }
700 }
701 });
702 let result = extract_output_schema(&op, Some(&doc));
703 assert_eq!(
704 result["properties"]["nested"]["properties"]["val"]["type"],
705 "number"
706 );
707 }
708
709 #[test]
710 fn test_extract_output_schema_empty_responses() {
711 let op = json!({"operationId": "noResponses"});
713 let result = extract_output_schema(&op, None);
714 assert_eq!(result["type"], "object");
715 assert!(result["properties"].as_object().unwrap().is_empty());
716 }
717}