mod normalization_details { use crank_import::rest::{ ImportFindingSeverity, ImportParseError, NormalizationConfig, normalize_verified_document, preview_document, preview_document_legacy_v1, }; fn normalize_document( document: &str, config: &NormalizationConfig, ) -> Result { normalize_verified_document( document, crank_import::rest::SourceDigest::parse("b".repeat(64)).unwrap(), config, ) } #[test] fn operation_identity_does_not_depend_on_operation_id() { let document = r#" openapi: 3.0.3 info: { title: Identity } paths: /same: get: operationId: duplicate responses: { '200': { description: ok } } post: operationId: duplicate responses: { '201': { description: ok } } "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_ne!(ir.operations[0].stable_id, ir.operations[1].stable_id); assert_eq!(ir.operations[0].key, "GET /same"); assert_eq!(ir.operations[1].key, "POST /same"); } #[test] fn blank_and_duplicate_operation_ids_have_exact_findings_and_path_servers_win() { let document = r#" openapi: 3.0.3 info: { title: IDs } servers: [{ url: https://document.test }] paths: /one: servers: [{ url: https://path.test }] get: operationId: duplicate responses: { '200': { description: ok } } /two: get: operationId: duplicate responses: { '200': { description: ok } } /blank: get: operationId: ' ' responses: { '200': { description: ok } } "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); let duplicate = ir .operations .iter() .filter(|operation| { operation .findings .iter() .any(|finding| finding.code == "duplicate_operation_id") }) .collect::>(); assert_eq!(duplicate.len(), 2); assert!(duplicate.iter().all(|operation| { operation .findings .iter() .any(|finding| finding.location.pointer.ends_with("/operationId")) })); let blank = ir .operations .iter() .find(|operation| operation.path == "/blank") .unwrap(); assert!( blank .findings .iter() .any(|finding| finding.code == "missing_operation_id") ); let preview = preview_document(document).unwrap(); let blank_preview = preview .groups .iter() .flat_map(|group| &group.operations) .find(|operation| operation.path == "/blank") .unwrap(); assert_eq!( blank_preview .findings .iter() .filter(|finding| finding.code == "missing_operation_id") .count(), 1 ); assert_eq!( preview .groups .iter() .flat_map(|group| &group.operations) .find(|operation| operation.path == "/one") .unwrap() .server_urls, vec!["https://path.test"] ); } #[test] fn parameters_keep_exact_path_and_operation_source_pointers() { let openapi = r#" openapi: 3.0.3 info: { title: Parameter locations } paths: /items/{id}: parameters: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: getItem parameters: - { name: filter, in: query, schema: { type: string } } responses: { '200': { description: ok } } "#; let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap(); let parameters = &ir.operations[0].parameters; assert_eq!( parameters[0].source_location.pointer, "/paths/~1items~1{id}/parameters/0" ); assert_eq!( parameters[1].source_location.pointer, "/paths/~1items~1{id}/get/parameters/0" ); assert_ne!(parameters[0].construct_id, parameters[1].construct_id); let swagger = r#" swagger: '2.0' info: { title: Swagger parameter locations } paths: /items/{id}: parameters: - { name: id, in: path, required: true, type: string } post: operationId: updateItem parameters: - { name: body, in: body, schema: { type: object } } responses: { '200': { description: ok } } "#; let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap(); let parameter = &ir.operations[0].parameters[0]; assert_eq!( parameter.source_location.pointer, "/paths/~1items~1{id}/parameters/0" ); assert!(ir.operations[0].request_body_schema.is_some()); } #[test] fn openapi_parameter_omissions_are_errors_at_the_dropped_item_pointer() { let document = r#" openapi: 3.1.0 info: { title: Parameter findings } servers: [{ url: https://example.test }] paths: /items/{id}: parameters: - { name: id, in: path, required: true, schema: { type: string } } - { name: path_cookie, in: cookie } - not-an-object - { $ref: '#/components/parameters/Id' } get: operationId: getItem parameters: - { in: query } - { name: missing_location } - { name: form_value, in: formData } - { name: operation_cookie, in: cookie } - { name: query, in: query, schema: { type: string } } responses: { '200': { description: ok } } components: parameters: Id: { name: id, in: path, required: true, schema: { type: string } } "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_eq!( ir.operations[0] .parameters .iter() .map(|parameter| parameter.name.as_str()) .collect::>(), vec!["id", "query"] ); let errors = ir .findings .iter() .filter(|finding| { [ "invalid_parameter", "missing_parameter_name", "missing_parameter_location", "unsupported_parameter_location", "unsupported_cookie_parameter", "unresolved_parameter_reference", ] .contains(&finding.code.as_str()) }) .map(|finding| (finding.code.as_str(), finding.location.pointer.as_str())) .collect::>(); assert_eq!( errors, std::collections::BTreeSet::from([ ( "unsupported_cookie_parameter", "/paths/~1items~1{id}/parameters/1", ), ("invalid_parameter", "/paths/~1items~1{id}/parameters/2",), ( "unresolved_parameter_reference", "/paths/~1items~1{id}/parameters/3", ), ( "missing_parameter_name", "/paths/~1items~1{id}/get/parameters/0", ), ( "missing_parameter_location", "/paths/~1items~1{id}/get/parameters/1", ), ( "unsupported_parameter_location", "/paths/~1items~1{id}/get/parameters/2", ), ( "unsupported_cookie_parameter", "/paths/~1items~1{id}/get/parameters/3", ), ]) ); assert!( ir.findings .iter() .filter(|finding| { [ "invalid_parameter", "missing_parameter_name", "missing_parameter_location", "unsupported_parameter_location", "unsupported_cookie_parameter", "unresolved_parameter_reference", ] .contains(&finding.code.as_str()) }) .all(|finding| finding.severity == ImportFindingSeverity::Error) ); let legacy = preview_document_legacy_v1(document).unwrap(); assert!(!legacy.findings.iter().any(|finding| { [ "invalid_parameter", "missing_parameter_name", "missing_parameter_location", "unsupported_parameter_location", "unresolved_parameter_reference", ] .contains(&finding.code.as_str()) })); assert!( !legacy.groups[0].operations[0] .findings .iter() .any(|finding| finding.code == "unsupported_cookie_parameter") ); } #[test] fn swagger_parameter_omissions_are_errors_at_the_dropped_item_pointer() { let document = r#" swagger: '2.0' info: { title: Swagger parameter findings } host: example.test paths: /items/{id}: parameters: - { name: id, in: path, required: true, type: string } - { name: path_cookie, in: cookie } - not-an-object - { $ref: '#/parameters/Id' } get: operationId: getItem parameters: - { in: query } - { name: missing_location } - { name: form_value, in: formData } - { name: operation_cookie, in: cookie } - { name: query, in: query, type: string } responses: { '200': { description: ok } } parameters: Id: { name: id, in: path, required: true, type: string } "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_eq!( ir.operations[0] .parameters .iter() .map(|parameter| parameter.name.as_str()) .collect::>(), vec!["id", "query"] ); let errors = ir .findings .iter() .filter(|finding| { [ "invalid_parameter", "missing_parameter_name", "missing_parameter_location", "unsupported_parameter_location", "unsupported_cookie_parameter", "unresolved_parameter_reference", ] .contains(&finding.code.as_str()) }) .map(|finding| (finding.code.as_str(), finding.location.pointer.as_str())) .collect::>(); assert_eq!( errors, std::collections::BTreeSet::from([ ( "unsupported_cookie_parameter", "/paths/~1items~1{id}/parameters/1", ), ("invalid_parameter", "/paths/~1items~1{id}/parameters/2",), ( "unresolved_parameter_reference", "/paths/~1items~1{id}/parameters/3", ), ( "missing_parameter_name", "/paths/~1items~1{id}/get/parameters/0", ), ( "missing_parameter_location", "/paths/~1items~1{id}/get/parameters/1", ), ( "unsupported_parameter_location", "/paths/~1items~1{id}/get/parameters/2", ), ( "unsupported_cookie_parameter", "/paths/~1items~1{id}/get/parameters/3", ), ]) ); assert!( ir.findings .iter() .filter(|finding| { [ "invalid_parameter", "missing_parameter_name", "missing_parameter_location", "unsupported_parameter_location", "unsupported_cookie_parameter", "unresolved_parameter_reference", ] .contains(&finding.code.as_str()) }) .all(|finding| finding.severity == ImportFindingSeverity::Error) ); } #[test] fn openapi_invalid_servers_and_tags_keep_valid_siblings_with_exact_findings() { let document = r#" openapi: 3.1.0 info: { title: Server and tag findings } servers: - { url: https://document.test/ } - not-an-object - {} - { url: 42 } paths: /items: servers: - { url: https://path.test/ } - false - {} get: operationId: getItems servers: - { url: https://operation.test/ } - { url: false } tags: [items, 42, {}, null] responses: { '200': { description: ok } } "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_eq!(ir.source.servers, vec!["https://document.test"]); assert_eq!(ir.operations[0].servers, vec!["https://operation.test"]); assert_eq!(ir.operations[0].tags, vec!["items"]); let server_errors = ir .findings .iter() .filter(|finding| finding.code == "invalid_server") .map(|finding| finding.location.pointer.as_str()) .collect::>(); assert_eq!( server_errors, std::collections::BTreeSet::from([ "/servers/1", "/servers/2", "/servers/3", "/paths/~1items/servers/1", "/paths/~1items/servers/2", "/paths/~1items/get/servers/1", ]) ); assert!( ir.findings .iter() .filter(|finding| finding.code == "invalid_server") .all(|finding| finding.severity == ImportFindingSeverity::Error) ); let tag_warnings = ir .findings .iter() .filter(|finding| finding.code == "invalid_tag") .map(|finding| finding.location.pointer.as_str()) .collect::>(); assert_eq!( tag_warnings, std::collections::BTreeSet::from([ "/paths/~1items/get/tags/1", "/paths/~1items/get/tags/2", "/paths/~1items/get/tags/3", ]) ); assert!( ir.findings .iter() .filter(|finding| finding.code == "invalid_tag") .all(|finding| finding.severity == ImportFindingSeverity::Warning) ); let legacy = preview_document_legacy_v1(document).unwrap(); assert_eq!(legacy.source.servers, vec!["https://document.test"]); assert_eq!( legacy.groups[0].operations[0].server_urls, vec!["https://operation.test"] ); assert_eq!(legacy.groups[0].operations[0].category, "items"); assert!( !legacy.findings.iter().any(|finding| { ["invalid_server", "invalid_tag"].contains(&finding.code.as_str()) }) ); } #[test] fn swagger_invalid_tags_keep_valid_siblings_with_exact_warning_pointers() { let document = r#" swagger: '2.0' info: { title: Swagger tag findings } host: example.test paths: /items: get: operationId: getItems tags: [items, 42, {}, null] responses: { '200': { description: ok } } "#; let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_eq!(ir.operations[0].tags, vec!["items"]); let warnings = ir .findings .iter() .filter(|finding| finding.code == "invalid_tag") .map(|finding| finding.location.pointer.as_str()) .collect::>(); assert_eq!( warnings, std::collections::BTreeSet::from([ "/paths/~1items/get/tags/1", "/paths/~1items/get/tags/2", "/paths/~1items/get/tags/3", ]) ); assert!( ir.findings .iter() .filter(|finding| finding.code == "invalid_tag") .all(|finding| finding.severity == ImportFindingSeverity::Warning) ); let legacy = preview_document_legacy_v1(document).unwrap(); assert_eq!(legacy.groups[0].operations[0].category, "items"); assert!( !legacy .findings .iter() .any(|finding| finding.code == "invalid_tag") ); } #[test] fn v2_response_selection_uses_all_success_codes_then_openapi_wildcard_and_default() { let openapi = r#" openapi: 3.1.0 info: { title: Response priority } paths: /numeric: get: operationId: numeric responses: '200': { description: no schema } '203': { description: accepted, content: { application/json: { schema: { type: string } } } } '206': { description: partial, content: { application/json: { schema: { type: integer } } } } /wildcard: get: operationId: wildcard responses: '200': { description: unsupported, content: { text/plain: { schema: { type: string } } } } '2xX': { description: wildcard, content: { application/json: { schema: { type: boolean } } } } default: { description: fallback, content: { application/json: { schema: { type: string } } } } /default: get: operationId: fallback responses: '203': { description: no schema, content: { application/json: {} } } default: { description: fallback, content: { application/json: { schema: { type: number } } } } "#; let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap(); let response_pointer = |path: &str| { ir.operations .iter() .find(|operation| operation.path == path) .unwrap() .response_schema .as_ref() .unwrap() .location .pointer .as_str() }; assert_eq!( response_pointer("/numeric"), "/paths/~1numeric/get/responses/203/content/application~1json/schema" ); assert_eq!( response_pointer("/wildcard"), "/paths/~1wildcard/get/responses/2xX/content/application~1json/schema" ); assert_eq!( response_pointer("/default"), "/paths/~1default/get/responses/default/content/application~1json/schema" ); let swagger = r#" swagger: '2.0' info: { title: Swagger response priority } paths: /numeric: get: operationId: numeric responses: '200': { description: no schema } '206': { description: partial, schema: { type: integer } } default: { description: fallback, schema: { type: string } } /no-wildcard: get: operationId: noWildcard responses: '2XX': { description: ignored wildcard, schema: { type: string } } default: { description: fallback, schema: { type: boolean } } "#; let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap(); let response_pointer = |path: &str| { ir.operations .iter() .find(|operation| operation.path == path) .unwrap() .response_schema .as_ref() .unwrap() .location .pointer .as_str() }; assert_eq!( response_pointer("/numeric"), "/paths/~1numeric/get/responses/206/schema" ); assert_eq!( response_pointer("/no-wildcard"), "/paths/~1no-wildcard/get/responses/default/schema" ); } #[test] fn schemas_keep_selected_source_pointers_and_pointer_derived_ids() { let openapi = r#" openapi: 3.1.0 info: { title: Schema locations } paths: /items: post: operationId: updateItems requestBody: content: application/vnd.example+json: schema: type: object properties: a/b: { type: string } a~1b: { type: string } responses: '201': description: created content: application/vnd.example+json: schema: { type: integer } "#; let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap(); let operation = &ir.operations[0]; assert_eq!( operation .request_body_schema .as_ref() .unwrap() .location .pointer, "/paths/~1items/post/requestBody/content/application~1vnd.example+json/schema" ); assert_eq!( operation.response_schema.as_ref().unwrap().location.pointer, "/paths/~1items/post/responses/201/content/application~1vnd.example+json/schema" ); let crank_import::rest::NormalizedSchemaKind::Object { properties, .. } = &operation.request_body_schema.as_ref().unwrap().kind else { panic!("request schema must be object"); }; assert_ne!( properties["a/b"].construct_id, properties["a~1b"].construct_id ); let swagger = r#" swagger: '2.0' info: { title: Swagger schema locations } paths: /items: post: operationId: updateItems parameters: - in: body name: body schema: { type: object } responses: '201': { description: created, schema: { type: integer } } "#; let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap(); assert_eq!( ir.operations[0] .request_body_schema .as_ref() .unwrap() .location .pointer, "/paths/~1items/post/parameters/0/schema" ); assert_eq!( ir.operations[0] .response_schema .as_ref() .unwrap() .location .pointer, "/paths/~1items/post/responses/201/schema" ); } #[test] fn preserves_every_unresolved_reference_from_the_full_source_tree() { let document = r#" openapi: 3.1.0 info: { title: References } paths: /ok: parameters: - { $ref: '#/components/parameters/Id' } get: operationId: getOk requestBody: { $ref: '#/components/requestBodies/Body' } responses: '200': { $ref: '#/components/responses/Ok' } /reused: $ref: '#/components/pathItems/Reusable' components: schemas: Loop: { $ref: '#/components/schemas/Loop' } Remote: { $ref: 'https://example.test/schema.json#/Remote' } parameters: Id: { $ref: '#/components/parameters/Id' } requestBodies: Body: { $ref: '#/components/requestBodies/Body' } responses: Ok: { $ref: '#/components/responses/Ok' } pathItems: Reusable: { $ref: '#/components/pathItems/Reusable' } "#; let first = normalize_document(document, &NormalizationConfig::default()).unwrap(); let second = normalize_document(document, &NormalizationConfig::default()).unwrap(); assert_eq!( serde_json::to_vec(&first).unwrap(), serde_json::to_vec(&second).unwrap() ); assert_eq!(first.unresolved_references.len(), 10); let references = first .unresolved_references .iter() .map(|reference| (reference.uri.as_str(), reference.location.pointer.as_str())) .collect::>(); assert!(references.contains(&( "#/components/parameters/Id", "/paths/~1ok/parameters/0/$ref" ))); assert!(references.contains(&( "#/components/requestBodies/Body", "/paths/~1ok/get/requestBody/$ref" ))); assert!(references.contains(&( "#/components/responses/Ok", "/paths/~1ok/get/responses/200/$ref" ))); assert!(references.contains(&("#/components/pathItems/Reusable", "/paths/~1reused/$ref"))); assert!( references.contains(&("#/components/schemas/Loop", "/components/schemas/Loop/$ref")) ); assert!(references.contains(&( "https://example.test/schema.json#/Remote", "/components/schemas/Remote/$ref" ))); assert!(first.unresolved_references.iter().all(|reference| { reference.construct_id == format!( "reference:{}", reference .location .pointer .replace('~', "~0") .replace('/', "~1") ) && first.coverage.iter().any(|entry| { entry.construct_id == reference.construct_id && entry.location == reference.location }) })); } }