feat(import): add deterministic OpenAPI normalized IR
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

This commit is contained in:
2026-08-29 04:15:37 +03:00
parent bc03c33387
commit 6c2a3712d8
22 changed files with 6051 additions and 352 deletions
+275
View File
@@ -0,0 +1,275 @@
use crank_import::rest::model::{CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition};
use crank_import::rest::{
ImportFindingSeverity, ImportParseError, NormalizationConfig, NormalizedFinding, NormalizedIr,
SourceDigest, SourceLocation, normalize_verified_document, validate_normalized_ir,
};
fn normalize(document: &str) -> NormalizedIr {
normalize_verified_document(
document,
SourceDigest::parse("c".repeat(64)).unwrap(),
&NormalizationConfig::default(),
)
.unwrap()
}
fn valid_source() -> &'static str {
r#"
openapi: 3.1.0
info: { title: Coverage, version: v1 }
paths:
/items:
get:
operationId: listItems
tags: [items]
responses:
'200':
description: ok
content:
application/json:
schema: { type: object, properties: { id: { type: string } } }
"#
}
fn assert_invalid(ir: &NormalizedIr) {
assert_eq!(
validate_normalized_ir(ir),
Err(ImportParseError::InvalidDocument)
);
}
#[test]
fn source_digest_deserialization_preserves_the_validated_invariant() {
assert!(serde_json::from_str::<SourceDigest>(&format!("\"{}\"", "a".repeat(64))).is_ok());
assert!(serde_json::from_str::<SourceDigest>(&format!("\"{}\"", "A".repeat(64))).is_err());
assert!(serde_json::from_str::<SourceDigest>("\"not-a-digest\"").is_err());
}
#[test]
fn rejects_tampered_coverage_location_disposition_and_orphan_finding() {
let ir = normalize(valid_source());
let operation_id = ir.operations[0].stable_id.clone();
let mut wrong_location = ir.clone();
wrong_location
.coverage
.iter_mut()
.find(|entry| entry.construct_id == operation_id)
.unwrap()
.location
.pointer = "/paths/~1other/get".to_owned();
assert_invalid(&wrong_location);
let mut wrong_disposition = ir.clone();
wrong_disposition
.coverage
.iter_mut()
.find(|entry| entry.construct_id == operation_id)
.unwrap()
.disposition = CoverageDisposition::Finding;
assert_invalid(&wrong_disposition);
let mut duplicate = ir.clone();
duplicate.coverage.push(duplicate.coverage[0].clone());
assert_invalid(&duplicate);
let mut orphan = ir;
orphan.findings.push(NormalizedFinding {
code: "forged".to_owned(),
severity: ImportFindingSeverity::Warning,
message: "forged".to_owned(),
construct_id: "orphan".to_owned(),
location: SourceLocation {
pointer: String::new(),
},
operation_key: None,
});
assert_invalid(&orphan);
}
#[test]
fn rejects_tampered_contract_operation_identity_and_source_tree() {
let ir = normalize(valid_source());
let mut normalizer = ir.clone();
normalizer.normalizer_version = "future-normalizer".to_owned();
assert_invalid(&normalizer);
let mut projection = ir.clone();
projection.projection_version = "future-projection".to_owned();
assert_invalid(&projection);
let mut source_version = ir.clone();
source_version.source.version = Some("3.0.3".to_owned());
assert_invalid(&source_version);
let mut stable_id = ir.clone();
stable_id.operations[0].stable_id.push_str(":forged");
assert_invalid(&stable_id);
let mut operation_location = ir.clone();
operation_location.operations[0].location.pointer = "/paths/~1items/post".to_owned();
assert_invalid(&operation_location);
let mut operation_key = ir.clone();
operation_key.operations[0].key = "POST /items".to_owned();
assert_invalid(&operation_key);
let mut schema_description = ir.clone();
schema_description.operations[0]
.response_schema
.as_mut()
.unwrap()
.description = Some("forged description".to_owned());
assert_invalid(&schema_description);
let mut source_tree = ir;
source_tree.source_tree.location.pointer = "/forged".to_owned();
assert_invalid(&source_tree);
}
#[test]
fn validates_every_recursive_source_tree_pointer_and_construct_id() {
let mut ir = normalize(valid_source());
let CanonicalSourceValue::Object(root) = &mut ir.source_tree.value else {
panic!("source root must be an object");
};
let CanonicalSourceValue::Object(info) = &mut root.get_mut("info").unwrap().value else {
panic!("info must be an object");
};
let title = info.get_mut("title").unwrap();
title.construct_id = "source:/info/wrong".to_owned();
assert_invalid(&ir);
let mut ir = normalize(valid_source());
let title = source_node_mut(&mut ir.source_tree, "/info/title").unwrap();
title.location.pointer = "/info/wrong".to_owned();
assert_invalid(&ir);
}
#[test]
fn accepts_virtual_missing_operation_id_with_exact_finding_coverage() {
let ir = normalize(
"openapi: 3.1.0\ninfo: { title: Missing ID }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
);
validate_normalized_ir(&ir).unwrap();
let operation = &ir.operations[0];
let id = format!("{}:operation_id", operation.stable_id);
let expected_pointer = format!("{}/operationId", operation.location.pointer);
assert!(source_node(&ir.source_tree, &expected_pointer).is_none());
assert!(operation.findings.iter().any(|finding| {
finding.code == "missing_operation_id"
&& finding.construct_id == id
&& finding.location.pointer == expected_pointer
}));
assert!(ir.coverage.iter().any(|entry| {
entry.construct_id == id
&& entry.location.pointer == expected_pointer
&& entry.disposition == CoverageDisposition::Finding
}));
let mut missing_finding = ir;
missing_finding.operations[0]
.findings
.retain(|finding| finding.code != "missing_operation_id");
assert_invalid(&missing_finding);
}
#[test]
fn deduplicates_shared_path_parameter_and_schema_coverage_exactly() {
let ir = normalize(
r#"
openapi: 3.1.0
info: { title: Shared }
paths:
/items/{id}:
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
get:
operationId: getItem
responses: { '200': { description: ok } }
post:
operationId: updateItem
responses: { '200': { description: ok } }
"#,
);
validate_normalized_ir(&ir).unwrap();
assert_eq!(ir.operations.len(), 2);
assert_eq!(ir.operations[0].parameters, ir.operations[1].parameters);
let parameter_id = &ir.operations[0].parameters[0].construct_id;
let schema_id = &ir.operations[0].parameters[0]
.schema
.as_ref()
.unwrap()
.construct_id;
assert_eq!(
ir.coverage
.iter()
.filter(|entry| &entry.construct_id == parameter_id)
.count(),
1
);
assert_eq!(
ir.coverage
.iter()
.filter(|entry| &entry.construct_id == schema_id)
.count(),
1
);
}
#[test]
fn unsupported_method_has_exact_finding_pointer_and_coverage() {
let ir = normalize(
"openapi: 3.1.0\ninfo: { title: Unsupported }\npaths: { /ok: { get: { responses: { '200': { description: ok } } }, head: { responses: {} } } }",
);
validate_normalized_ir(&ir).unwrap();
let pointer = "/paths/~1ok/head";
let construct_id = format!("3.1.0:{pointer}");
assert!(ir.findings.iter().any(|finding| {
finding.code == "unsupported_http_method"
&& finding.construct_id == construct_id
&& finding.location.pointer == pointer
}));
assert!(ir.coverage.iter().any(|entry| {
entry.construct_id == construct_id
&& entry.location.pointer == pointer
&& entry.disposition == CoverageDisposition::Finding
}));
}
fn source_node<'a>(
node: &'a CanonicalSourceNode,
pointer: &str,
) -> Option<&'a CanonicalSourceNode> {
if node.location.pointer == pointer {
return Some(node);
}
match &node.value {
CanonicalSourceValue::Array(items) => {
items.iter().find_map(|child| source_node(child, pointer))
}
CanonicalSourceValue::Object(items) => {
items.values().find_map(|child| source_node(child, pointer))
}
_ => None,
}
}
fn source_node_mut<'a>(
node: &'a mut CanonicalSourceNode,
pointer: &str,
) -> Option<&'a mut CanonicalSourceNode> {
if node.location.pointer == pointer {
return Some(node);
}
match &mut node.value {
CanonicalSourceValue::Array(items) => items
.iter_mut()
.find_map(|child| source_node_mut(child, pointer)),
CanonicalSourceValue::Object(items) => items
.values_mut()
.find_map(|child| source_node_mut(child, pointer)),
_ => None,
}
}
@@ -0,0 +1 @@
["not", "an", "openapi", "root"]
@@ -0,0 +1,3 @@
openapi: 3.2.0
info: { title: unsupported }
paths: {}
@@ -0,0 +1,3 @@
openapi: [wrong-root-field]
info: { title: Invalid }
paths: {}
+6
View File
@@ -0,0 +1,6 @@
openapi: 3.0.3
info: { title: Fixture OpenAPI 3.0 }
paths:
/health:
get:
responses: { '200': { description: OK } }
+9
View File
@@ -0,0 +1,9 @@
{
"openapi": "3.1.0",
"info": { "title": "Fixture OpenAPI 3.1" },
"paths": {
"/health": {
"get": { "responses": { "200": { "description": "OK" } } }
}
}
}
+6
View File
@@ -0,0 +1,6 @@
swagger: '2.0'
info: { title: Fixture Swagger 2.0 }
paths:
/health:
get:
responses: { '200': { description: OK } }
@@ -0,0 +1,767 @@
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
})
}));
}
}
+615
View File
@@ -0,0 +1,615 @@
mod unit {
use crank_core::HttpMethod;
use crank_import::rest::{ImportParseError, preview_document, preview_document_legacy_v1};
use crank_schema::SchemaKind;
use serde_json::json;
const OPENAPI3: &str = r#"
openapi: 3.0.3
info:
title: Frankfurter API
servers:
- url: https://api.frankfurter.dev
paths:
/v2/latest:
get:
operationId: getLatestRates
summary: Получить последние курсы
description: Возвращает последние курсы валют для базовой валюты.
tags: [currency]
parameters:
- name: base
in: query
required: true
schema:
type: string
- name: symbols
in: query
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [amount, base]
properties:
amount:
type: number
base:
type: string
"#;
const SWAGGER2: &str = r#"
swagger: "2.0"
info:
title: Pet API
host: petstore.example.com
basePath: /api
schemes: [https]
paths:
/pets/{id}:
get:
operationId: getPet
summary: Получить питомца
tags: [pets]
parameters:
- name: id
in: path
required: true
type: string
responses:
'200':
description: OK
schema:
$ref: '#/definitions/Pet'
definitions:
Pet:
type: object
required: [id, name]
properties:
id:
type: string
name:
type: string
"#;
#[test]
fn previews_openapi3_rest_operations_grouped_by_tag() {
let preview = preview_document(OPENAPI3).unwrap();
assert_eq!(preview.source.format, "openapi");
assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]);
assert_eq!(preview.groups.len(), 1);
assert_eq!(preview.groups[0].key, "currency");
let operation = &preview.groups[0].operations[0];
assert_eq!(operation.method, HttpMethod::Get);
assert_eq!(operation.suggested_name, "get_latest_rates");
assert_eq!(operation.input_fields, 2);
assert_eq!(operation.output_fields, 2);
assert_eq!(operation.draft.target.path_template, "/v2/latest");
assert_eq!(operation.draft.input_mapping.rules.len(), 2);
}
#[test]
fn previews_swagger2_and_preserves_unresolved_definitions() {
let preview = preview_document(SWAGGER2).unwrap();
assert_eq!(preview.source.format, "swagger");
assert_eq!(
preview.source.servers,
vec!["https://petstore.example.com/api"]
);
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.draft.target.path_template, "/pets/{id}");
assert_eq!(
operation.draft.input_mapping.rules[0].target,
"$.request.path.id"
);
}
#[test]
fn normalized_schema_descriptions_round_trip_to_draft_fields_and_arrays_default_to_string_items()
{
let document = r#"
openapi: 3.1.0
info: { title: Schema descriptions }
paths:
/items:
post:
operationId: createItem
parameters:
- name: filter
in: query
schema:
type: object
description: Filter input
properties:
nested:
type: object
description: Nested filter
properties:
labels:
type: array
description: Labels without item schema
requestBody:
content:
application/json:
schema:
type: object
description: Request root
properties:
payload:
type: object
description: Request payload
properties:
enabled: { type: boolean, description: Enable the item }
responses:
'201':
description: created
content:
application/json:
schema:
type: object
description: Created item response
properties:
id: { type: string, description: New item identifier }
"#;
let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0];
let filter = operation.draft.input_schema.field("filter").unwrap();
assert_eq!(filter.description.as_deref(), Some("Filter input"));
let nested = filter.field("nested").unwrap();
assert_eq!(nested.description.as_deref(), Some("Nested filter"));
let labels = nested.field("labels").unwrap();
assert_eq!(
labels.description.as_deref(),
Some("Labels without item schema")
);
assert_eq!(labels.kind, SchemaKind::Array);
assert_eq!(labels.items.as_ref().unwrap().kind, SchemaKind::String);
let payload = operation.draft.input_schema.field("payload").unwrap();
assert_eq!(payload.description.as_deref(), Some("Request payload"));
assert_eq!(
payload.field("enabled").unwrap().description.as_deref(),
Some("Enable the item")
);
assert_eq!(
operation.draft.output_schema.description.as_deref(),
Some("Ответ API")
);
assert_eq!(
operation
.draft
.output_schema
.field("id")
.unwrap()
.description
.as_deref(),
Some("New item identifier")
);
}
#[test]
fn legacy_v1_keeps_frozen_response_priority_when_v2_selects_later_success_code() {
let document = r#"
openapi: 3.1.0
info: { title: Response replay priority }
paths:
/items:
get:
operationId: listItems
responses:
'200': { description: no schema }
'203': { description: selected by v2, content: { application/json: { schema: { type: string } } } }
'206': { description: later success, content: { application/json: { schema: { type: integer } } } }
default: { description: legacy fallback, content: { application/json: { schema: { type: boolean } } } }
"#;
let v2 = preview_document(document).unwrap();
assert_eq!(
v2.groups[0].operations[0].draft.output_schema.kind,
SchemaKind::String
);
let legacy = preview_document_legacy_v1(document).unwrap();
assert_eq!(
legacy.groups[0].operations[0].draft.output_schema.kind,
SchemaKind::Boolean
);
}
#[test]
fn legacy_v1_projection_matches_baseline_local_ref_and_composition_behavior() {
let document = r#"
swagger: '2.0'
info: { title: Legacy }
paths:
/pets:
get:
responses:
'200': { description: OK, schema: { $ref: '#/definitions/Pet' } }
definitions:
Pet:
allOf:
- type: object
properties: { id: { type: string }, name: { type: string } }
"#;
let preview = preview_document_legacy_v1(document).unwrap();
assert_eq!(preview.groups[0].operations[0].output_fields, 2);
}
#[test]
fn legacy_v1_resolves_object_level_local_refs_before_projection() {
let document = r#"
openapi: 3.0.3
info: { title: Legacy object refs }
paths:
/items:
get:
operationId: getItem
parameters: [{ $ref: '#/components/parameters/Id' }]
requestBody: { $ref: '#/components/requestBodies/Body' }
responses:
'200': { $ref: '#/components/responses/Ok' }
components:
parameters:
Id:
name: id
in: query
schema: { type: string }
requestBodies:
Body:
content:
application/json:
schema: { $ref: '#/components/schemas/Input' }
responses:
Ok:
description: ok
content:
application/json:
schema: { $ref: '#/components/schemas/Output' }
schemas:
Input:
type: object
properties: { name: { type: string } }
Output:
type: object
properties: { result: { type: string } }
"#;
let preview = preview_document_legacy_v1(document).unwrap();
let operation = &preview.groups[0].operations[0];
assert_eq!(operation.input_fields, 2);
assert_eq!(operation.output_fields, 1);
assert!(operation.draft.input_schema.fields.contains_key("id"));
assert!(operation.draft.input_schema.fields.contains_key("name"));
assert!(operation.draft.output_schema.fields.contains_key("result"));
}
#[test]
fn legacy_v1_rejects_hostile_alias_input_before_decode() {
let document = format!(
"openapi: 3.0.3\ninfo: {{ title: aliases }}\npaths: {{}}\nitems: [{}]",
std::iter::repeat_n("*bomb", 129)
.collect::<Vec<_>>()
.join(", ")
);
assert_eq!(
preview_document_legacy_v1(&document),
Err(ImportParseError::LimitExceeded)
);
}
#[test]
fn legacy_v1_keeps_baseline_version_dispatch_and_does_not_expand_path_item_refs() {
let document = r#"
openapi: 9.9.0
info: { title: Legacy dispatch }
paths:
/valid:
get:
operationId: valid
responses: { '200': { description: ok } }
/referenced:
$ref: '#/x-path-items/referenced'
x-path-items:
referenced:
post:
operationId: mustNotAppear
responses: { '201': { description: ok } }
"#;
let preview = preview_document_legacy_v1(document).unwrap();
assert_eq!(preview.source.version.as_deref(), Some("9.9.0"));
let keys = preview
.groups
.iter()
.flat_map(|group| group.operations.iter())
.map(|operation| operation.key.as_str())
.collect::<Vec<_>>();
assert_eq!(keys, vec!["GET /valid"]);
}
#[test]
fn legacy_v1_preview_exactly_matches_baseline_for_path_refs_servers_and_malformed_operations() {
let document = r#"
openapi: 3.0.3
info: { title: Legacy parity }
servers: [{ url: https://document.test }]
paths:
/items:
parameters:
- { $ref: '#/components/parameters/Query' }
- { name: session, in: cookie }
servers: [{ url: https://path.test }]
head: { responses: {} }
get: false
components:
parameters:
Query: { name: q, in: query, schema: { type: string } }
"#;
let preview = preview_document_legacy_v1(document).unwrap();
assert_eq!(
serde_json::to_value(preview).unwrap(),
json!({
"source": {
"format": "openapi",
"version": "3.0.3",
"title": "Legacy parity",
"servers": ["https://document.test"]
},
"groups": [{
"key": "imported_operation",
"title": "Без группы",
"operations": [{
"key": "GET /items",
"method": "GET",
"path": "/items",
"suggested_name": "g_e_t_items",
"suggested_display_name": "G E T Items",
"description": "Выполняет GET /items",
"category": "imported",
"input_fields": 1,
"output_fields": 0,
"server_urls": ["https://document.test"],
"findings": [
{
"code": "missing_operation_id",
"severity": "warning",
"message": "У метода нет operationId, имя инструмента будет сгенерировано из метода и пути.",
"operation_key": "GET /items"
},
{
"code": "missing_summary",
"severity": "warning",
"message": "У метода нет summary, отображаемое имя будет сгенерировано автоматически.",
"operation_key": "GET /items"
},
{
"code": "missing_description",
"severity": "warning",
"message": "У метода нет description. Перед публикацией лучше описать, когда агенту стоит вызывать этот инструмент.",
"operation_key": "GET /items"
},
{
"code": "missing_response_schema",
"severity": "warning",
"message": "У метода не найдена схема успешного ответа, результат будет описан как общий объект.",
"operation_key": "GET /items"
},
{
"code": "parameter_descriptions_missing",
"severity": "warning",
"message": "У 1 входных параметров нет описания. Модели будет сложнее понять, какие значения туда передавать.",
"operation_key": "GET /items"
},
{
"code": "empty_output_schema",
"severity": "warning",
"message": "В ответе не найдено отдельных полей. Перед публикацией проверьте схему ответа и маппинг результата.",
"operation_key": "GET /items"
},
{
"code": "weak_tool_description",
"severity": "warning",
"message": "Описание инструмента слишком короткое или техническое. Перед публикацией добавьте, когда агент должен вызывать инструмент и что будет в успешном ответе.",
"operation_key": "GET /items"
},
{
"code": "weak_tool_name",
"severity": "warning",
"message": "Имя инструмента `g_e_t_items` выглядит слишком общим. Лучше использовать имя с конкретным действием и объектом.",
"operation_key": "GET /items"
}
],
"draft": {
"name": "g_e_t_items",
"display_name": "G E T Items",
"category": "imported",
"target": {
"base_url": "https://document.test",
"method": "GET",
"path_template": "/items"
},
"input_schema": {
"type": "object",
"description": "Входные параметры MCP-инструмента",
"required": true,
"nullable": false,
"fields": {
"q": {
"type": "string",
"required": false,
"nullable": false
}
}
},
"output_schema": {
"type": "object",
"description": "Ответ API",
"required": true,
"nullable": false
},
"input_mapping": {
"rules": [{
"source": "$.mcp.q",
"target": "$.request.query.q",
"required": false
}]
},
"output_mapping": {
"rules": [{
"source": "$.response.body",
"target": "$.output",
"required": true
}]
},
"tool_description": {
"title": "G E T Items",
"description": "Выполняет GET /items",
"examples": [{ "input": {} }]
},
"wizard_state": {}
}
}]
}],
"findings": []
})
);
}
#[test]
fn reports_missing_descriptions_as_recommendations() {
let document = r#"
openapi: 3.0.3
info: { title: Minimal API }
paths:
/items:
get:
responses:
'204': { description: Empty }
"#;
let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0];
let codes = operation
.findings
.iter()
.map(|finding| finding.code.as_str())
.collect::<Vec<_>>();
assert!(codes.contains(&"missing_operation_id"));
assert!(codes.contains(&"missing_summary"));
assert!(codes.contains(&"missing_description"));
assert!(codes.contains(&"missing_response_schema"));
}
#[test]
fn expands_json_request_body_object_into_tool_inputs() {
let document = r#"
openapi: 3.0.3
info: { title: CRM API }
servers:
- url: https://crm.example.test
paths:
/leads:
post:
operationId: createLead
summary: Создать лид
description: Создает лид в CRM.
tags: [crm]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string }
name: { type: string }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
"#;
let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0];
let input_fields = &operation.draft.input_schema.fields;
let targets = operation
.draft
.input_mapping
.rules
.iter()
.map(|rule| rule.target.as_str())
.collect::<Vec<_>>();
assert!(input_fields.contains_key("email"));
assert!(input_fields.contains_key("name"));
assert!(targets.contains(&"$.request.body.email"));
assert!(targets.contains(&"$.request.body.name"));
}
#[test]
fn reports_tool_quality_recommendations_for_imported_operations() {
let document = r#"
openapi: 3.0.3
info: { title: Wide API }
paths:
/items:
get:
operationId: getItems
summary: Get items
description: Get items.
parameters:
- name: page
in: query
schema: { type: integer }
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
field01: { type: string }
field02: { type: string }
field03: { type: string }
field04: { type: string }
field05: { type: string }
field06: { type: string }
field07: { type: string }
field08: { type: string }
field09: { type: string }
field10: { type: string }
field11: { type: string }
field12: { type: string }
field13: { type: string }
"#;
let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0];
let codes = operation
.findings
.iter()
.map(|finding| finding.code.as_str())
.collect::<Vec<_>>();
assert!(codes.contains(&"parameter_descriptions_missing"));
assert!(codes.contains(&"weak_tool_description"));
assert!(codes.contains(&"weak_tool_name"));
assert!(codes.contains(&"too_many_output_fields"));
}
}
+504 -167
View File
@@ -1,44 +1,8 @@
mod unit {
use crank_core::HttpMethod;
use crank_import::rest::preview_document;
const OPENAPI3: &str = r#"
openapi: 3.0.3
info:
title: Frankfurter API
servers:
- url: https://api.frankfurter.dev
paths:
/v2/latest:
get:
operationId: getLatestRates
summary: Получить последние курсы
description: Возвращает последние курсы валют для базовой валюты.
tags: [currency]
parameters:
- name: base
in: query
required: true
schema:
type: string
- name: symbols
in: query
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
required: [amount, base]
properties:
amount:
type: number
base:
type: string
"#;
use crank_import::rest::{
ImportFindingSeverity, ImportParseError, NormalizationConfig, normalize_verified_document,
preview_document,
};
const SWAGGER2: &str = r#"
swagger: "2.0"
@@ -74,170 +38,543 @@ definitions:
type: string
"#;
#[test]
fn previews_openapi3_rest_operations_grouped_by_tag() {
let preview = preview_document(OPENAPI3).unwrap();
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,
)
}
assert_eq!(preview.source.format, "openapi");
assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]);
assert_eq!(preview.groups.len(), 1);
assert_eq!(preview.groups[0].key, "currency");
let operation = &preview.groups[0].operations[0];
assert_eq!(operation.method, HttpMethod::Get);
assert_eq!(operation.suggested_name, "get_latest_rates");
assert_eq!(operation.input_fields, 2);
assert_eq!(operation.output_fields, 2);
assert_eq!(operation.draft.target.path_template, "/v2/latest");
assert_eq!(operation.draft.input_mapping.rules.len(), 2);
fn matrix_source() -> String {
"openapi: 3.1.0\ninfo: { title: Matrix }\npaths:\n /ok:\n get:\n operationId: getOk\n tags: [matrix]\n parameters: [{ name: q, in: query, schema: { type: string } }]\n responses: { '200': { description: ok, content: { application/json: { schema: { type: object, properties: { value: { type: string } } } } } } }".to_owned()
}
#[test]
fn previews_swagger2_and_resolves_definitions() {
let preview = preview_document(SWAGGER2).unwrap();
fn normalizes_supported_versions_with_canonical_order_and_stable_ids() {
let fixture_matrix = [
(include_str!("fixtures/openapi-3.0.yaml"), "3.0.3"),
(include_str!("fixtures/openapi-3.1.json"), "3.1.0"),
(include_str!("fixtures/swagger-2.0.yaml"), "2.0"),
];
for (fixture, version) in fixture_matrix {
assert_eq!(
normalize_document(fixture, &NormalizationConfig::default())
.unwrap()
.source
.version
.as_deref(),
Some(version)
);
}
let openapi_31_json = r#"{
"openapi":"3.1.0", "info":{"title":"Canonical"},
"paths": {
"/z":{"post":{"responses":{"201":{"description":"ok"}}}},
"/a":{"get":{"responses":{"200":{"description":"ok"}}}}
}
}"#;
let openapi_30_yaml = r#"
openapi: 3.0.3
info: { title: Canonical }
paths:
/a:
get:
responses: { '200': { description: ok } }
"#;
let swagger = r#"
swagger: '2.0'
info: { title: Legacy }
paths:
/a:
get:
responses: { '200': { description: ok } }
"#;
assert_eq!(preview.source.format, "swagger");
let first = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap();
let second = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap();
assert_eq!(
preview.source.servers,
vec!["https://petstore.example.com/api"]
serde_json::to_vec(&first).unwrap(),
serde_json::to_vec(&second).unwrap()
);
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, 2);
assert_eq!(operation.draft.target.path_template, "/pets/{id}");
assert_eq!(first.operations[0].path, "/a");
assert_eq!(first.operations[0].stable_id, "3.1.0:get:/paths/~1a/get");
assert!(first.coverage.len() >= 2);
assert_eq!(
operation.draft.input_mapping.rules[0].target,
"$.request.path.id"
normalize_document(openapi_30_yaml, &NormalizationConfig::default())
.unwrap()
.source
.version
.as_deref(),
Some("3.0.3")
);
assert_eq!(
normalize_document(swagger, &NormalizationConfig::default())
.unwrap()
.source
.version
.as_deref(),
Some("2.0")
);
}
#[test]
fn reports_missing_descriptions_as_recommendations() {
let document = r#"
openapi: 3.0.3
info: { title: Minimal API }
paths:
/items:
get:
responses:
'204': { description: Empty }
"#;
let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0];
let codes = operation
.findings
.iter()
.map(|finding| finding.code.as_str())
.collect::<Vec<_>>();
assert!(codes.contains(&"missing_operation_id"));
assert!(codes.contains(&"missing_summary"));
assert!(codes.contains(&"missing_description"));
assert!(codes.contains(&"missing_response_schema"));
fn keeps_references_typed_without_resolving_them() {
let ir = normalize_document(SWAGGER2, &NormalizationConfig::default()).unwrap();
let operation = &ir.operations[0];
assert!(matches!(
operation
.response_schema
.as_ref()
.map(|schema| &schema.kind),
Some(crank_import::rest::NormalizedSchemaKind::Reference { .. })
));
assert!(
operation
.findings
.iter()
.any(|finding| finding.code == "unresolved_reference")
);
}
#[test]
fn expands_json_request_body_object_into_tool_inputs() {
fn preserves_valid_operations_when_another_path_item_is_malformed() {
let document = r#"
openapi: 3.0.3
info: { title: CRM API }
servers:
- url: https://crm.example.test
info: { title: Partial }
paths:
/leads:
post:
operationId: createLead
summary: Создать лид
description: Создает лид в CRM.
tags: [crm]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string }
name: { type: string }
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
id: { type: string }
/valid:
get:
responses: { '200': { description: ok } }
/broken: invalid
"#;
let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0];
let input_fields = &operation.draft.input_schema.fields;
let targets = operation
.draft
.input_mapping
.rules
.iter()
.map(|rule| rule.target.as_str())
.collect::<Vec<_>>();
assert!(input_fields.contains_key("email"));
assert!(input_fields.contains_key("name"));
assert!(targets.contains(&"$.request.body.email"));
assert!(targets.contains(&"$.request.body.name"));
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
assert_eq!(ir.operations.len(), 1);
assert!(ir.findings.iter().any(|finding| {
finding.code == "invalid_path_item"
&& finding.severity == crank_import::rest::ImportFindingSeverity::Error
}));
assert!(ir.findings.iter().any(|finding| {
finding.code == "invalid_path_item"
&& finding.location.pointer == "/paths/~1broken"
&& finding.construct_id.contains("/paths/~1broken")
}));
}
#[test]
fn reports_tool_quality_recommendations_for_imported_operations() {
fn malformed_method_values_are_blockers_while_valid_siblings_survive() {
for (format, document) in [
(
"openapi",
"openapi: 3.0.3\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }",
),
(
"swagger",
"swagger: '2.0'\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }",
),
] {
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
assert_eq!(ir.operations.len(), 1, "{format}");
assert_eq!(ir.operations[0].key, "GET /mixed", "{format}");
let finding = ir
.findings
.iter()
.find(|finding| finding.code == "invalid_operation")
.unwrap();
assert_eq!(finding.severity, ImportFindingSeverity::Error, "{format}");
assert_eq!(finding.location.pointer, "/paths/~1mixed/post", "{format}");
}
}
#[test]
fn rejects_hostile_input_at_configured_limits() {
let document = r#"
openapi: 3.0.3
info: { title: Wide API }
info: { title: Too deep }
paths: {}
"#;
let config = NormalizationConfig {
max_bytes: 10,
..NormalizationConfig::default()
};
assert_eq!(
normalize_document(document, &config),
Err(ImportParseError::LimitExceeded)
);
let unsupported = "openapi: 3.2.0\ninfo: { title: Nope }\npaths: {}\n";
assert_eq!(
normalize_document(unsupported, &NormalizationConfig::default()),
Err(ImportParseError::UnsupportedDocument)
);
}
#[test]
fn checked_in_hostile_regressions_are_bounded_and_redacted() {
// Minimal regression corpus distilled from malformed/fuzzed inputs.
// Assertions deliberately compare typed errors, never parser text.
for document in [
include_str!("fixtures/fuzz-regressions/invalid-root.json"),
include_str!("fixtures/fuzz-regressions/unsupported-version.yaml"),
include_str!("fixtures/fuzz-regressions/wrong-version-shape.yaml"),
] {
let error = normalize_document(document, &NormalizationConfig::default()).unwrap_err();
assert!(matches!(
error,
ImportParseError::InvalidDocument | ImportParseError::UnsupportedDocument
));
assert!(!error.to_string().contains("wrong-root-field"));
}
}
#[test]
fn verified_digest_stays_inside_ir_and_unknown_contract_fails_closed() {
let digest = crank_import::rest::SourceDigest::parse("a".repeat(64)).unwrap();
let ir = crank_import::rest::normalize_verified_document(
include_str!("fixtures/openapi-3.1.json"),
digest,
&NormalizationConfig::default(),
)
.unwrap();
assert_eq!(ir.source_identity.digest.as_str(), "a".repeat(64));
let preview = crank_import::rest::preview_from_ir(&ir);
assert!(
!serde_json::to_string(&preview)
.unwrap()
.contains("source_identity")
);
assert!(
!serde_json::to_string(&preview)
.unwrap()
.contains(&"a".repeat(64))
);
let config = NormalizationConfig {
normalizer_version: "future".to_owned(),
..NormalizationConfig::default()
};
assert_eq!(
normalize_document(include_str!("fixtures/openapi-3.1.json"), &config),
Err(ImportParseError::UnsupportedDocument)
);
}
#[test]
fn yaml_alias_preflight_counts_inline_aliases_but_not_quotes_or_comments() {
let mut document =
"openapi: 3.0.3\ninfo: { title: aliases }\npaths: {}\nitems: [".to_owned();
document.push_str(
&std::iter::repeat_n("*a", 129)
.collect::<Vec<_>>()
.join(", "),
);
document.push(']');
assert_eq!(
normalize_document(&document, &NormalizationConfig::default()),
Err(ImportParseError::LimitExceeded)
);
let harmless = "openapi: 3.0.3\ninfo: { title: '*not_alias' } # *also_not_alias\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }";
assert!(normalize_document(harmless, &NormalizationConfig::default()).is_ok());
}
#[test]
fn yaml_alias_preflight_keeps_quote_state_after_escaped_and_doubled_quotes() {
let mut escaped_quote = String::from(
"openapi: 3.0.3\ninfo:\n title: \"escaped \\\" quote\"\npaths: {}\nitems: [",
);
escaped_quote.push_str(
&std::iter::repeat_n("*alias", 129)
.collect::<Vec<_>>()
.join(", "),
);
escaped_quote.push(']');
assert_eq!(
normalize_document(&escaped_quote, &NormalizationConfig::default()),
Err(ImportParseError::LimitExceeded)
);
let harmless = r#"openapi: 3.0.3
info:
title: 'it''s *not_an_alias'
description: "escaped \" *also_not_an_alias"
paths:
/items:
/ok:
get:
responses: { '200': { description: ok } }
"#;
assert!(normalize_document(harmless, &NormalizationConfig::default()).is_ok());
}
#[test]
fn yaml_alias_preflight_fails_closed_for_stars_in_block_scalars() {
let hidden_alias = r#"openapi: 3.0.3
info:
title: Block scalar
description: |
this text contains *an_alias_like_token
paths:
/ok:
get:
responses: { '200': { description: ok } }
"#;
assert_eq!(
normalize_document(hidden_alias, &NormalizationConfig::default()),
Err(ImportParseError::LimitExceeded)
);
let safe_block = r#"openapi: 3.0.3
info:
title: Block scalar
description: |
this text contains no alias-looking marker
paths:
/ok:
get:
responses: { '200': { description: ok } }
"#;
assert!(normalize_document(safe_block, &NormalizationConfig::default()).is_ok());
}
#[test]
fn typed_scalar_literals_round_trip_without_stringification() {
let document = r#"
openapi: 3.1.0
info: { title: Scalars }
paths:
/value:
get:
operationId: getItems
summary: Get items
description: Get items.
parameters:
- name: page
in: query
schema: { type: integer }
responses:
'200':
description: OK
description: ok
content:
application/json:
schema:
type: object
properties:
field01: { type: string }
field02: { type: string }
field03: { type: string }
field04: { type: string }
field05: { type: string }
field06: { type: string }
field07: { type: string }
field08: { type: string }
field09: { type: string }
field10: { type: string }
field11: { type: string }
field12: { type: string }
field13: { type: string }
count: { type: integer, default: 7, enum: [1, 2] }
enabled: { type: boolean, default: true, enum: [true, false] }
ratio: { type: number, default: 1.5, enum: [0.5, 1.5] }
"#;
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0];
let codes = operation
let fields = &preview.groups[0].operations[0].draft.output_schema.fields;
assert_eq!(fields["count"].kind, crank_schema::SchemaKind::Integer);
assert_eq!(fields["enabled"].kind, crank_schema::SchemaKind::Boolean);
let rendered = serde_json::to_value(&ir.operations[0].response_schema).unwrap();
assert!(rendered.to_string().contains("integer"));
assert!(rendered.to_string().contains("1.5"));
}
#[test]
fn coverage_validator_rejects_nested_gap_and_duplicate_findings() {
let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap();
let nested = ir.operations[0]
.response_schema
.as_ref()
.unwrap()
.construct_id
.clone();
ir.coverage.retain(|entry| entry.construct_id != nested);
assert_eq!(
crank_import::rest::validate_normalized_ir(&ir),
Err(ImportParseError::InvalidDocument)
);
let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap();
ir.coverage
.retain(|entry| entry.construct_id != "source:/paths/~1ok/get/responses/200");
assert_eq!(
crank_import::rest::validate_normalized_ir(&ir),
Err(ImportParseError::InvalidDocument)
);
let ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap();
let findings = &ir.operations[0].findings;
assert_eq!(
findings.len(),
findings
.iter()
.map(|finding| (&finding.code, &finding.construct_id))
.collect::<std::collections::BTreeSet<_>>()
.len()
);
}
#[test]
fn two_malformed_paths_keep_distinct_internal_locations() {
let ir = normalize_document(
"openapi: 3.0.3\ninfo: { title: broken }\npaths: { /one: bad, /two: bad }",
&NormalizationConfig::default(),
)
.unwrap_err();
assert_eq!(ir, ImportParseError::NoMethods);
// Structural parser findings require at least one valid operation to return IR.
let ir = normalize_document("openapi: 3.0.3\ninfo: { title: broken }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } }, /one: bad, /two: bad }", &NormalizationConfig::default()).unwrap();
let pointers = ir
.findings
.iter()
.map(|finding| finding.code.as_str())
.collect::<Vec<_>>();
.filter(|finding| finding.code == "invalid_path_item")
.map(|finding| finding.location.pointer.as_str())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(
pointers,
std::collections::BTreeSet::from(["/paths/~1one", "/paths/~1two"])
);
}
assert!(codes.contains(&"parameter_descriptions_missing"));
assert!(codes.contains(&"weak_tool_description"));
assert!(codes.contains(&"weak_tool_name"));
assert!(codes.contains(&"too_many_output_fields"));
#[test]
fn canonical_matrix_covers_all_supported_versions_syntaxes_and_concurrency() {
let matrix = [
(
"oas30-yaml",
"openapi: 3.0.3\ninfo: { title: OAS30 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
),
(
"oas30-json",
r#"{"openapi":"3.0.3","info":{"title":"OAS30"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
),
(
"oas31-yaml",
"openapi: 3.1.0\ninfo: { title: OAS31 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
),
(
"oas31-json",
r#"{"openapi":"3.1.0","info":{"title":"OAS31"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
),
(
"swagger-yaml",
"swagger: '2.0'\ninfo: { title: Swagger }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
),
(
"swagger-json",
r#"{"swagger":"2.0","info":{"title":"Swagger"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
),
];
for (name, source) in matrix {
let first = normalize_document(source, &NormalizationConfig::default()).unwrap();
let second = normalize_document(source, &NormalizationConfig::default()).unwrap();
assert_eq!(
serde_json::to_vec(&first).unwrap(),
serde_json::to_vec(&second).unwrap(),
"{name}"
);
}
let source = std::sync::Arc::new(matrix_source());
let results = (0..8)
.map(|_| {
let source = std::sync::Arc::clone(&source);
std::thread::spawn(move || {
serde_json::to_vec(
&normalize_document(&source, &NormalizationConfig::default()).unwrap(),
)
.unwrap()
})
})
.map(|thread| thread.join().unwrap())
.collect::<Vec<_>>();
assert!(results.windows(2).all(|pair| pair[0] == pair[1]));
}
#[test]
fn every_small_path_order_permutation_has_the_same_canonical_ir() {
let paths = [
("/a", "get", "200"),
("/b", "post", "201"),
("/c", "delete", "204"),
];
let permutations = [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
];
let mut canonical = None;
for permutation in permutations {
let path_entries = permutation
.into_iter()
.map(|index| {
let (path, method, status) = paths[index];
format!(
"\"{path}\":{{\"{method}\":{{\"responses\":{{\"{status}\":{{\"description\":\"ok\"}}}}}}}}"
)
})
.collect::<Vec<_>>()
.join(",");
let source = format!(
"{{\"openapi\":\"3.1.0\",\"info\":{{\"title\":\"Permutation\"}},\"paths\":{{{path_entries}}}}}"
);
let bytes = serde_json::to_vec(
&normalize_document(&source, &NormalizationConfig::default()).unwrap(),
)
.unwrap();
if let Some(expected) = &canonical {
assert_eq!(&bytes, expected);
} else {
canonical = Some(bytes);
}
}
}
#[test]
fn limit_and_reference_regression_matrix_is_bounded_and_typed() {
let source = matrix_source();
for config in [
NormalizationConfig {
max_bytes: 1,
..NormalizationConfig::default()
},
NormalizationConfig {
max_depth: 1,
..NormalizationConfig::default()
},
NormalizationConfig {
max_nodes: 1,
..NormalizationConfig::default()
},
NormalizationConfig {
max_collection_items: 1,
..NormalizationConfig::default()
},
NormalizationConfig {
max_scalar_bytes: 1,
..NormalizationConfig::default()
},
] {
assert_eq!(
normalize_document(&source, &config),
Err(ImportParseError::LimitExceeded)
);
}
assert_eq!(
normalize_document(
"openapi: 3.0.3\ninfo: { title: none }\npaths: {}",
&NormalizationConfig::default()
),
Err(ImportParseError::NoMethods)
);
assert_eq!(
normalize_document("[]", &NormalizationConfig::default()),
Err(ImportParseError::UnsupportedDocument)
);
for reference in [
"#/components/schemas/Loop",
"https://example.test/schema.json",
"#/components/schemas/Loop",
] {
let document = format!(
"openapi: 3.0.3\ninfo: {{ title: refs }}\npaths:\n /ok:\n get:\n responses:\n '200': {{ description: ok, content: {{ application/json: {{ schema: {{ $ref: '{reference}' }} }} }} }}"
);
let ir = normalize_document(&document, &NormalizationConfig::default()).unwrap();
assert!(matches!(
ir.operations[0]
.response_schema
.as_ref()
.map(|schema| &schema.kind),
Some(crank_import::rest::NormalizedSchemaKind::Reference { .. })
));
}
}
}