1use std::fs;
2
3use httpgenerator_core::{
4 NormalizedHttpMethod, NormalizedInlineParameter, NormalizedInlineRequestBody,
5 NormalizedMediaType, NormalizedOpenApiDocument, NormalizedOperation, NormalizedParameter,
6 NormalizedParameterLocation, NormalizedRequestBody, NormalizedSchema, NormalizedSchemaProperty,
7 NormalizedSchemaType, NormalizedServer, NormalizedSpecificationVersion,
8};
9use serde_json::{Map, Value};
10
11use crate::{
12 LoadedOpenApiDocument, OpenApiDocumentNormalizationError, OpenApiNormalizationError,
13 OpenApiSource,
14 loader::load_document_with_options,
15};
16
17pub fn load_and_normalize_document(
18 input: &str,
19) -> Result<NormalizedOpenApiDocument, OpenApiDocumentNormalizationError> {
20 load_and_normalize_document_with_options(input, false)
21}
22
23pub fn load_and_normalize_document_with_options(
24 input: &str,
25 tolerate_invalid_openapi31: bool,
26) -> Result<NormalizedOpenApiDocument, OpenApiDocumentNormalizationError> {
27 let document = load_document_with_options(input, tolerate_invalid_openapi31)
28 .map_err(OpenApiDocumentNormalizationError::Load)?;
29 normalize_loaded_document(&document).map_err(OpenApiDocumentNormalizationError::Normalize)
30}
31
32pub fn normalize_loaded_document(
33 document: &LoadedOpenApiDocument,
34) -> Result<NormalizedOpenApiDocument, OpenApiNormalizationError> {
35 Ok(NormalizedOpenApiDocument {
36 specification_version: normalize_specification_version(document),
37 servers: normalize_servers(document)?,
38 operations: normalize_operations(document.raw().value())?,
39 })
40}
41
42fn normalize_specification_version(
43 document: &LoadedOpenApiDocument,
44) -> NormalizedSpecificationVersion {
45 match document.specification_version() {
46 crate::OpenApiSpecificationVersion::Swagger2 => NormalizedSpecificationVersion::Swagger2,
47 crate::OpenApiSpecificationVersion::OpenApi30 => NormalizedSpecificationVersion::OpenApi30,
48 crate::OpenApiSpecificationVersion::OpenApi31 => NormalizedSpecificationVersion::OpenApi31,
49 }
50}
51
52fn normalize_servers(
53 document: &LoadedOpenApiDocument,
54) -> Result<Vec<NormalizedServer>, OpenApiNormalizationError> {
55 let value = document.raw().value();
56 let Some(servers) = value.get("servers") else {
57 if value.get("swagger").is_some() {
58 return normalize_swagger2_servers(value, document.source());
59 }
60
61 return Ok(Vec::new());
62 };
63
64 let Some(servers) = servers.as_array() else {
65 return Err(OpenApiNormalizationError::InvalidStructure {
66 path: "servers".to_string(),
67 context: "expected an array".to_string(),
68 });
69 };
70
71 let mut normalized = Vec::with_capacity(servers.len());
72 for (index, server) in servers.iter().enumerate() {
73 let Some(server) = server.as_object() else {
74 return Err(OpenApiNormalizationError::InvalidStructure {
75 path: format!("servers[{index}]"),
76 context: "expected an object".to_string(),
77 });
78 };
79
80 if let Some(url) = server.get("url").and_then(Value::as_str) {
81 normalized.push(NormalizedServer {
82 url: url.to_string(),
83 });
84 }
85 }
86
87 Ok(normalized)
88}
89
90fn normalize_swagger2_servers(
91 value: &Value,
92 source: &OpenApiSource,
93) -> Result<Vec<NormalizedServer>, OpenApiNormalizationError> {
94 let host = value
95 .get("host")
96 .and_then(Value::as_str)
97 .unwrap_or_default()
98 .trim();
99 let base_path = value
100 .get("basePath")
101 .and_then(Value::as_str)
102 .unwrap_or_default();
103 let schemes = value.get("schemes");
104
105 if host.is_empty() && base_path.is_empty() {
106 return Ok(local_swagger2_file_server(source)
107 .into_iter()
108 .map(|url| NormalizedServer { url })
109 .collect());
110 }
111
112 let schemes = match schemes {
113 Some(schemes) => {
114 let Some(schemes) = schemes.as_array() else {
115 return Err(OpenApiNormalizationError::InvalidStructure {
116 path: "schemes".to_string(),
117 context: "expected an array".to_string(),
118 });
119 };
120
121 schemes
122 .iter()
123 .filter_map(Value::as_str)
124 .map(str::to_string)
125 .collect::<Vec<_>>()
126 }
127 None => Vec::new(),
128 };
129
130 if host.is_empty() {
131 return Ok(vec![NormalizedServer {
132 url: base_path.to_string(),
133 }]);
134 }
135
136 if schemes.is_empty() {
137 return Ok(vec![NormalizedServer {
138 url: format!("https://{host}{base_path}"),
139 }]);
140 }
141
142 Ok(schemes
143 .into_iter()
144 .map(|scheme| NormalizedServer {
145 url: format!("{scheme}://{host}{base_path}"),
146 })
147 .collect())
148}
149
150fn local_swagger2_file_server(source: &OpenApiSource) -> Option<String> {
151 let OpenApiSource::Path(path) = source else {
152 return None;
153 };
154
155 let path = fs::canonicalize(path).unwrap_or_else(|_| path.clone());
156 let directory = path.parent()?;
157 let mut directory = directory.to_string_lossy().into_owned();
158
159 if let Some(stripped) = directory.strip_prefix(r"\\?\") {
160 directory = stripped.to_string();
161 }
162
163 directory = directory.replace('\\', "/");
164 Some(format!("file://{directory}"))
165}
166
167fn normalize_operations(
168 root: &Value,
169) -> Result<Vec<NormalizedOperation>, OpenApiNormalizationError> {
170 let Some(paths) = root.get("paths") else {
171 return Ok(Vec::new());
172 };
173
174 let Some(paths) = paths.as_object() else {
175 return Err(OpenApiNormalizationError::InvalidStructure {
176 path: "paths".to_string(),
177 context: "expected an object".to_string(),
178 });
179 };
180
181 let mut operations = Vec::new();
182 for (path, path_item_value) in paths {
183 let Some(path_item) = path_item_value.as_object() else {
184 return Err(OpenApiNormalizationError::InvalidStructure {
185 path: path.clone(),
186 context: "expected a path item object".to_string(),
187 });
188 };
189
190 if let Some(reference) = path_item.get("$ref").and_then(Value::as_str) {
191 return Err(OpenApiNormalizationError::UnsupportedPathItemReference {
192 path: path.clone(),
193 reference: reference.to_string(),
194 });
195 }
196
197 let path_parameters = get_parameter_values(path_item, path, "parameters")?;
198
199 for method in supported_methods() {
200 let Some(operation_value) = path_item.get(method.as_str()) else {
201 continue;
202 };
203
204 let Some(operation) = operation_value.as_object() else {
205 return Err(OpenApiNormalizationError::InvalidStructure {
206 path: format!("paths.{path}.{}", method.as_str()),
207 context: "expected an operation object".to_string(),
208 });
209 };
210
211 operations.push(normalize_operation(
212 root,
213 path,
214 method,
215 &path_parameters,
216 operation,
217 )?);
218 }
219 }
220
221 Ok(operations)
222}
223
224fn normalize_operation(
225 root: &Value,
226 path: &str,
227 method: NormalizedHttpMethod,
228 path_parameters: &[&Value],
229 operation: &Map<String, Value>,
230) -> Result<NormalizedOperation, OpenApiNormalizationError> {
231 Ok(NormalizedOperation {
232 path: path.to_string(),
233 method,
234 operation_id: operation
235 .get("operationId")
236 .and_then(Value::as_str)
237 .map(str::to_string),
238 summary: operation
239 .get("summary")
240 .and_then(Value::as_str)
241 .map(str::to_string),
242 description: operation
243 .get("description")
244 .and_then(Value::as_str)
245 .map(str::to_string),
246 tags: normalize_tags(operation)?,
247 parameters: normalize_parameters(root, path, method, path_parameters, operation)?,
248 request_body: normalize_request_body(root, path, method, operation)?,
249 })
250}
251
252fn normalize_tags(
253 operation: &Map<String, Value>,
254) -> Result<Vec<String>, OpenApiNormalizationError> {
255 let Some(tags) = operation.get("tags") else {
256 return Ok(Vec::new());
257 };
258
259 let Some(tags) = tags.as_array() else {
260 return Err(OpenApiNormalizationError::InvalidStructure {
261 path: "operation.tags".to_string(),
262 context: "expected an array".to_string(),
263 });
264 };
265
266 Ok(tags
267 .iter()
268 .filter_map(Value::as_str)
269 .map(str::to_string)
270 .collect())
271}
272
273fn normalize_parameters(
274 root: &Value,
275 path: &str,
276 method: NormalizedHttpMethod,
277 path_parameters: &[&Value],
278 operation: &Map<String, Value>,
279) -> Result<Vec<NormalizedParameter>, OpenApiNormalizationError> {
280 let operation_parameters = get_parameter_values(operation, path, "parameters")?;
281 let mut merged = Vec::new();
282
283 for parameter in path_parameters
284 .iter()
285 .copied()
286 .chain(operation_parameters.iter().copied())
287 {
288 let Some(normalized) = normalize_parameter(root, path, method, parameter)? else {
289 continue;
290 };
291 let parameter_key = normalized.inline_key();
292
293 if let Some(parameter_key) = parameter_key {
294 if let Some(index) = merged.iter().position(|existing: &NormalizedParameter| {
295 existing.inline_key() == Some(parameter_key)
296 }) {
297 merged[index] = normalized;
298 continue;
299 }
300 }
301
302 merged.push(normalized);
303 }
304
305 Ok(merged)
306}
307
308fn get_parameter_values<'a>(
309 object: &'a Map<String, Value>,
310 path: &str,
311 field_name: &str,
312) -> Result<Vec<&'a Value>, OpenApiNormalizationError> {
313 let Some(parameters) = object.get(field_name) else {
314 return Ok(Vec::new());
315 };
316
317 let Some(parameters) = parameters.as_array() else {
318 return Err(OpenApiNormalizationError::InvalidStructure {
319 path: format!("{path}.{field_name}"),
320 context: "expected an array".to_string(),
321 });
322 };
323
324 Ok(parameters.iter().collect())
325}
326
327fn normalize_parameter(
328 root: &Value,
329 path: &str,
330 method: NormalizedHttpMethod,
331 value: &Value,
332) -> Result<Option<NormalizedParameter>, OpenApiNormalizationError> {
333 if let Some(reference) = value.get("$ref").and_then(Value::as_str) {
334 return Err(OpenApiNormalizationError::UnsupportedParameterReference {
335 path: path.to_string(),
336 method,
337 reference: reference.to_string(),
338 });
339 }
340
341 let Some(parameter) = value.as_object() else {
342 return Err(OpenApiNormalizationError::InvalidStructure {
343 path: format!("{path}.{}", method.as_str()),
344 context: "expected a parameter object".to_string(),
345 });
346 };
347
348 let name = parameter
349 .get("name")
350 .and_then(Value::as_str)
351 .map(str::to_string)
352 .unwrap_or_default();
353
354 let location_name = parameter.get("in").and_then(Value::as_str).ok_or_else(|| {
355 OpenApiNormalizationError::InvalidStructure {
356 path: format!("{path}.{}.parameters", method.as_str()),
357 context: "parameter is missing a location".to_string(),
358 }
359 })?;
360 let Some(location) = normalize_parameter_location(location_name) else {
361 return Ok(None);
362 };
363
364 let synthetic_schema = synthesize_swagger2_parameter_schema(parameter);
365
366 Ok(Some(NormalizedParameter::Inline(
367 NormalizedInlineParameter {
368 name,
369 location,
370 description: parameter
371 .get("description")
372 .and_then(Value::as_str)
373 .map(str::to_string),
374 required: parameter
375 .get("required")
376 .and_then(Value::as_bool)
377 .unwrap_or(false),
378 schema: parameter
379 .get("schema")
380 .or(synthetic_schema.as_ref())
381 .map(|schema| normalize_schema(root, schema)),
382 },
383 )))
384}
385
386fn synthesize_swagger2_parameter_schema(parameter: &Map<String, Value>) -> Option<Value> {
387 if parameter.get("schema").is_some() {
388 return None;
389 }
390
391 let mut schema = Map::new();
392
393 for field_name in ["type", "items", "allOf", "oneOf", "anyOf", "properties"] {
394 if let Some(value) = parameter.get(field_name) {
395 schema.insert(field_name.to_string(), value.clone());
396 }
397 }
398
399 if schema.is_empty() {
400 None
401 } else {
402 Some(Value::Object(schema))
403 }
404}
405
406fn normalize_request_body(
407 root: &Value,
408 path: &str,
409 method: NormalizedHttpMethod,
410 operation: &Map<String, Value>,
411) -> Result<Option<NormalizedRequestBody>, OpenApiNormalizationError> {
412 if let Some(request_body) = operation.get("requestBody") {
413 return normalize_openapi3_request_body(root, path, method, request_body);
414 }
415
416 normalize_swagger2_request_body(root, path, method, operation)
417}
418
419fn normalize_openapi3_request_body(
420 root: &Value,
421 path: &str,
422 method: NormalizedHttpMethod,
423 request_body: &Value,
424) -> Result<Option<NormalizedRequestBody>, OpenApiNormalizationError> {
425 if let Some(reference) = request_body.get("$ref").and_then(Value::as_str) {
426 return Err(OpenApiNormalizationError::UnsupportedRequestBodyReference {
427 path: path.to_string(),
428 method,
429 reference: reference.to_string(),
430 });
431 }
432
433 let Some(request_body) = request_body.as_object() else {
434 return Err(OpenApiNormalizationError::InvalidStructure {
435 path: format!("{path}.{}.requestBody", method.as_str()),
436 context: "expected a requestBody object".to_string(),
437 });
438 };
439
440 Ok(Some(NormalizedRequestBody::Inline(
441 NormalizedInlineRequestBody {
442 description: request_body
443 .get("description")
444 .and_then(Value::as_str)
445 .map(str::to_string),
446 required: request_body
447 .get("required")
448 .and_then(Value::as_bool)
449 .unwrap_or(false),
450 content: normalize_request_body_content(root, path, method, request_body)?,
451 },
452 )))
453}
454
455fn normalize_swagger2_request_body(
456 root: &Value,
457 path: &str,
458 method: NormalizedHttpMethod,
459 operation: &Map<String, Value>,
460) -> Result<Option<NormalizedRequestBody>, OpenApiNormalizationError> {
461 let Some(parameters) = operation.get("parameters").and_then(Value::as_array) else {
462 return Ok(None);
463 };
464
465 let Some(body_parameter) = parameters.iter().find(|parameter| {
466 parameter
467 .get("in")
468 .and_then(Value::as_str)
469 .is_some_and(|location| location == "body")
470 }) else {
471 return Ok(None);
472 };
473
474 if let Some(reference) = body_parameter.get("$ref").and_then(Value::as_str) {
475 return Err(OpenApiNormalizationError::UnsupportedRequestBodyReference {
476 path: path.to_string(),
477 method,
478 reference: reference.to_string(),
479 });
480 }
481
482 let Some(body_parameter) = body_parameter.as_object() else {
483 return Err(OpenApiNormalizationError::InvalidStructure {
484 path: format!("{path}.{}.parameters.body", method.as_str()),
485 context: "expected a body parameter object".to_string(),
486 });
487 };
488
489 Ok(Some(NormalizedRequestBody::Inline(
490 NormalizedInlineRequestBody {
491 description: body_parameter
492 .get("description")
493 .and_then(Value::as_str)
494 .map(str::to_string),
495 required: body_parameter
496 .get("required")
497 .and_then(Value::as_bool)
498 .unwrap_or(false),
499 content: normalize_swagger2_request_body_content(root, body_parameter, operation),
500 },
501 )))
502}
503
504fn normalize_request_body_content(
505 root: &Value,
506 path: &str,
507 method: NormalizedHttpMethod,
508 request_body: &Map<String, Value>,
509) -> Result<Vec<NormalizedMediaType>, OpenApiNormalizationError> {
510 match request_body.get("content") {
511 Some(content) => {
512 let Some(content) = content.as_object() else {
513 return Err(OpenApiNormalizationError::InvalidStructure {
514 path: format!("{path}.{}.requestBody.content", method.as_str()),
515 context: "expected a content object".to_string(),
516 });
517 };
518
519 content
520 .iter()
521 .map(|(content_type, media_type)| {
522 let Some(media_type) = media_type.as_object() else {
523 return Err(OpenApiNormalizationError::InvalidStructure {
524 path: format!(
525 "{path}.{}.requestBody.content.{content_type}",
526 method.as_str()
527 ),
528 context: "expected a media type object".to_string(),
529 });
530 };
531
532 Ok(NormalizedMediaType {
533 content_type: content_type.clone(),
534 schema: media_type
535 .get("schema")
536 .map(|schema| normalize_schema(root, schema)),
537 })
538 })
539 .collect::<Result<Vec<_>, _>>()
540 }
541 None => Ok(Vec::new()),
542 }
543}
544
545fn normalize_swagger2_request_body_content(
546 root: &Value,
547 body_parameter: &Map<String, Value>,
548 operation: &Map<String, Value>,
549) -> Vec<NormalizedMediaType> {
550 let content_types = operation
551 .get("consumes")
552 .or_else(|| root.get("consumes"))
553 .and_then(Value::as_array)
554 .map(|content_types| {
555 content_types
556 .iter()
557 .filter_map(Value::as_str)
558 .map(str::to_string)
559 .collect::<Vec<_>>()
560 })
561 .filter(|content_types| !content_types.is_empty())
562 .unwrap_or_else(|| vec!["application/json".to_string()]);
563
564 content_types
565 .into_iter()
566 .map(|content_type| NormalizedMediaType {
567 content_type,
568 schema: body_parameter
569 .get("schema")
570 .map(|schema| normalize_schema(root, schema)),
571 })
572 .collect()
573}
574
575fn normalize_schema(root: &Value, value: &Value) -> NormalizedSchema {
576 let mut resolution_stack = Vec::new();
577 normalize_schema_with_resolution(root, value, &mut resolution_stack)
578}
579
580fn normalize_schema_with_resolution(
581 root: &Value,
582 value: &Value,
583 resolution_stack: &mut Vec<String>,
584) -> NormalizedSchema {
585 match value {
586 Value::Object(schema) => {
587 let reference = schema
588 .get("$ref")
589 .and_then(Value::as_str)
590 .map(str::to_string);
591 let mut normalized = reference
592 .as_deref()
593 .and_then(|reference| resolve_internal_reference(root, reference, resolution_stack))
594 .unwrap_or_default();
595 let overlay = NormalizedSchema {
596 reference,
597 types: normalize_schema_types(schema.get("type")),
598 properties: schema
599 .get("properties")
600 .and_then(Value::as_object)
601 .map(|properties| {
602 properties
603 .iter()
604 .map(|(name, property)| NormalizedSchemaProperty {
605 name: name.clone(),
606 schema: normalize_schema_with_resolution(
607 root,
608 property,
609 resolution_stack,
610 ),
611 })
612 .collect()
613 })
614 .unwrap_or_default(),
615 items: schema.get("items").map(|items| {
616 Box::new(normalize_schema_with_resolution(
617 root,
618 items,
619 resolution_stack,
620 ))
621 }),
622 all_of: normalize_schema_array(root, schema.get("allOf"), resolution_stack),
623 one_of: normalize_schema_array(root, schema.get("oneOf"), resolution_stack),
624 any_of: normalize_schema_array(root, schema.get("anyOf"), resolution_stack),
625 };
626
627 merge_schema(&mut normalized, overlay);
628 normalized
629 }
630 Value::Bool(value) => NormalizedSchema {
631 types: vec![NormalizedSchemaType::Other(format!(
632 "boolean-schema:{value}"
633 ))],
634 ..NormalizedSchema::default()
635 },
636 _ => NormalizedSchema::default(),
637 }
638}
639
640fn normalize_schema_array(
641 root: &Value,
642 value: Option<&Value>,
643 resolution_stack: &mut Vec<String>,
644) -> Vec<NormalizedSchema> {
645 value
646 .and_then(Value::as_array)
647 .map(|schemas| {
648 schemas
649 .iter()
650 .map(|schema| normalize_schema_with_resolution(root, schema, resolution_stack))
651 .collect()
652 })
653 .unwrap_or_default()
654}
655
656fn resolve_internal_reference(
657 root: &Value,
658 reference: &str,
659 resolution_stack: &mut Vec<String>,
660) -> Option<NormalizedSchema> {
661 if !reference.starts_with("#/") || resolution_stack.iter().any(|value| value == reference) {
662 return None;
663 }
664
665 let target = root.pointer(&reference[1..])?;
666 resolution_stack.push(reference.to_string());
667 let resolved = normalize_schema_with_resolution(root, target, resolution_stack);
668 resolution_stack.pop();
669 Some(resolved)
670}
671
672fn merge_schema(base: &mut NormalizedSchema, overlay: NormalizedSchema) {
673 if overlay.reference.is_some() {
674 base.reference = overlay.reference;
675 }
676 if !overlay.types.is_empty() {
677 base.types = overlay.types;
678 }
679 if !overlay.properties.is_empty() {
680 base.properties = overlay.properties;
681 }
682 if overlay.items.is_some() {
683 base.items = overlay.items;
684 }
685 if !overlay.all_of.is_empty() {
686 base.all_of = overlay.all_of;
687 }
688 if !overlay.one_of.is_empty() {
689 base.one_of = overlay.one_of;
690 }
691 if !overlay.any_of.is_empty() {
692 base.any_of = overlay.any_of;
693 }
694}
695
696fn normalize_schema_types(value: Option<&Value>) -> Vec<NormalizedSchemaType> {
697 match value {
698 Some(Value::String(schema_type)) => vec![normalize_schema_type(schema_type)],
699 Some(Value::Array(types)) => types
700 .iter()
701 .filter_map(Value::as_str)
702 .map(normalize_schema_type)
703 .collect(),
704 _ => Vec::new(),
705 }
706}
707
708fn normalize_schema_type(value: &str) -> NormalizedSchemaType {
709 match value {
710 "string" => NormalizedSchemaType::String,
711 "integer" => NormalizedSchemaType::Integer,
712 "number" => NormalizedSchemaType::Number,
713 "boolean" => NormalizedSchemaType::Boolean,
714 "object" => NormalizedSchemaType::Object,
715 "array" => NormalizedSchemaType::Array,
716 "null" => NormalizedSchemaType::Null,
717 other => NormalizedSchemaType::Other(other.to_string()),
718 }
719}
720
721fn normalize_parameter_location(value: &str) -> Option<NormalizedParameterLocation> {
722 match value {
723 "path" => Some(NormalizedParameterLocation::Path),
724 "query" => Some(NormalizedParameterLocation::Query),
725 "header" => Some(NormalizedParameterLocation::Header),
726 "cookie" => Some(NormalizedParameterLocation::Cookie),
727 _ => None,
728 }
729}
730
731fn supported_methods() -> [NormalizedHttpMethod; 8] {
732 [
733 NormalizedHttpMethod::Get,
734 NormalizedHttpMethod::Put,
735 NormalizedHttpMethod::Post,
736 NormalizedHttpMethod::Delete,
737 NormalizedHttpMethod::Options,
738 NormalizedHttpMethod::Head,
739 NormalizedHttpMethod::Patch,
740 NormalizedHttpMethod::Trace,
741 ]
742}
743
744trait InlineParameterKey {
745 fn inline_key(&self) -> Option<(&str, NormalizedParameterLocation)>;
746}
747
748impl InlineParameterKey for NormalizedParameter {
749 fn inline_key(&self) -> Option<(&str, NormalizedParameterLocation)> {
750 match self {
751 NormalizedParameter::Inline(parameter) => Some((¶meter.name, parameter.location)),
752 NormalizedParameter::Reference { .. } => None,
753 }
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use std::path::PathBuf;
760
761 use httpgenerator_core::{
762 NormalizedHttpMethod, NormalizedParameter, NormalizedParameterLocation,
763 NormalizedRequestBody, NormalizedServer, NormalizedSpecificationVersion,
764 };
765
766 use crate::{OpenApiSource, decode_raw_document, load_document_from_raw};
767
768 use super::{
769 load_and_normalize_document, load_and_normalize_document_with_options,
770 normalize_loaded_document,
771 };
772
773 #[test]
774 fn normalizes_petstore_v30_fixture_into_generator_facing_operations() {
775 let raw = decode_raw_document(
776 OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.0/petstore.json")),
777 include_str!("../../../../test/OpenAPI/v3.0/petstore.json"),
778 )
779 .unwrap();
780 let loaded = load_document_from_raw(raw).unwrap();
781 let normalized = normalize_loaded_document(&loaded).unwrap();
782
783 assert_eq!(
784 normalized.specification_version,
785 NormalizedSpecificationVersion::OpenApi30
786 );
787 assert_eq!(normalized.servers[0].url, "/api/v3");
788 assert_eq!(normalized.operations.len(), 19);
789
790 let add_pet = normalized
791 .operations
792 .iter()
793 .find(|operation| {
794 operation.path == "/pet" && operation.method == NormalizedHttpMethod::Post
795 })
796 .unwrap();
797 assert_eq!(add_pet.tags.first().map(String::as_str), Some("pet"));
798
799 match add_pet.request_body.as_ref().unwrap() {
800 NormalizedRequestBody::Inline(request_body) => {
801 let application_json = request_body
802 .content
803 .iter()
804 .find(|content| content.content_type == "application/json")
805 .unwrap();
806 let schema = application_json.schema.as_ref().unwrap();
807 assert_eq!(
808 schema.reference.as_deref(),
809 Some("#/components/schemas/Pet")
810 );
811 assert_eq!(
812 schema
813 .properties
814 .iter()
815 .take(3)
816 .map(|property| property.name.as_str())
817 .collect::<Vec<_>>(),
818 vec!["id", "name", "category"]
819 );
820 let category = schema
821 .properties
822 .iter()
823 .find(|property| property.name == "category")
824 .unwrap();
825 assert!(
826 category
827 .schema
828 .types
829 .contains(&httpgenerator_core::NormalizedSchemaType::Object)
830 );
831 }
832 NormalizedRequestBody::Reference { .. } => {
833 panic!("expected addPet to use an inline request body")
834 }
835 }
836
837 let find_by_status = normalized
838 .operations
839 .iter()
840 .find(|operation| {
841 operation.path == "/pet/findByStatus"
842 && operation.method == NormalizedHttpMethod::Get
843 })
844 .unwrap();
845 assert!(find_by_status.parameters.iter().any(|parameter| {
846 matches!(
847 parameter,
848 NormalizedParameter::Inline(parameter)
849 if parameter.name == "status"
850 && parameter.location == NormalizedParameterLocation::Query
851 )
852 }));
853 }
854
855 #[test]
856 fn normalizes_petstore_v20_fixture_into_generator_facing_operations() {
857 let raw = decode_raw_document(
858 OpenApiSource::Path(PathBuf::from("test/OpenAPI/v2.0/petstore.json")),
859 include_str!("../../../../test/OpenAPI/v2.0/petstore.json"),
860 )
861 .unwrap();
862 let loaded = load_document_from_raw(raw).unwrap();
863 let normalized = normalize_loaded_document(&loaded).unwrap();
864
865 assert_eq!(
866 normalized.specification_version,
867 NormalizedSpecificationVersion::Swagger2
868 );
869 assert_eq!(normalized.servers[0].url, "https://petstore.swagger.io/v2");
870 assert_eq!(normalized.operations.len(), 20);
871 assert!(normalized.operations.iter().any(|operation| {
872 operation.path == "/user/createWithArray"
873 && operation.method == NormalizedHttpMethod::Post
874 }));
875
876 let add_pet = normalized
877 .operations
878 .iter()
879 .find(|operation| {
880 operation.path == "/pet" && operation.method == NormalizedHttpMethod::Post
881 })
882 .unwrap();
883 match add_pet.request_body.as_ref().unwrap() {
884 NormalizedRequestBody::Inline(request_body) => {
885 assert_eq!(
886 request_body
887 .content
888 .iter()
889 .map(|content| content.content_type.as_str())
890 .collect::<Vec<_>>(),
891 vec!["application/json", "application/xml"]
892 );
893 let schema = request_body.content[0].schema.as_ref().unwrap();
894 assert_eq!(schema.reference.as_deref(), Some("#/definitions/Pet"));
895 assert_eq!(
896 schema
897 .properties
898 .iter()
899 .take(3)
900 .map(|property| property.name.as_str())
901 .collect::<Vec<_>>(),
902 vec!["id", "category", "name"]
903 );
904 }
905 NormalizedRequestBody::Reference { .. } => {
906 panic!("expected addPet to use an inline Swagger 2 request body")
907 }
908 }
909
910 let find_by_status = normalized
911 .operations
912 .iter()
913 .find(|operation| {
914 operation.path == "/pet/findByStatus"
915 && operation.method == NormalizedHttpMethod::Get
916 })
917 .unwrap();
918 assert!(find_by_status.parameters.iter().any(|parameter| {
919 matches!(
920 parameter,
921 NormalizedParameter::Inline(parameter)
922 if parameter.name == "status"
923 && parameter.location == NormalizedParameterLocation::Query
924 && parameter
925 .schema
926 .as_ref()
927 .is_some_and(|schema| schema.types.contains(&httpgenerator_core::NormalizedSchemaType::Array))
928 )
929 }));
930
931 let upload_image = normalized
932 .operations
933 .iter()
934 .find(|operation| {
935 operation.path == "/pet/{petId}/uploadImage"
936 && operation.method == NormalizedHttpMethod::Post
937 })
938 .unwrap();
939 assert_eq!(upload_image.parameters.len(), 1);
940 assert!(matches!(
941 &upload_image.parameters[0],
942 NormalizedParameter::Inline(parameter)
943 if parameter.name == "petId"
944 && parameter.location == NormalizedParameterLocation::Path
945 ));
946
947 let update_pet_with_form = normalized
948 .operations
949 .iter()
950 .find(|operation| {
951 operation.path == "/pet/{petId}" && operation.method == NormalizedHttpMethod::Post
952 })
953 .unwrap();
954 assert_eq!(update_pet_with_form.parameters.len(), 1);
955 assert!(update_pet_with_form.request_body.is_none());
956 }
957
958 #[test]
959 fn swagger2_local_documents_without_host_or_base_path_use_parent_directory_server() {
960 let input = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
961 .join("..")
962 .join("..")
963 .join("..")
964 .join("test")
965 .join("OpenAPI")
966 .join("v2.0")
967 .join("api-with-examples.json");
968 let normalized = load_and_normalize_document(input.to_str().unwrap()).unwrap();
969 let mut expected_directory = std::fs::canonicalize(&input)
970 .unwrap()
971 .parent()
972 .unwrap()
973 .to_string_lossy()
974 .into_owned();
975
976 if let Some(stripped) = expected_directory.strip_prefix(r"\\?\") {
977 expected_directory = stripped.to_string();
978 }
979
980 expected_directory = expected_directory.replace('\\', "/");
981
982 assert_eq!(
983 normalized.servers,
984 vec![NormalizedServer {
985 url: format!("file://{expected_directory}"),
986 }]
987 );
988 }
989
990 #[test]
991 fn openapi30_local_documents_without_servers_do_not_use_parent_directory_server() {
992 let input = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
993 .join("..")
994 .join("..")
995 .join("..")
996 .join("test")
997 .join("OpenAPI")
998 .join("v3.0")
999 .join("api-with-examples.json");
1000 let normalized = load_and_normalize_document(input.to_str().unwrap()).unwrap();
1001
1002 assert!(normalized.servers.is_empty());
1003 }
1004
1005 #[test]
1006 fn webhook_only_v31_documents_normalize_without_operations() {
1007 let raw = decode_raw_document(
1008 OpenApiSource::Path(PathBuf::from("test/OpenAPI/v3.1/webhook-example.json")),
1009 include_str!("../../../../test/OpenAPI/v3.1/webhook-example.json"),
1010 )
1011 .unwrap();
1012 let loaded = load_document_from_raw(raw).unwrap();
1013 let normalized = normalize_loaded_document(&loaded).unwrap();
1014
1015 assert_eq!(
1016 normalized.specification_version,
1017 NormalizedSpecificationVersion::OpenApi31
1018 );
1019 assert!(normalized.servers.is_empty());
1020 assert!(normalized.operations.is_empty());
1021 }
1022
1023 #[test]
1024 fn invalid_v31_documents_normalize_when_tolerated() {
1025 let normalized = load_and_normalize_document_with_options(
1026 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1027 .join("..")
1028 .join("..")
1029 .join("..")
1030 .join("test")
1031 .join("OpenAPI")
1032 .join("v3.1")
1033 .join("non-oauth-scopes.json")
1034 .to_str()
1035 .unwrap(),
1036 true,
1037 )
1038 .unwrap();
1039
1040 assert_eq!(
1041 normalized.specification_version,
1042 NormalizedSpecificationVersion::OpenApi31
1043 );
1044 assert_eq!(normalized.operations.len(), 1);
1045 assert_eq!(normalized.operations[0].path, "/users");
1046 assert_eq!(normalized.operations[0].method, NormalizedHttpMethod::Get);
1047 }
1048
1049 #[test]
1050 fn operation_level_parameters_override_path_level_parameters() {
1051 let raw = decode_raw_document(
1052 OpenApiSource::Path(PathBuf::from("inline.json")),
1053 r#"{
1054 "openapi": "3.0.2",
1055 "info": { "title": "Example", "version": "1.0.0" },
1056 "paths": {
1057 "/pets": {
1058 "parameters": [
1059 {
1060 "name": "status",
1061 "in": "query",
1062 "description": "path-level",
1063 "schema": { "type": "string" }
1064 }
1065 ],
1066 "get": {
1067 "parameters": [
1068 {
1069 "name": "status",
1070 "in": "query",
1071 "description": "operation-level",
1072 "schema": { "type": "string" }
1073 }
1074 ],
1075 "responses": {
1076 "200": {
1077 "description": "ok"
1078 }
1079 }
1080 }
1081 }
1082 }
1083 }"#,
1084 )
1085 .unwrap();
1086 let loaded = load_document_from_raw(raw).unwrap();
1087 let normalized = normalize_loaded_document(&loaded).unwrap();
1088
1089 assert_eq!(normalized.operations.len(), 1);
1090 assert_eq!(normalized.operations[0].parameters.len(), 1);
1091 match &normalized.operations[0].parameters[0] {
1092 NormalizedParameter::Inline(parameter) => {
1093 assert_eq!(parameter.description.as_deref(), Some("operation-level"));
1094 }
1095 NormalizedParameter::Reference { .. } => panic!("expected an inline parameter"),
1096 }
1097 }
1098
1099 #[test]
1100 fn top_level_request_body_refs_fail_explicitly_during_normalization() {
1101 let raw = decode_raw_document(
1102 OpenApiSource::Path(PathBuf::from("inline.json")),
1103 r##"{
1104 "openapi": "3.0.2",
1105 "info": { "title": "Example", "version": "1.0.0" },
1106 "paths": {
1107 "/pets": {
1108 "post": {
1109 "requestBody": {
1110 "$ref": "#/components/requestBodies/PetBody"
1111 },
1112 "responses": {
1113 "200": {
1114 "description": "ok"
1115 }
1116 }
1117 }
1118 }
1119 }
1120 }"##,
1121 )
1122 .unwrap();
1123 let loaded = load_document_from_raw(raw).unwrap();
1124 let error = normalize_loaded_document(&loaded).unwrap_err();
1125
1126 assert_eq!(
1127 error,
1128 crate::OpenApiNormalizationError::UnsupportedRequestBodyReference {
1129 path: "/pets".to_string(),
1130 method: NormalizedHttpMethod::Post,
1131 reference: "#/components/requestBodies/PetBody".to_string(),
1132 }
1133 );
1134 }
1135
1136 #[test]
1137 fn convenience_loader_normalizes_local_documents() {
1138 let normalized = load_and_normalize_document(
1139 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1140 .join("..")
1141 .join("..")
1142 .join("..")
1143 .join("test")
1144 .join("OpenAPI")
1145 .join("v3.0")
1146 .join("petstore.json")
1147 .to_str()
1148 .unwrap(),
1149 )
1150 .unwrap();
1151
1152 assert_eq!(
1153 normalized.specification_version,
1154 NormalizedSpecificationVersion::OpenApi30
1155 );
1156 }
1157}