Skip to main content

httpgenerator_openapi/
normalize.rs

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((&parameter.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}