feat(import): resolve references and schema composition
This commit is contained in:
@@ -161,6 +161,58 @@ paths:
|
||||
assert!(ir.operations[0].request_body_schema.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operation_parameters_override_path_parameters_by_name_and_location() {
|
||||
let openapi = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: OAS parameter overrides }
|
||||
servers: [{ url: https://example.test }]
|
||||
paths:
|
||||
/items:
|
||||
parameters:
|
||||
- { name: page, in: query, description: path, schema: { type: integer } }
|
||||
get:
|
||||
operationId: listItems
|
||||
parameters:
|
||||
- { name: page, in: query, description: operation, schema: { type: string } }
|
||||
responses: { '200': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap();
|
||||
assert_eq!(ir.operations[0].parameters.len(), 1);
|
||||
assert_eq!(
|
||||
ir.operations[0].parameters[0].description.as_deref(),
|
||||
Some("operation")
|
||||
);
|
||||
assert_eq!(
|
||||
ir.operations[0].parameters[0].source_location.pointer,
|
||||
"/paths/~1items/get/parameters/0"
|
||||
);
|
||||
|
||||
let swagger = r#"
|
||||
swagger: '2.0'
|
||||
info: { title: Swagger parameter overrides }
|
||||
paths:
|
||||
/items:
|
||||
parameters:
|
||||
- { name: page, in: query, description: path, type: integer }
|
||||
get:
|
||||
operationId: listItems
|
||||
parameters:
|
||||
- { name: page, in: query, description: operation, type: string }
|
||||
responses: { '200': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap();
|
||||
assert_eq!(ir.operations[0].parameters.len(), 1);
|
||||
assert_eq!(
|
||||
ir.operations[0].parameters[0].description.as_deref(),
|
||||
Some("operation")
|
||||
);
|
||||
assert_eq!(
|
||||
ir.operations[0].parameters[0].source_location.pointer,
|
||||
"/paths/~1items/get/parameters/0"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_parameter_omissions_are_errors_at_the_dropped_item_pointer() {
|
||||
let document = r#"
|
||||
@@ -220,10 +272,6 @@ components:
|
||||
"/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",
|
||||
@@ -336,10 +384,6 @@ parameters:
|
||||
"/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",
|
||||
@@ -688,7 +732,7 @@ paths:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_every_unresolved_reference_from_the_full_source_tree() {
|
||||
fn resolves_local_reference_graph_and_preserves_external_blocker() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: References }
|
||||
@@ -722,32 +766,24 @@ components:
|
||||
serde_json::to_vec(&first).unwrap(),
|
||||
serde_json::to_vec(&second).unwrap()
|
||||
);
|
||||
assert_eq!(first.unresolved_references.len(), 10);
|
||||
assert_eq!(first.unresolved_references.len(), 1);
|
||||
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.reference_graph.edges.len() >= 5);
|
||||
assert!(
|
||||
first
|
||||
.reference_graph
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.recursive)
|
||||
);
|
||||
assert!(first.unresolved_references.iter().all(|reference| {
|
||||
reference.construct_id
|
||||
== format!(
|
||||
|
||||
@@ -94,7 +94,7 @@ definitions:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_swagger2_and_preserves_unresolved_definitions() {
|
||||
fn previews_swagger2_and_resolves_local_definitions() {
|
||||
let preview = preview_document(SWAGGER2).unwrap();
|
||||
|
||||
assert_eq!(preview.source.format, "swagger");
|
||||
@@ -105,7 +105,7 @@ definitions:
|
||||
let operation = &preview.groups[0].operations[0];
|
||||
assert_eq!(operation.suggested_name, "get_pet");
|
||||
assert_eq!(operation.input_fields, 1);
|
||||
assert_eq!(operation.output_fields, 0);
|
||||
assert_eq!(operation.output_fields, 2);
|
||||
assert_eq!(operation.draft.target.path_template, "/pets/{id}");
|
||||
assert_eq!(
|
||||
operation.draft.input_mapping.rules[0].target,
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
use crank_import::rest::{
|
||||
ExternalDocumentSnapshot, ImportFindingSeverity, NormalizationConfig, NormalizedSchemaKind,
|
||||
SourceDigest, external_reference_uris, normalize_verified_bundle, normalize_verified_document,
|
||||
preview_from_ir, reference_uris,
|
||||
};
|
||||
|
||||
fn digest(byte: char) -> SourceDigest {
|
||||
SourceDigest::parse(byte.to_string().repeat(64)).unwrap()
|
||||
}
|
||||
|
||||
fn normalize(document: &str) -> crank_import::rest::NormalizedIr {
|
||||
normalize_verified_document(document, digest('a'), &NormalizationConfig::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_local_schema_and_object_references_with_rfc6901_escaping() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Local refs }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items/{id}:
|
||||
get:
|
||||
operationId: getItem
|
||||
parameters:
|
||||
- { $ref: '#/components/parameters/Id' }
|
||||
responses:
|
||||
'200': { $ref: '#/components/responses/Ok' }
|
||||
components:
|
||||
parameters:
|
||||
Id: { name: id, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
Ok:
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/a~1b~0c' }
|
||||
schemas:
|
||||
a/b~c:
|
||||
type: object
|
||||
required: [id]
|
||||
properties: { id: { type: string } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
let operation = &ir.operations[0];
|
||||
assert_eq!(operation.parameters.len(), 1);
|
||||
assert_eq!(operation.parameters[0].name, "id");
|
||||
assert!(matches!(
|
||||
operation.response_schema.as_ref().map(|schema| &schema.kind),
|
||||
Some(NormalizedSchemaKind::Object { properties, .. }) if properties.contains_key("id")
|
||||
));
|
||||
assert!(ir.unresolved_references.is_empty());
|
||||
assert_eq!(ir.reference_graph.edges.len(), 3);
|
||||
assert!(
|
||||
ir.findings
|
||||
.iter()
|
||||
.chain(operation.findings.iter())
|
||||
.all(|finding| finding.code != "unresolved_reference")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_reference_blocks_only_affected_candidate_and_full_preview_remains_visible() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Partial graph }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/broken:
|
||||
get:
|
||||
operationId: broken
|
||||
responses:
|
||||
'200':
|
||||
description: nope
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/Missing' } } }
|
||||
/healthy:
|
||||
get:
|
||||
operationId: healthy
|
||||
responses: { '204': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
let broken = ir
|
||||
.operations
|
||||
.iter()
|
||||
.find(|operation| operation.path == "/broken")
|
||||
.unwrap();
|
||||
let healthy = ir
|
||||
.operations
|
||||
.iter()
|
||||
.find(|operation| operation.path == "/healthy")
|
||||
.unwrap();
|
||||
assert!(broken.findings.iter().any(|finding| {
|
||||
finding.code == "reference_target_missing"
|
||||
&& finding.severity == ImportFindingSeverity::Error
|
||||
}));
|
||||
assert!(healthy.findings.is_empty());
|
||||
let preview = preview_from_ir(&ir);
|
||||
assert_eq!(
|
||||
preview
|
||||
.groups
|
||||
.iter()
|
||||
.map(|group| group.operations.len())
|
||||
.sum::<usize>(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_references_are_default_deny_without_network_or_snapshot() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: External deny }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: { application/json: { schema: { $ref: 'https://schemas.example.test/root.yaml#/Item' } } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
assert!(
|
||||
ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "external_reference_disabled")
|
||||
);
|
||||
assert!(ir.reference_graph.edges.is_empty());
|
||||
assert!(ir.reference_graph.dependency_digests.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_supplied_external_snapshot_and_relative_chain_deterministically() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: External snapshots }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: { application/json: { schema: { $ref: 'https://schemas.example.test/root.yaml#/Item' } } }
|
||||
"#;
|
||||
let snapshots = vec![
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/root.yaml".to_owned(),
|
||||
digest: digest('b'),
|
||||
document: "Item: { $ref: 'child.yaml#/Child' }".to_owned(),
|
||||
},
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/child.yaml".to_owned(),
|
||||
digest: digest('c'),
|
||||
document: "Child: { type: object, properties: { value: { type: integer } } }"
|
||||
.to_owned(),
|
||||
},
|
||||
];
|
||||
let first = normalize_verified_bundle(
|
||||
document,
|
||||
digest('a'),
|
||||
&snapshots,
|
||||
&NormalizationConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let second = normalize_verified_bundle(
|
||||
document,
|
||||
digest('a'),
|
||||
&snapshots,
|
||||
&NormalizationConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_vec(&first).unwrap(),
|
||||
serde_json::to_vec(&second).unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
first.reference_graph.dependency_digests,
|
||||
vec![digest('b'), digest('c')]
|
||||
);
|
||||
assert_eq!(first.reference_graph.edges.len(), 2);
|
||||
assert_eq!(
|
||||
first.reference_graph.edges[1].source.snapshot_digest,
|
||||
digest('b')
|
||||
);
|
||||
assert!(first.unresolved_references.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursion_is_a_stable_graph_edge_without_unbounded_expansion() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Recursive }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/nodes:
|
||||
get:
|
||||
operationId: getNode
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/Node' } } }
|
||||
components:
|
||||
schemas:
|
||||
Node:
|
||||
type: object
|
||||
properties:
|
||||
child: { $ref: '#/components/schemas/Node' }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
assert!(ir.reference_graph.edges.iter().any(|edge| edge.recursive));
|
||||
assert!(ir.unresolved_references.is_empty());
|
||||
assert!(
|
||||
ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.all(|finding| finding.code != "reference_graph_limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merges_compatible_all_of_and_blocks_conflicts_without_first_branch_loss() {
|
||||
let compatible = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: AllOf }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
post:
|
||||
operationId: createItem
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- { type: object, required: [id], properties: { id: { type: string } } }
|
||||
- { type: object, required: [name], properties: { name: { type: string } } }
|
||||
responses: { '204': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize(compatible);
|
||||
assert!(matches!(
|
||||
ir.operations[0].request_body_schema.as_ref().map(|schema| &schema.kind),
|
||||
Some(NormalizedSchemaKind::Object { properties, required })
|
||||
if properties.len() == 2 && required == &vec!["id".to_owned(), "name".to_owned()]
|
||||
));
|
||||
|
||||
let conflict = compatible.replace("{ name: { type: string } }", "{ id: { type: integer } }");
|
||||
let ir = normalize(&conflict);
|
||||
assert!(
|
||||
ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "all_of_conflict")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_one_of_discriminator_and_projects_all_alternatives() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Alternatives }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/events:
|
||||
post:
|
||||
operationId: createEvent
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
discriminator:
|
||||
propertyName: kind
|
||||
mapping: { text: '#/components/schemas/Text' }
|
||||
oneOf:
|
||||
- { type: object, properties: { text: { type: string } } }
|
||||
- { type: object, properties: { count: { type: integer } } }
|
||||
responses: { '204': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
let schema = ir.operations[0].request_body_schema.as_ref().unwrap();
|
||||
assert_eq!(schema.discriminator.as_ref().unwrap().property_name, "kind");
|
||||
assert!(matches!(
|
||||
&schema.kind,
|
||||
NormalizedSchemaKind::Composition { operator, variants }
|
||||
if operator == "oneOf" && variants.len() == 2
|
||||
));
|
||||
let preview = preview_from_ir(&ir);
|
||||
let candidate = &preview.groups[0].operations[0];
|
||||
assert_eq!(
|
||||
candidate
|
||||
.draft
|
||||
.input_schema
|
||||
.fields
|
||||
.get("body")
|
||||
.unwrap()
|
||||
.variants
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
|
||||
let any_of = document
|
||||
.replace("discriminator:\n propertyName: kind\n mapping: { text: '#/components/schemas/Text' }\n oneOf:", "anyOf:");
|
||||
let ir = normalize(&any_of);
|
||||
assert!(matches!(
|
||||
&ir.operations[0].request_body_schema.as_ref().unwrap().kind,
|
||||
NormalizedSchemaKind::Composition { operator, variants }
|
||||
if operator == "anyOf" && variants.len() == 2
|
||||
));
|
||||
let preview = preview_from_ir(&ir);
|
||||
assert_eq!(
|
||||
preview.groups[0].operations[0].draft.input_schema.fields["body"]
|
||||
.variants
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oas_31_applies_ref_siblings_while_oas_30_ignores_them() {
|
||||
let template = |version: &str| {
|
||||
format!(
|
||||
r#"
|
||||
openapi: {version}
|
||||
info: {{ title: Siblings }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Item'
|
||||
description: sibling-description
|
||||
components:
|
||||
schemas:
|
||||
Item: {{ type: string, description: target-description }}
|
||||
"#
|
||||
)
|
||||
};
|
||||
let v30 = normalize(&template("3.0.3"));
|
||||
let v31 = normalize(&template("3.1.0"));
|
||||
assert_eq!(
|
||||
v30.operations[0]
|
||||
.response_schema
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.description
|
||||
.as_deref(),
|
||||
Some("target-description")
|
||||
);
|
||||
assert_eq!(
|
||||
v31.operations[0]
|
||||
.response_schema
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.description
|
||||
.as_deref(),
|
||||
Some("sibling-description")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pointer_and_type_mismatch_are_exact_blockers_without_panic() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Invalid targets }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/missing:
|
||||
get:
|
||||
operationId: missing
|
||||
responses:
|
||||
'200': { description: ok, content: { application/json: { schema: { $ref: '#not-a-pointer' } } } }
|
||||
/scalar:
|
||||
get:
|
||||
operationId: scalar
|
||||
responses:
|
||||
'200': { description: ok, content: { application/json: { schema: { $ref: '#/info/title' } } } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
let codes = ir
|
||||
.operations
|
||||
.iter()
|
||||
.flat_map(|operation| {
|
||||
operation
|
||||
.findings
|
||||
.iter()
|
||||
.map(move |finding| (operation.path.as_str(), finding.code.as_str()))
|
||||
})
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert!(codes.contains(&("/missing", "reference_target_missing")));
|
||||
assert!(codes.contains(&("/scalar", "reference_type_mismatch")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_depth_and_expanded_node_limits_fail_closed() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Bounded }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200': { description: ok, content: { application/json: { schema: { $ref: '#/components/schemas/A' } } } }
|
||||
components:
|
||||
schemas:
|
||||
A: { $ref: '#/components/schemas/B' }
|
||||
B: { $ref: '#/components/schemas/C' }
|
||||
C: { type: object, properties: { id: { type: string } } }
|
||||
"#;
|
||||
let config = NormalizationConfig {
|
||||
max_reference_depth: 1,
|
||||
..NormalizationConfig::default()
|
||||
};
|
||||
let ir = normalize_verified_document(document, digest('a'), &config).unwrap();
|
||||
assert!(
|
||||
ir.operations
|
||||
.iter()
|
||||
.flat_map(|operation| &operation.findings)
|
||||
.any(|finding| finding.code == "reference_graph_limit")
|
||||
);
|
||||
|
||||
let config = NormalizationConfig {
|
||||
max_expanded_nodes: 8,
|
||||
..NormalizationConfig::default()
|
||||
};
|
||||
let ir = normalize_verified_document(document, digest('a'), &config).unwrap();
|
||||
assert!(
|
||||
ir.findings
|
||||
.iter()
|
||||
.chain(
|
||||
ir.operations
|
||||
.iter()
|
||||
.flat_map(|operation| &operation.findings)
|
||||
)
|
||||
.any(|finding| finding.code == "reference_graph_limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_discriminator_and_multiple_composition_operators_are_blockers() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Unsupported composition }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/events:
|
||||
post:
|
||||
operationId: createEvent
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
discriminator: { mapping: { bad: 42 } }
|
||||
oneOf: [{ type: string }, { type: integer }]
|
||||
anyOf: [{ type: boolean }, { type: string }]
|
||||
responses: { '204': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
let codes = ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.map(|finding| finding.code.as_str())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
assert!(codes.contains("unsupported_discriminator"));
|
||||
assert!(codes.contains("unsupported_composition"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_uri_scan_applies_external_size_depth_and_alias_limits_before_traversal() {
|
||||
let config = NormalizationConfig {
|
||||
max_bytes: 8,
|
||||
max_external_document_bytes: 8,
|
||||
..NormalizationConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
reference_uris("external-document", &config),
|
||||
Err(crank_import::rest::ImportParseError::LimitExceeded)
|
||||
);
|
||||
|
||||
let config = NormalizationConfig {
|
||||
max_bytes: 8,
|
||||
max_external_document_bytes: 32,
|
||||
..NormalizationConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
external_reference_uris("external-document", &config),
|
||||
Ok(Vec::new())
|
||||
);
|
||||
|
||||
let config = NormalizationConfig {
|
||||
max_depth: 1,
|
||||
..NormalizationConfig::default()
|
||||
};
|
||||
assert_eq!(
|
||||
reference_uris("a: { b: { $ref: '#/x' } }", &config),
|
||||
Err(crank_import::rest::ImportParseError::LimitExceeded)
|
||||
);
|
||||
|
||||
let aliases = format!(
|
||||
"items: [{}]",
|
||||
std::iter::repeat_n("*a", 129)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
);
|
||||
assert_eq!(
|
||||
reference_uris(&aliases, &NormalizationConfig::default()),
|
||||
Err(crank_import::rest::ImportParseError::LimitExceeded)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_references_using_rfc3986_paths_and_decoded_fragments() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Relative refs }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content: { application/json: { schema: { $ref: 'HTTPS://SCHEMAS.EXAMPLE.TEST:443/a/b/root.yaml#/Item' } } }
|
||||
"#;
|
||||
let snapshots = vec![
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/a/b/root.yaml".to_owned(),
|
||||
digest: digest('b'),
|
||||
document: r#"
|
||||
Item:
|
||||
type: object
|
||||
properties:
|
||||
dot: { $ref: './child.yaml#/Value' }
|
||||
parent: { $ref: '../common.yaml#/Value' }
|
||||
absolute: { $ref: '/shared.yaml#/Value' }
|
||||
"#
|
||||
.to_owned(),
|
||||
},
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/a/b/child.yaml".to_owned(),
|
||||
digest: digest('c'),
|
||||
document: "Value: { type: string }".to_owned(),
|
||||
},
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/a/common.yaml".to_owned(),
|
||||
digest: digest('d'),
|
||||
document: "Value: { type: integer }".to_owned(),
|
||||
},
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/shared.yaml".to_owned(),
|
||||
digest: digest('e'),
|
||||
document: "Value: { $ref: '#/a%7E1b' }\na/b: { type: boolean }".to_owned(),
|
||||
},
|
||||
];
|
||||
let ir = normalize_verified_bundle(
|
||||
document,
|
||||
digest('a'),
|
||||
&snapshots,
|
||||
&NormalizationConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let Some(NormalizedSchemaKind::Object { properties, .. }) = ir.operations[0]
|
||||
.response_schema
|
||||
.as_ref()
|
||||
.map(|schema| &schema.kind)
|
||||
else {
|
||||
panic!("response should be an expanded object schema");
|
||||
};
|
||||
assert_eq!(properties.len(), 3);
|
||||
assert_eq!(ir.reference_graph.edges.len(), 5);
|
||||
assert!(ir.findings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_percent_encoded_fragment_is_a_blocker() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Malformed reference }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200': { description: ok, content: { application/json: { schema: { $ref: '#/components/%ZZ' } } } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
assert!(
|
||||
ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "reference_uri_malformed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_follow_references_inside_literal_payloads() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Literal payloads }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200':
|
||||
description: ok
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
example: { $ref: '#/components/schemas/Missing' }
|
||||
examples: { sample: { value: { $ref: '#/components/schemas/Missing' } } }
|
||||
default: { $ref: '#/components/schemas/Missing' }
|
||||
enum: [{ $ref: '#/components/schemas/Missing' }]
|
||||
const: { $ref: '#/components/schemas/Missing' }
|
||||
x-fixture: { $ref: '#/components/schemas/Missing' }
|
||||
properties: { known: { $ref: '#/components/schemas/Known' } }
|
||||
components:
|
||||
schemas:
|
||||
Known: { type: string }
|
||||
"#;
|
||||
assert_eq!(
|
||||
reference_uris(document, &NormalizationConfig::default()),
|
||||
Ok(vec!["#/components/schemas/Known".to_owned()])
|
||||
);
|
||||
let ir = normalize(document);
|
||||
assert_eq!(ir.reference_graph.edges.len(), 1);
|
||||
assert!(
|
||||
ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.all(|finding| finding.code != "reference_target_missing")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_oas31_applies_ref_siblings_in_external_snapshots_without_openapi_field() {
|
||||
let source = |version: &str| {
|
||||
format!(
|
||||
r#"
|
||||
openapi: {version}
|
||||
info: {{ title: External siblings }}
|
||||
servers: [{{ url: https://api.example.test }}]
|
||||
paths:
|
||||
/items:
|
||||
get:
|
||||
operationId: listItems
|
||||
responses:
|
||||
'200': {{ description: ok, content: {{ application/json: {{ schema: {{ $ref: 'https://schemas.example.test/root.yaml#/Item' }} }} }} }}
|
||||
"#
|
||||
)
|
||||
};
|
||||
let snapshots = vec![
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/root.yaml".to_owned(),
|
||||
digest: digest('b'),
|
||||
document: "Item: { $ref: 'child.yaml#/Base', description: sibling }".to_owned(),
|
||||
},
|
||||
ExternalDocumentSnapshot {
|
||||
canonical_uri: "https://schemas.example.test/child.yaml".to_owned(),
|
||||
digest: digest('c'),
|
||||
document: "Base: { type: string, description: target }".to_owned(),
|
||||
},
|
||||
];
|
||||
let v31 = normalize_verified_bundle(
|
||||
&source("3.1.0"),
|
||||
digest('a'),
|
||||
&snapshots,
|
||||
&NormalizationConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let v30 = normalize_verified_bundle(
|
||||
&source("3.0.3"),
|
||||
digest('a'),
|
||||
&snapshots,
|
||||
&NormalizationConfig::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
v31.operations[0]
|
||||
.response_schema
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.description
|
||||
.as_deref(),
|
||||
Some("sibling")
|
||||
);
|
||||
assert_eq!(
|
||||
v30.operations[0]
|
||||
.response_schema
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.description
|
||||
.as_deref(),
|
||||
Some("target")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_of_intersects_constraints_and_nested_properties_without_losing_conflicts() {
|
||||
let source = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: allOf intersections }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
post:
|
||||
operationId: createItem
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
allOf:
|
||||
- type: object
|
||||
minimum: 1
|
||||
maximum: 10
|
||||
minLength: 2
|
||||
maxLength: 12
|
||||
properties: { nested: { type: object, properties: { left: { type: string } } } }
|
||||
- type: object
|
||||
minimum: 4
|
||||
maximum: 8
|
||||
minLength: 5
|
||||
maxLength: 9
|
||||
properties: { nested: { type: object, properties: { right: { type: integer } } } }
|
||||
responses: { '204': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize(source);
|
||||
let schema = ir.operations[0].request_body_schema.as_ref().unwrap();
|
||||
assert_eq!(schema.constraints.minimum, Some(4.0));
|
||||
assert_eq!(schema.constraints.maximum, Some(8.0));
|
||||
assert_eq!(schema.constraints.min_length, Some(5));
|
||||
assert_eq!(schema.constraints.max_length, Some(9));
|
||||
let NormalizedSchemaKind::Object { properties, .. } = &schema.kind else {
|
||||
panic!("merged schema should remain an object");
|
||||
};
|
||||
let NormalizedSchemaKind::Object { properties, .. } = &properties["nested"].kind else {
|
||||
panic!("nested property should remain an object");
|
||||
};
|
||||
assert!(properties.contains_key("left") && properties.contains_key("right"));
|
||||
|
||||
let conflicting = source.replace("maximum: 8", "maximum: 3");
|
||||
let ir = normalize(&conflicting);
|
||||
assert!(
|
||||
ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "all_of_conflict")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_array_all_of_is_preserved_and_reported() {
|
||||
let document = r#"
|
||||
openapi: 3.1.0
|
||||
info: { title: Invalid allOf }
|
||||
servers: [{ url: https://api.example.test }]
|
||||
paths:
|
||||
/items:
|
||||
post:
|
||||
operationId: createItem
|
||||
requestBody:
|
||||
content: { application/json: { schema: { allOf: { type: string } } } }
|
||||
responses: { '204': { description: ok } }
|
||||
"#;
|
||||
let ir = normalize(document);
|
||||
assert!(
|
||||
ir.operations[0]
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "unsupported_composition")
|
||||
);
|
||||
assert!(matches!(
|
||||
ir.operations[0]
|
||||
.request_body_schema
|
||||
.as_ref()
|
||||
.map(|schema| &schema.kind),
|
||||
Some(NormalizedSchemaKind::Unknown)
|
||||
));
|
||||
}
|
||||
@@ -122,7 +122,7 @@ paths:
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_references_typed_without_resolving_them() {
|
||||
fn resolves_local_references_into_typed_graph() {
|
||||
let ir = normalize_document(SWAGGER2, &NormalizationConfig::default()).unwrap();
|
||||
let operation = &ir.operations[0];
|
||||
assert!(matches!(
|
||||
@@ -130,13 +130,15 @@ paths:
|
||||
.response_schema
|
||||
.as_ref()
|
||||
.map(|schema| &schema.kind),
|
||||
Some(crank_import::rest::NormalizedSchemaKind::Reference { .. })
|
||||
Some(crank_import::rest::NormalizedSchemaKind::Object { .. })
|
||||
));
|
||||
assert!(ir.unresolved_references.is_empty());
|
||||
assert!(!ir.reference_graph.edges.is_empty());
|
||||
assert!(
|
||||
operation
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "unresolved_reference")
|
||||
.all(|finding| finding.code != "unresolved_reference")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user