1use crate::swagger_convert;
9use mockforge_foundation::error::{Error, Result};
10use openapiv3::{OpenAPI, ReferenceOr, Schema};
11use std::collections::HashSet;
12use std::path::Path;
13use tokio::fs;
14use tracing;
15
16const OPERATION_KEYS: [&str; 8] = [
18 "get", "put", "post", "delete", "options", "head", "patch", "trace",
19];
20
21fn fill_missing_operation_responses(raw: &mut serde_json::Value) -> usize {
44 let Some(paths) = raw.get_mut("paths").and_then(|p| p.as_object_mut()) else {
45 return 0;
46 };
47
48 let mut repaired = 0;
49 for (_path, item) in paths.iter_mut() {
50 let Some(item) = item.as_object_mut() else {
51 continue;
52 };
53 for method in OPERATION_KEYS {
54 let Some(op) = item.get_mut(method).and_then(|o| o.as_object_mut()) else {
55 continue;
56 };
57 if !op.contains_key("responses") {
58 op.insert(
59 "responses".to_string(),
60 serde_json::Value::Object(serde_json::Map::new()),
61 );
62 repaired += 1;
63 }
64 }
65 }
66 repaired
67}
68
69const JSON_SCHEMA_TYPE_NAMES: &[&str] = &[
73 "null", "boolean", "object", "array", "number", "string", "integer",
74];
75
76fn is_json_schema_type_name(name: &str) -> bool {
77 JSON_SCHEMA_TYPE_NAMES.contains(&name)
78}
79
80fn coerce_json_schema_type_arrays(raw: &mut serde_json::Value) -> usize {
101 fn walk(value: &mut serde_json::Value) -> usize {
102 let mut repaired = 0;
103 match value {
104 serde_json::Value::Object(map) => {
105 repaired += coerce_type_array_in_object(map);
106 for child in map.values_mut() {
107 repaired += walk(child);
108 }
109 }
110 serde_json::Value::Array(items) => {
111 for child in items {
112 repaired += walk(child);
113 }
114 }
115 _ => {}
116 }
117 repaired
118 }
119 walk(raw)
120}
121
122fn coerce_type_array_in_object(map: &mut serde_json::Map<String, serde_json::Value>) -> usize {
123 let Some(serde_json::Value::Array(items)) = map.get("type") else {
124 return 0;
125 };
126 if items.is_empty() {
127 return 0;
128 }
129
130 let mut names = Vec::with_capacity(items.len());
134 for item in items {
135 let Some(name) = item.as_str() else {
136 return 0;
137 };
138 if !is_json_schema_type_name(name) {
139 return 0;
140 }
141 names.push(name.to_string());
142 }
143
144 let nullable = names.iter().any(|n| n == "null");
145 let mut non_null = names.into_iter().filter(|n| n != "null");
146 let primary = non_null.next().unwrap_or_else(|| "string".to_string());
149
150 map.insert("type".to_string(), serde_json::Value::String(primary));
151 if nullable {
152 map.insert("nullable".to_string(), serde_json::Value::Bool(true));
154 }
155 1
156}
157
158fn repair_spec_for_parsing(raw: &mut serde_json::Value, source: &str) {
162 let repaired = fill_missing_operation_responses(raw);
163 if repaired > 0 {
164 tracing::warn!(
165 "{source}: {repaired} operation(s) had no `responses` field, which OpenAPI 3.x \
166 requires. Treating them as having no declared responses so the spec can load. \
167 Request generation does not use `responses`, but response-schema validation \
168 will have nothing to check for these operations."
169 );
170 }
171
172 let type_arrays = coerce_json_schema_type_arrays(raw);
173 if type_arrays > 0 {
174 tracing::warn!(
175 "{source}: {type_arrays} schema `type` value(s) were OpenAPI 3.1 type arrays \
176 (for example `[\"string\", \"null\"]`). Coerced them to OpenAPI 3.0 `type` + \
177 `nullable` so the spec can load."
178 );
179 }
180}
181
182#[derive(Debug, Clone)]
184pub struct OpenApiSpec {
185 pub spec: OpenAPI,
187 pub file_path: Option<String>,
189 pub raw_document: Option<serde_json::Value>,
191}
192
193impl OpenApiSpec {
194 pub async fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
199 let path_ref = path.as_ref();
200 let content = fs::read_to_string(path_ref)
201 .await
202 .map_err(|e| Error::io_with_context("reading OpenAPI spec file", e.to_string()))?;
203
204 let raw_json = if path_ref.extension().and_then(|s| s.to_str()) == Some("yaml")
205 || path_ref.extension().and_then(|s| s.to_str()) == Some("yml")
206 {
207 let yaml_value: serde_yaml::Value = serde_yaml::from_str(&content)
208 .map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
209 serde_json::to_value(&yaml_value).map_err(|e| {
210 Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
211 })?
212 } else {
213 serde_json::from_str(&content)
214 .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
215 };
216
217 let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
219 tracing::info!("Detected Swagger 2.0 specification, converting to OpenAPI 3.0");
220 let converted =
221 swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
222 Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
223 })?;
224 let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
225 Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
226 })?;
227 (converted, spec)
228 } else {
229 let mut raw_json = raw_json;
230 repair_spec_for_parsing(&mut raw_json, "OpenAPI spec");
231 let spec: OpenAPI = serde_json::from_value(raw_json.clone()).map_err(|e| {
232 let error_str = format!("{}", e);
234 let mut error_msg = format!("Failed to read OpenAPI spec: {}", e);
235
236 if error_str.contains("missing field") {
238 tracing::error!("OpenAPI deserialization error: {}", error_str);
239
240 if let Some(info) = raw_json.get("info") {
242 if let Some(info_obj) = info.as_object() {
243 let has_desc = info_obj.contains_key("description");
244 error_msg
245 .push_str(&format!(" | Info.description present: {}", has_desc));
246 }
247 }
248 if let Some(servers) = raw_json.get("servers") {
249 if let Some(servers_arr) = servers.as_array() {
250 error_msg.push_str(&format!(" | Servers count: {}", servers_arr.len()));
251 }
252 }
253 }
254
255 Error::config(error_msg)
256 })?;
257 (raw_json, spec)
258 };
259
260 Ok(Self {
261 spec,
262 file_path: path_ref.to_str().map(|s| s.to_string()),
263 raw_document: Some(raw_document),
264 })
265 }
266
267 pub fn from_string(content: &str, format: Option<&str>) -> Result<Self> {
272 let raw_json = if format == Some("yaml") || format == Some("yml") {
273 let yaml_value: serde_yaml::Value = serde_yaml::from_str(content)
274 .map_err(|e| Error::config(format!("Failed to parse YAML OpenAPI spec: {}", e)))?;
275 serde_json::to_value(&yaml_value).map_err(|e| {
276 Error::config(format!("Failed to convert YAML OpenAPI spec to JSON: {}", e))
277 })?
278 } else {
279 serde_json::from_str(content)
280 .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?
281 };
282
283 let (raw_document, spec) = if swagger_convert::is_swagger_2(&raw_json) {
285 let converted =
286 swagger_convert::convert_swagger_to_openapi3(&raw_json).map_err(|e| {
287 Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
288 })?;
289 let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
290 Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
291 })?;
292 (converted, spec)
293 } else {
294 let mut raw_json = raw_json;
295 repair_spec_for_parsing(&mut raw_json, "OpenAPI spec");
296 let spec: OpenAPI = serde_json::from_value(raw_json.clone())
297 .map_err(|e| Error::io_with_context("reading OpenAPI spec", e.to_string()))?;
298 (raw_json, spec)
299 };
300
301 Ok(Self {
302 spec,
303 file_path: None,
304 raw_document: Some(raw_document),
305 })
306 }
307
308 pub fn from_json(json: serde_json::Value) -> Result<Self> {
313 let (raw_document, spec) = if swagger_convert::is_swagger_2(&json) {
315 let converted = swagger_convert::convert_swagger_to_openapi3(&json).map_err(|e| {
316 Error::config(format!("Failed to convert Swagger 2.0 to OpenAPI 3.0: {}", e))
317 })?;
318 let spec: OpenAPI = serde_json::from_value(converted.clone()).map_err(|e| {
319 Error::config(format!("Failed to parse converted OpenAPI spec: {}", e))
320 })?;
321 (converted, spec)
322 } else {
323 let mut json = json;
324 repair_spec_for_parsing(&mut json, "OpenAPI spec");
325 let spec: OpenAPI = serde_json::from_value(json.clone())
326 .map_err(|e| Error::config(format!("Failed to parse JSON OpenAPI spec: {}", e)))?;
327 (json, spec)
328 };
329
330 Ok(Self {
331 spec,
332 file_path: None,
333 raw_document: Some(raw_document),
334 })
335 }
336
337 pub fn validate(&self) -> Result<()> {
342 if self.spec.paths.paths.is_empty() {
344 return Err(Error::validation("OpenAPI spec must contain at least one path"));
345 }
346
347 if self.spec.info.title.is_empty() {
349 return Err(Error::validation("OpenAPI spec info must have a title"));
350 }
351
352 if self.spec.info.version.is_empty() {
353 return Err(Error::validation("OpenAPI spec info must have a version"));
354 }
355
356 Ok(())
357 }
358
359 pub fn validate_enhanced(&self) -> crate::spec_parser::ValidationResult {
361 if let Some(raw) = &self.raw_document {
363 let format = if raw.get("swagger").is_some() {
364 crate::spec_parser::SpecFormat::OpenApi20
365 } else if let Some(version) = raw.get("openapi").and_then(|v| v.as_str()) {
366 if version.starts_with("3.1") {
367 crate::spec_parser::SpecFormat::OpenApi31
368 } else {
369 crate::spec_parser::SpecFormat::OpenApi30
370 }
371 } else {
372 crate::spec_parser::SpecFormat::OpenApi30
374 };
375 crate::spec_parser::OpenApiValidator::validate(raw, format)
376 } else {
377 crate::spec_parser::ValidationResult::failure(vec![
379 crate::spec_parser::ValidationError::new(
380 "Cannot perform enhanced validation without raw document".to_string(),
381 ),
382 ])
383 }
384 }
385
386 pub fn version(&self) -> &str {
388 &self.spec.openapi
389 }
390
391 pub fn title(&self) -> &str {
393 &self.spec.info.title
394 }
395
396 pub fn description(&self) -> Option<&str> {
398 self.spec.info.description.as_deref()
399 }
400
401 pub fn api_version(&self) -> &str {
403 &self.spec.info.version
404 }
405
406 pub fn servers(&self) -> &[openapiv3::Server] {
408 &self.spec.servers
409 }
410
411 pub fn paths(&self) -> &openapiv3::Paths {
413 &self.spec.paths
414 }
415
416 pub fn schemas(&self) -> Option<&indexmap::IndexMap<String, ReferenceOr<Schema>>> {
418 self.spec.components.as_ref().map(|c| &c.schemas)
419 }
420
421 pub fn security_schemes(
423 &self,
424 ) -> Option<&indexmap::IndexMap<String, ReferenceOr<openapiv3::SecurityScheme>>> {
425 self.spec.components.as_ref().map(|c| &c.security_schemes)
426 }
427
428 pub fn operations_for_path(
430 &self,
431 path: &str,
432 ) -> std::collections::HashMap<String, openapiv3::Operation> {
433 let mut operations = std::collections::HashMap::new();
434
435 if let Some(path_item_ref) = self.spec.paths.paths.get(path) {
436 if let Some(path_item) = path_item_ref.as_item() {
438 let resolved_path_params: Vec<ReferenceOr<openapiv3::Parameter>> =
456 path_item.parameters.iter().map(|p| self.resolve_parameter_ref(p)).collect();
457 let merge = |op: &openapiv3::Operation| -> openapiv3::Operation {
458 let mut resolved_op = op.clone();
460 resolved_op.parameters =
461 op.parameters.iter().map(|p| self.resolve_parameter_ref(p)).collect();
462 merge_path_params_into_operation(&resolved_op, &resolved_path_params)
463 };
464 if let Some(op) = &path_item.get {
465 operations.insert("GET".to_string(), merge(op));
466 }
467 if let Some(op) = &path_item.post {
468 operations.insert("POST".to_string(), merge(op));
469 }
470 if let Some(op) = &path_item.put {
471 operations.insert("PUT".to_string(), merge(op));
472 }
473 if let Some(op) = &path_item.delete {
474 operations.insert("DELETE".to_string(), merge(op));
475 }
476 if let Some(op) = &path_item.patch {
477 operations.insert("PATCH".to_string(), merge(op));
478 }
479 if let Some(op) = &path_item.head {
480 operations.insert("HEAD".to_string(), merge(op));
481 }
482 if let Some(op) = &path_item.options {
483 operations.insert("OPTIONS".to_string(), merge(op));
484 }
485 if let Some(op) = &path_item.trace {
486 operations.insert("TRACE".to_string(), merge(op));
487 }
488 }
489 }
490
491 operations
492 }
493
494 pub fn all_paths_and_operations(
496 &self,
497 ) -> std::collections::HashMap<String, std::collections::HashMap<String, openapiv3::Operation>>
498 {
499 self.spec
500 .paths
501 .paths
502 .iter()
503 .map(|(path, _)| (path.clone(), self.operations_for_path(path)))
504 .collect()
505 }
506
507 pub fn get_schema(&self, reference: &str) -> Option<crate::schema::OpenApiSchema> {
509 self.resolve_schema(reference).map(crate::schema::OpenApiSchema::new)
510 }
511
512 pub fn resolve_schema_ref(&self, reference: &str) -> Option<Schema> {
517 self.resolve_schema(reference)
518 }
519
520 pub fn resolve_parameter_ref(
534 &self,
535 p_ref: &ReferenceOr<openapiv3::Parameter>,
536 ) -> ReferenceOr<openapiv3::Parameter> {
537 match p_ref {
538 ReferenceOr::Item(_) => p_ref.clone(),
539 ReferenceOr::Reference { reference } => {
540 let Some(name) = reference.strip_prefix("#/components/parameters/") else {
541 return p_ref.clone();
542 };
543 let Some(components) = self.spec.components.as_ref() else {
544 return p_ref.clone();
545 };
546 match components.parameters.get(name) {
547 Some(ReferenceOr::Item(p)) => ReferenceOr::Item(p.clone()),
548 Some(ReferenceOr::Reference { reference: nested }) => {
549 let Some(nested_name) = nested.strip_prefix("#/components/parameters/")
552 else {
553 return p_ref.clone();
554 };
555 match components.parameters.get(nested_name) {
556 Some(ReferenceOr::Item(p)) => ReferenceOr::Item(p.clone()),
557 _ => p_ref.clone(),
558 }
559 }
560 None => p_ref.clone(),
561 }
562 }
563 }
564 }
565
566 pub fn validate_security_requirements(
568 &self,
569 security_requirements: &[openapiv3::SecurityRequirement],
570 auth_header: Option<&str>,
571 api_key: Option<&str>,
572 ) -> Result<()> {
573 if security_requirements.is_empty() {
574 return Ok(());
575 }
576
577 for requirement in security_requirements {
579 if self.is_security_requirement_satisfied(requirement, auth_header, api_key)? {
580 return Ok(());
581 }
582 }
583
584 Err(Error::validation(
585 "Security validation failed: no valid authentication provided",
586 ))
587 }
588
589 fn resolve_schema(&self, reference: &str) -> Option<Schema> {
590 let mut visited = HashSet::new();
591 self.resolve_schema_recursive(reference, &mut visited)
592 }
593
594 fn resolve_schema_recursive(
595 &self,
596 reference: &str,
597 visited: &mut HashSet<String>,
598 ) -> Option<Schema> {
599 if !visited.insert(reference.to_string()) {
600 tracing::warn!("Detected recursive schema reference: {}", reference);
601 return None;
602 }
603
604 let schema_name = reference.strip_prefix("#/components/schemas/")?;
605 let components = self.spec.components.as_ref()?;
606 let schema_ref = components.schemas.get(schema_name)?;
607
608 match schema_ref {
609 ReferenceOr::Item(schema) => Some(schema.clone()),
610 ReferenceOr::Reference { reference: nested } => {
611 self.resolve_schema_recursive(nested, visited)
612 }
613 }
614 }
615
616 fn is_security_requirement_satisfied(
618 &self,
619 requirement: &openapiv3::SecurityRequirement,
620 auth_header: Option<&str>,
621 api_key: Option<&str>,
622 ) -> Result<bool> {
623 for (scheme_name, _scopes) in requirement {
625 if !self.is_security_scheme_satisfied(scheme_name, auth_header, api_key)? {
626 return Ok(false);
627 }
628 }
629 Ok(true)
630 }
631
632 fn is_security_scheme_satisfied(
634 &self,
635 scheme_name: &str,
636 auth_header: Option<&str>,
637 api_key: Option<&str>,
638 ) -> Result<bool> {
639 let security_schemes = match self.security_schemes() {
640 Some(schemes) => schemes,
641 None => return Ok(false),
642 };
643
644 let scheme = match security_schemes.get(scheme_name) {
645 Some(scheme) => scheme,
646 None => {
647 return Err(Error::config(format!("Security scheme '{}' not found", scheme_name)))
648 }
649 };
650
651 let scheme = match scheme {
652 ReferenceOr::Item(s) => s,
653 ReferenceOr::Reference { reference } => {
654 let ref_name =
656 reference.strip_prefix("#/components/securitySchemes/").ok_or_else(|| {
657 Error::config(format!(
658 "Unsupported security scheme reference format: {}",
659 reference
660 ))
661 })?;
662 match security_schemes.get(ref_name) {
663 Some(ReferenceOr::Item(resolved)) => resolved,
664 Some(ReferenceOr::Reference { .. }) => {
665 return Err(Error::config(format!(
666 "Nested security scheme reference not supported: {}",
667 ref_name
668 )))
669 }
670 None => {
671 return Err(Error::config(format!(
672 "Security scheme '{}' not found",
673 ref_name
674 )))
675 }
676 }
677 }
678 };
679
680 match scheme {
681 openapiv3::SecurityScheme::HTTP { scheme, .. } => {
682 match scheme.as_str() {
683 "bearer" => match auth_header {
684 Some(header) if header.starts_with("Bearer ") => Ok(true),
685 _ => Ok(false),
686 },
687 "basic" => match auth_header {
688 Some(header) if header.starts_with("Basic ") => Ok(true),
689 _ => Ok(false),
690 },
691 _ => Ok(false), }
693 }
694 openapiv3::SecurityScheme::APIKey { location, .. } => match location {
695 openapiv3::APIKeyLocation::Header => Ok(auth_header.is_some()),
696 openapiv3::APIKeyLocation::Query => Ok(api_key.is_some()),
697 openapiv3::APIKeyLocation::Cookie => Ok(api_key.is_some()),
698 },
699 openapiv3::SecurityScheme::OpenIDConnect { .. } => {
700 match auth_header {
702 Some(header) if header.starts_with("Bearer ") => Ok(true),
703 _ => Ok(false),
704 }
705 }
706 openapiv3::SecurityScheme::OAuth2 { .. } => {
707 match auth_header {
709 Some(header) if header.starts_with("Bearer ") => Ok(true),
710 _ => Ok(false),
711 }
712 }
713 }
714 }
715
716 pub fn get_global_security_requirements(&self) -> Vec<openapiv3::SecurityRequirement> {
718 self.spec.security.clone().unwrap_or_default()
719 }
720
721 pub fn get_request_body(&self, reference: &str) -> Option<&openapiv3::RequestBody> {
723 if let Some(components) = &self.spec.components {
724 if let Some(param_name) = reference.strip_prefix("#/components/requestBodies/") {
725 if let Some(request_body_ref) = components.request_bodies.get(param_name) {
726 return request_body_ref.as_item();
727 }
728 }
729 }
730 None
731 }
732
733 pub fn get_response(&self, reference: &str) -> Option<&openapiv3::Response> {
735 if let Some(components) = &self.spec.components {
736 if let Some(response_name) = reference.strip_prefix("#/components/responses/") {
737 if let Some(response_ref) = components.responses.get(response_name) {
738 return response_ref.as_item();
739 }
740 }
741 }
742 None
743 }
744
745 pub fn get_example(&self, reference: &str) -> Option<&openapiv3::Example> {
747 if let Some(components) = &self.spec.components {
748 if let Some(example_name) = reference.strip_prefix("#/components/examples/") {
749 if let Some(example_ref) = components.examples.get(example_name) {
750 return example_ref.as_item();
751 }
752 }
753 }
754 None
755 }
756}
757
758pub(crate) fn merge_path_params_into_operation(
767 operation: &openapiv3::Operation,
768 path_level_params: &[ReferenceOr<openapiv3::Parameter>],
769) -> openapiv3::Operation {
770 use std::collections::HashSet;
771 if path_level_params.is_empty() {
772 return operation.clone();
773 }
774 let mut op_keys: HashSet<(String, String)> = HashSet::new();
775 for p_ref in &operation.parameters {
776 if let Some(key) = parameter_key(p_ref) {
777 op_keys.insert(key);
778 }
779 }
780 let mut merged: Vec<ReferenceOr<openapiv3::Parameter>> =
781 Vec::with_capacity(path_level_params.len() + operation.parameters.len());
782 for p_ref in path_level_params {
783 match parameter_key(p_ref) {
784 Some(key) if op_keys.contains(&key) => {}
785 _ => merged.push(p_ref.clone()),
786 }
787 }
788 merged.extend(operation.parameters.iter().cloned());
789 let mut cloned = operation.clone();
790 cloned.parameters = merged;
791 cloned
792}
793
794fn parameter_key(p_ref: &ReferenceOr<openapiv3::Parameter>) -> Option<(String, String)> {
795 let p = p_ref.as_item()?;
796 let (name, in_loc) = match p {
797 openapiv3::Parameter::Path { parameter_data, .. } => (parameter_data.name.clone(), "path"),
798 openapiv3::Parameter::Query { parameter_data, .. } => {
799 (parameter_data.name.clone(), "query")
800 }
801 openapiv3::Parameter::Header { parameter_data, .. } => {
802 (parameter_data.name.clone(), "header")
803 }
804 openapiv3::Parameter::Cookie { parameter_data, .. } => {
805 (parameter_data.name.clone(), "cookie")
806 }
807 };
808 Some((name, in_loc.to_string()))
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use openapiv3::{SchemaKind, Type};
815
816 #[test]
817 fn resolves_security_scheme_ref() {
818 let yaml = r#"
819openapi: 3.0.3
820info:
821 title: Test API
822 version: "1.0.0"
823paths:
824 /test:
825 get:
826 security:
827 - BearerRef: []
828 responses:
829 '200':
830 description: OK
831components:
832 securitySchemes:
833 BearerAuth:
834 type: http
835 scheme: bearer
836 BearerRef:
837 $ref: '#/components/securitySchemes/BearerAuth'
838 "#;
839
840 let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
841
842 let result = spec
844 .is_security_scheme_satisfied("BearerRef", Some("Bearer token123"), None)
845 .expect("should resolve ref");
846 assert!(result);
847
848 let result = spec
850 .is_security_scheme_satisfied("BearerRef", None, None)
851 .expect("should resolve ref");
852 assert!(!result);
853 }
854
855 #[test]
856 fn resolves_nested_schema_references() {
857 let yaml = r#"
858openapi: 3.0.3
859info:
860 title: Test API
861 version: "1.0.0"
862paths: {}
863components:
864 schemas:
865 Apiary:
866 type: object
867 properties:
868 id:
869 type: string
870 hive:
871 $ref: '#/components/schemas/Hive'
872 Hive:
873 type: object
874 properties:
875 name:
876 type: string
877 HiveWrapper:
878 $ref: '#/components/schemas/Hive'
879 "#;
880
881 let spec = OpenApiSpec::from_string(yaml, Some("yaml")).expect("spec parses");
882
883 let apiary = spec.get_schema("#/components/schemas/Apiary").expect("resolve apiary schema");
884 assert!(matches!(apiary.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
885
886 let wrapper = spec
887 .get_schema("#/components/schemas/HiveWrapper")
888 .expect("resolve wrapper schema");
889 assert!(matches!(wrapper.schema.schema_kind, SchemaKind::Type(Type::Object(_))));
890 }
891}
892
893#[cfg(test)]
894mod missing_responses_tests {
895 use super::*;
896
897 #[test]
901 fn spec_without_operation_responses_loads() {
902 let raw = r#"{
903 "openapi": "3.0.0",
904 "info": { "title": "proxy-generated", "version": "1.0.0" },
905 "paths": {
906 "/orders": {
907 "get": { "summary": "list" },
908 "post": {
909 "summary": "create",
910 "requestBody": {
911 "content": { "application/json": { "schema": { "type": "object" } } }
912 }
913 }
914 }
915 }
916 }"#;
917
918 let spec = OpenApiSpec::from_string(raw, Some("json"))
919 .expect("a spec missing `responses` should still load for bench/load use");
920
921 let item = spec.spec.paths.paths.get("/orders").expect("path present");
922 let ReferenceOr::Item(item) = item else {
923 panic!("expected inline path item");
924 };
925 assert!(item.get.is_some(), "GET survived the repair");
926 assert!(item.post.is_some(), "POST survived the repair");
927 assert!(
928 item.post.as_ref().unwrap().request_body.is_some(),
929 "requestBody must be preserved — it is what request generation actually reads"
930 );
931 }
932
933 #[test]
936 fn existing_responses_are_left_alone() {
937 let mut raw: serde_json::Value = serde_json::from_str(
938 r#"{"paths": {"/a": {"get": {"responses": {"200": {"description": "ok"}}}}}}"#,
939 )
940 .unwrap();
941 let before = raw.clone();
942 assert_eq!(fill_missing_operation_responses(&mut raw), 0);
943 assert_eq!(raw, before, "a conformant spec must be byte-identical after the pass");
944 }
945
946 #[test]
949 fn non_operation_keys_are_not_touched() {
950 let mut raw: serde_json::Value = serde_json::from_str(
951 r#"{"paths": {"/a": {"parameters": [], "servers": [], "get": {}}}}"#,
952 )
953 .unwrap();
954 assert_eq!(fill_missing_operation_responses(&mut raw), 1, "only `get` is an operation");
955 let path = &raw["paths"]["/a"];
956 assert!(path["parameters"].is_array(), "parameters untouched");
957 assert!(path["servers"].is_array(), "servers untouched");
958 assert!(path["get"]["responses"].is_object(), "get repaired");
959 }
960}
961
962#[cfg(test)]
963mod oas31_type_array_tests {
964 use super::*;
965 use openapiv3::{SchemaKind, Type};
966
967 #[test]
971 fn oas31_nullable_type_array_loads() {
972 let raw = r#"{
973 "openapi": "3.1.0",
974 "info": { "title": "limit", "version": "1" },
975 "paths": {
976 "/companies": {
977 "post": {
978 "responses": { "200": { "description": "ok" } },
979 "requestBody": {
980 "content": {
981 "application/json": {
982 "schema": {
983 "type": "object",
984 "properties": {
985 "taxId": { "type": ["string", "null"] }
986 },
987 "required": ["taxId"]
988 }
989 }
990 }
991 }
992 }
993 }
994 },
995 "components": {
996 "schemas": {
997 "ResourceBase": {
998 "type": "object",
999 "properties": {
1000 "nickname": { "type": ["null", "string"] }
1001 }
1002 }
1003 }
1004 }
1005 }"#;
1006
1007 let spec = OpenApiSpec::from_string(raw, Some("json"))
1008 .expect("OAS 3.1 type: [string, null] must load for bench use");
1009
1010 let schema = spec
1011 .resolve_schema_ref("#/components/schemas/ResourceBase")
1012 .expect("ResourceBase present");
1013 let SchemaKind::Type(Type::Object(obj)) = schema.schema_kind else {
1014 panic!("ResourceBase should be an object after the type-array coerce");
1015 };
1016 let nickname = obj.properties.get("nickname").expect("nickname property");
1017 let ReferenceOr::Item(nickname) = nickname else {
1018 panic!("nickname should be inline");
1019 };
1020 assert!(
1021 nickname.schema_data.nullable,
1022 "`null` in the 3.1 type union becomes OAS 3.0 nullable"
1023 );
1024 assert!(
1025 matches!(nickname.schema_kind, SchemaKind::Type(Type::String(_))),
1026 "remaining primitive is string"
1027 );
1028 }
1029
1030 #[test]
1032 fn required_arrays_are_not_rewritten() {
1033 let mut raw: serde_json::Value = serde_json::from_str(
1034 r#"{
1035 "components": {
1036 "schemas": {
1037 "A": { "type": "object", "required": ["a", "b"] }
1038 }
1039 }
1040 }"#,
1041 )
1042 .unwrap();
1043 let before = raw.clone();
1044 assert_eq!(coerce_json_schema_type_arrays(&mut raw), 0);
1045 assert_eq!(raw, before, "required arrays must stay arrays of strings");
1046 }
1047
1048 #[test]
1051 fn singleton_type_array_becomes_string() {
1052 let mut raw: serde_json::Value =
1053 serde_json::from_str(r#"{"properties": {"id": {"type": ["string"]}}}"#).unwrap();
1054 assert_eq!(coerce_json_schema_type_arrays(&mut raw), 1);
1055 assert_eq!(raw["properties"]["id"]["type"], "string");
1056 assert!(raw["properties"]["id"].get("nullable").is_none());
1057 }
1058}