Files
crank/crates/crank-import/tests/normalization_details.rs
T
bsodfather 6c2a3712d8
CI / Rust Checks (push) Failing after 3m12s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped
feat(import): add deterministic OpenAPI normalized IR
2026-08-29 04:15:37 +03:00

768 lines
25 KiB
Rust

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<crank_import::rest::NormalizedIr, ImportParseError> {
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::<Vec<_>>();
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<_>>(),
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::<std::collections::BTreeSet<_>>();
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<_>>(),
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::<std::collections::BTreeSet<_>>();
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::<std::collections::BTreeSet<_>>();
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::<std::collections::BTreeSet<_>>();
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::<std::collections::BTreeSet<_>>();
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::<std::collections::BTreeSet<_>>();
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
})
}));
}
}