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
+108 -18
View File
@@ -6,7 +6,8 @@ use crank_core::{
WorkspaceId, WorkspaceId,
}; };
use crank_import::rest::{ use crank_import::rest::{
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate, ImportFinding, ImportFindingSeverity, ImportOperationCandidate, NORMALIZER_VERSION,
PROJECTION_VERSION, operation_draft_from_candidate,
}; };
use crank_registry::{ use crank_registry::{
ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity, ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity,
@@ -94,14 +95,22 @@ impl AdminService {
detach_guard.detach_now().await; detach_guard.detach_now().await;
return Err(ApiError::openapi_upload(locale, "source_integrity")); return Err(ApiError::openapi_upload(locale, "source_integrity"));
} }
let preview = match parse_verified_preview(verified.bytes, locale).await { let parsed = match parse_verified_preview(
verified.bytes,
artifact.artifact_ref().digest_hex().to_owned(),
locale,
false,
)
.await
{
Ok(preview) => preview, Ok(preview) => preview,
Err(error) => { Err(error) => {
detach_guard.detach_now().await; detach_guard.detach_now().await;
return Err(error); return Err(error);
} }
}; };
if preview if parsed
.preview
.groups .groups
.iter() .iter()
.all(|group| group.operations.is_empty()) .all(|group| group.operations.is_empty())
@@ -113,7 +122,7 @@ impl AdminService {
source_id, source_id,
digest: artifact.artifact_ref().clone(), digest: artifact.artifact_ref().clone(),
}; };
let preview_value = serde_json::to_value(&preview) let preview_value = serde_json::to_value(&parsed.preview)
.map_err(|error| ApiError::internal(error.to_string()))?; .map_err(|error| ApiError::internal(error.to_string()))?;
let preview_digest = preview_digest(&preview_value)?; let preview_digest = preview_digest(&preview_value)?;
let preview_payload = json!({ let preview_payload = json!({
@@ -123,6 +132,11 @@ impl AdminService {
}, },
"preview": preview_value, "preview": preview_value,
"preview_digest": preview_digest, "preview_digest": preview_digest,
"normalization": {
"normalizer_version": NORMALIZER_VERSION,
"projection_version": PROJECTION_VERSION,
"ir_fingerprint": parsed.ir_fingerprint,
},
}); });
if let Err(error) = self if let Err(error) = self
@@ -131,8 +145,8 @@ impl AdminService {
id: &job_id, id: &job_id,
workspace_id, workspace_id,
kind: ImportJobKind::OpenApi, kind: ImportJobKind::OpenApi,
source_format: &preview.source.format, source_format: &parsed.preview.source.format,
source_version: preview.source.version.as_deref(), source_version: parsed.preview.source.version.as_deref(),
status: ImportJobStatus::Pending, status: ImportJobStatus::Pending,
source: &source_envelope, source: &source_envelope,
preview_payload: &preview_payload, preview_payload: &preview_payload,
@@ -151,7 +165,7 @@ impl AdminService {
expires_at: expires_at expires_at: expires_at
.format(&Rfc3339) .format(&Rfc3339)
.map_err(|error| ApiError::internal(error.to_string()))?, .map_err(|error| ApiError::internal(error.to_string()))?,
preview, preview: parsed.preview,
}) })
} }
@@ -277,10 +291,20 @@ impl AdminService {
if verified.source.blob.artifact_ref != source.digest { if verified.source.blob.artifact_ref != source.digest {
return Err(ApiError::openapi_upload(locale, "source_integrity")); return Err(ApiError::openapi_upload(locale, "source_integrity"));
} }
let preview = parse_verified_preview(verified.bytes, locale).await?; let legacy_v1 = job.preview_payload.get("normalization").is_none();
verify_preview_contract(&job.preview_payload, &preview, locale)?; let parsed = parse_verified_preview(
verified.bytes,
source.digest.digest_hex().to_owned(),
locale,
legacy_v1,
)
.await?;
verify_preview_contract(&job.preview_payload, &parsed, locale)?;
if preview_has_blocker(&parsed.preview) {
return Err(ApiError::openapi_upload(locale, "invalid_document"));
}
let mut candidates = BTreeMap::new(); let mut candidates = BTreeMap::new();
for group in &preview.groups { for group in &parsed.preview.groups {
for operation in &group.operations { for operation in &group.operations {
candidates.insert(operation.key.clone(), operation); candidates.insert(operation.key.clone(), operation);
} }
@@ -441,10 +465,17 @@ fn validate_openapi_upload(upload: &OpenApiUpload) -> Result<(), ApiError> {
Ok(()) Ok(())
} }
struct ParsedOpenApiPreview {
preview: crank_import::rest::ImportPreview,
ir_fingerprint: String,
}
async fn parse_verified_preview( async fn parse_verified_preview(
bytes: Vec<u8>, bytes: Vec<u8>,
digest: String,
locale: OpenApiUploadLocale, locale: OpenApiUploadLocale,
) -> Result<crank_import::rest::ImportPreview, ApiError> { legacy_v1: bool,
) -> Result<ParsedOpenApiPreview, ApiError> {
let started = tokio::time::Instant::now(); let started = tokio::time::Instant::now();
let permit = tokio::time::timeout(OPENAPI_PARSE_DEADLINE, OPENAPI_PARSE_SLOTS.acquire()) let permit = tokio::time::timeout(OPENAPI_PARSE_DEADLINE, OPENAPI_PARSE_SLOTS.acquire())
.await .await
@@ -457,8 +488,32 @@ async fn parse_verified_preview(
let _permit = permit; let _permit = permit;
let document = std::str::from_utf8(&bytes) let document = std::str::from_utf8(&bytes)
.map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?; .map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?;
crank_import::rest::preview_document(document) if legacy_v1 {
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document")) return crank_import::rest::preview_document_legacy_v1(document)
.map(|preview| ParsedOpenApiPreview {
preview,
ir_fingerprint: String::new(),
})
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"));
}
let digest = crank_import::rest::SourceDigest::parse(digest)
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
let ir =
crank_import::rest::normalize_verified_document(document, digest, &Default::default())
.map_err(|error| match error {
crank_import::rest::ImportParseError::NoMethods => {
ApiError::openapi_upload(locale, "no_methods")
}
_ => ApiError::openapi_upload(locale, "invalid_document"),
})?;
let ir_fingerprint = preview_digest(
&serde_json::to_value(&ir)
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"))?,
)?;
Ok::<_, ApiError>(ParsedOpenApiPreview {
preview: crank_import::rest::preview_from_ir(&ir),
ir_fingerprint,
})
}); });
let remaining = OPENAPI_PARSE_DEADLINE let remaining = OPENAPI_PARSE_DEADLINE
.checked_sub(started.elapsed()) .checked_sub(started.elapsed())
@@ -499,28 +554,63 @@ fn preview_digest(preview: &serde_json::Value) -> Result<String, ApiError> {
fn verify_preview_contract( fn verify_preview_contract(
payload: &serde_json::Value, payload: &serde_json::Value,
preview: &crank_import::rest::ImportPreview, parsed: &ParsedOpenApiPreview,
locale: OpenApiUploadLocale, locale: OpenApiUploadLocale,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
// Pre-fingerprint jobs are legacy rolling-upgrade records. They retain the // Pre-fingerprint jobs are legacy rolling-upgrade records. They retain the
// old reparse behavior; new jobs fail closed if parser output drifts or a // old reparse behavior; new jobs fail closed if parser output drifts or a
// persisted preview has been changed. // persisted preview has been changed.
let Some(expected) = payload let expected = payload
.get("preview_digest") .get("preview_digest")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str);
else { if payload.get("normalization").is_some() && expected.is_none() {
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
let Some(expected) = expected else {
return Ok(()); return Ok(());
}; };
let actual = preview_digest( let actual = preview_digest(
&serde_json::to_value(preview).map_err(|error| ApiError::internal(error.to_string()))?, &serde_json::to_value(&parsed.preview)
.map_err(|error| ApiError::internal(error.to_string()))?,
)?; )?;
if actual == expected { if actual == expected {
if let Some(normalization) = payload.get("normalization") {
let normalizer = normalization
.get("normalizer_version")
.and_then(serde_json::Value::as_str);
let projection = normalization
.get("projection_version")
.and_then(serde_json::Value::as_str);
let fingerprint = normalization
.get("ir_fingerprint")
.and_then(serde_json::Value::as_str);
if normalizer != Some(NORMALIZER_VERSION)
|| projection != Some(PROJECTION_VERSION)
|| fingerprint != Some(parsed.ir_fingerprint.as_str())
{
return Err(ApiError::openapi_upload(locale, "source_integrity"));
}
}
Ok(()) Ok(())
} else { } else {
Err(ApiError::openapi_upload(locale, "source_integrity")) Err(ApiError::openapi_upload(locale, "source_integrity"))
} }
} }
fn preview_has_blocker(preview: &crank_import::rest::ImportPreview) -> bool {
preview
.findings
.iter()
.chain(
preview
.groups
.iter()
.flat_map(|group| group.operations.iter())
.flat_map(|operation| operation.findings.iter()),
)
.any(|finding| finding.severity == ImportFindingSeverity::Error)
}
fn import_job_source( fn import_job_source(
payload: &serde_json::Value, payload: &serde_json::Value,
locale: OpenApiUploadLocale, locale: OpenApiUploadLocale,
@@ -60,12 +60,26 @@ async fn previews_openapi_and_creates_draft_operations() {
preview.preview.groups[0].operations[0].suggested_name, preview.preview.groups[0].operations[0].suggested_name,
"latest_rates" "latest_rates"
); );
let public_response = serde_json::to_string(&preview).unwrap();
assert!(!public_response.contains("normalizer_version"));
assert!(!public_response.contains("projection_version"));
assert!(!public_response.contains("ir_fingerprint"));
assert!(!public_response.contains("source_identity"));
let preview_job = registry let preview_job = registry
.get_import_job(&workspace_id, &preview.job_id.as_str().into()) .get_import_job(&workspace_id, &preview.job_id.as_str().into())
.await .await
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_eq!(preview_job.status, ImportJobStatus::Pending); assert_eq!(preview_job.status, ImportJobStatus::Pending);
assert_eq!(
preview_job.preview_payload["normalization"]["normalizer_version"],
"normalized-ir-v2"
);
assert_eq!(
preview_job.preview_payload["normalization"]["projection_version"],
"preview-v2"
);
assert!(preview_job.preview_payload["normalization"]["ir_fingerprint"].is_string());
let created = service let created = service
.create_openapi_import( .create_openapi_import(
@@ -321,6 +335,251 @@ async fn apply_fails_closed_when_the_preview_parser_contract_drifts() {
); );
} }
#[tokio::test]
#[serial]
async fn apply_fails_closed_for_each_normalization_contract_field_and_digest_shape() {
let registry = test_registry().await;
let service = test_service(
registry.clone(),
test_storage_root("openapi_import_contract_fields"),
test_auth_settings(),
test_secret_crypto(),
);
let workspace_id = WorkspaceId::new("ws_default");
for case in [
"normalizer_version",
"projection_version",
"ir_fingerprint",
"missing_preview_digest",
"non_string_preview_digest",
] {
let preview = service
.preview_openapi_import(&workspace_id, openapi_upload())
.await
.unwrap();
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
let query = match case {
"normalizer_version" => sqlx::query(
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,normalizer_version}', to_jsonb('future'::text)) where id = $1",
),
"projection_version" => sqlx::query(
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,projection_version}', to_jsonb('future'::text)) where id = $1",
),
"ir_fingerprint" => sqlx::query(
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,ir_fingerprint}', to_jsonb('bad'::text)) where id = $1",
),
"missing_preview_digest" => sqlx::query(
"update import_jobs set preview_payload = preview_payload - 'preview_digest' where id = $1",
),
"non_string_preview_digest" => sqlx::query(
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{preview_digest}', '42'::jsonb) where id = $1",
),
_ => unreachable!(),
};
query
.bind(job_id.as_str())
.execute(registry.pool())
.await
.unwrap();
assert!(
service
.create_openapi_import(
&workspace_id,
&job_id,
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".to_owned()),
conflict_mode: "rename".to_owned(),
}
)
.await
.is_err()
);
assert!(
service
.list_operations(&workspace_id)
.await
.unwrap()
.is_empty()
);
}
}
#[tokio::test]
#[serial]
async fn legacy_job_without_normalization_contract_replays_until_expiry() {
let registry = test_registry().await;
let service = test_service(
registry.clone(),
test_storage_root("openapi_import_legacy_replay"),
test_auth_settings(),
test_secret_crypto(),
);
let workspace_id = WorkspaceId::new("ws_default");
let preview = service
.preview_openapi_import(&workspace_id, openapi_upload())
.await
.unwrap();
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
sqlx::query(
"update import_jobs set preview_payload = preview_payload - 'normalization' where id = $1",
)
.bind(job_id.as_str())
.execute(registry.pool())
.await
.unwrap();
let result = service
.create_openapi_import(
&workspace_id,
&job_id,
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
server_url: Some("https://api.frankfurter.dev".to_owned()),
conflict_mode: "skip".to_owned(),
},
)
.await
.unwrap();
assert_eq!(result.created.len(), 1);
}
#[tokio::test]
#[serial]
async fn blocker_finding_keeps_valid_preview_but_prevents_draft_mutation() {
let registry = test_registry().await;
let service = test_service(
registry,
test_storage_root("openapi_import_blocker"),
test_auth_settings(),
test_secret_crypto(),
);
let workspace_id = WorkspaceId::new("ws_default");
let upload = OpenApiUpload {
bytes: br#"
openapi: 3.0.3
info: { title: Partial }
paths:
/valid:
get:
responses: { '200': { description: ok } }
/broken: not-a-path-item
"#
.to_vec(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
};
let preview = service
.preview_openapi_import(&workspace_id, upload)
.await
.unwrap();
assert_eq!(preview.preview.groups[0].operations.len(), 1);
assert!(
preview
.preview
.findings
.iter()
.any(|finding| finding.severity == crank_import::rest::ImportFindingSeverity::Error)
);
assert!(
service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /valid".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.is_err()
);
assert!(
service
.list_operations(&workspace_id)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]
#[serial]
async fn operation_level_reference_blocker_prevents_draft_mutation() {
let registry = test_registry().await;
let service = test_service(
registry,
test_storage_root("openapi_import_operation_blocker"),
test_auth_settings(),
test_secret_crypto(),
);
let workspace_id = WorkspaceId::new("ws_default");
let upload = OpenApiUpload {
bytes: br#"
openapi: 3.0.3
info: { title: References }
paths:
/referenced:
get:
operationId: referenced
responses:
'200':
description: ok
content:
application/json:
schema: { $ref: '#/components/schemas/Result' }
components:
schemas:
Result: { type: object, properties: { id: { type: string } } }
"#
.to_vec(),
mime_type: "application/yaml".to_owned(),
locale: OpenApiUploadLocale::En,
};
let preview = service
.preview_openapi_import(&workspace_id, upload)
.await
.unwrap();
assert!(
preview.preview.findings.iter().all(|finding| {
finding.severity != crank_import::rest::ImportFindingSeverity::Error
})
);
assert!(
preview.preview.groups[0].operations[0]
.findings
.iter()
.any(|finding| {
finding.code == "unresolved_reference"
&& finding.severity == crank_import::rest::ImportFindingSeverity::Error
})
);
assert!(
service
.create_openapi_import(
&workspace_id,
&preview.job_id.as_str().into(),
OpenApiImportCreatePayload {
selected_operation_keys: vec!["GET /referenced".to_owned()],
server_url: None,
conflict_mode: "skip".to_owned(),
},
)
.await
.is_err()
);
assert!(
service
.list_operations(&workspace_id)
.await
.unwrap()
.is_empty()
);
}
fn openapi_upload() -> OpenApiUpload { fn openapi_upload() -> OpenApiUpload {
OpenApiUpload { OpenApiUpload {
bytes: OPENAPI3.as_bytes().to_vec(), bytes: OPENAPI3.as_bytes().to_vec(),
+12 -3
View File
@@ -2,6 +2,9 @@ mod mapping;
pub mod model; pub mod model;
mod naming; mod naming;
mod normalize; mod normalize;
mod normalize_coverage;
mod normalize_limits;
mod normalize_schema;
mod openapi3; mod openapi3;
mod payload; mod payload;
mod recommendations; mod recommendations;
@@ -10,8 +13,14 @@ mod swagger2;
pub use model::{ pub use model::{
ImportFinding, ImportFindingSeverity, ImportGroupPreview, ImportOperationCandidate, ImportFinding, ImportFindingSeverity, ImportGroupPreview, ImportOperationCandidate,
ImportPreview, ImportSourcePreview, RestImportCandidate, RestImportDocument, ImportPreview, ImportSourcePreview, NORMALIZER_VERSION, NormalizationConfig, NormalizedFinding,
RestImportOperation, RestImportParameter, RestParameterLocation, NormalizedIr, NormalizedOperation, NormalizedParameter, NormalizedReference, NormalizedSchema,
NormalizedSchemaKind, PROJECTION_VERSION, RestImportCandidate, RestImportDocument,
RestImportOperation, RestImportParameter, RestParameterLocation, SourceDigest, SourceIdentity,
SourceLocation, UnresolvedReference,
};
pub use normalize::{
ImportParseError, normalize_verified_document, preview_document, preview_document_legacy_v1,
preview_from_ir, validate_normalized_ir,
}; };
pub use normalize::preview_document;
pub use payload::operation_draft_from_candidate; pub use payload::operation_draft_from_candidate;
+307 -5
View File
@@ -1,9 +1,294 @@
use std::collections::BTreeMap;
use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState}; use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState};
use crank_mapping::MappingSet; use crank_mapping::MappingSet;
use crank_schema::Schema; use crank_schema::Schema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Deserializer, Serialize, de};
use serde_json::Value; use serde_json::Value;
/// The immutable contract used to normalize an OpenAPI source. These names
/// deliberately travel with an import job: changing either contract must not
/// silently reinterpret a pending preview.
pub const NORMALIZER_VERSION: &str = "normalized-ir-v2";
pub const PROJECTION_VERSION: &str = "preview-v2";
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SourceDigest(String);
impl SourceDigest {
pub fn parse(value: impl Into<String>) -> Result<Self, &'static str> {
let value = value.into();
if value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
Ok(Self(value))
} else {
Err("source digest must be a lowercase SHA-256 hex string")
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl<'de> Deserialize<'de> for SourceDigest {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(value).map_err(de::Error::custom)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceIdentity {
pub digest: SourceDigest,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceSyntax {
Json,
Yaml,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalSourceNode {
pub construct_id: String,
pub location: SourceLocation,
pub value: CanonicalSourceValue,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum CanonicalSourceValue {
Null,
Boolean(bool),
Number(String),
String(String),
Array(Vec<CanonicalSourceNode>),
Object(BTreeMap<String, CanonicalSourceNode>),
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedApiMetadata {
pub title: String,
pub version: Option<String>,
pub description: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedPath {
pub construct_id: String,
pub location: SourceLocation,
pub path: String,
pub operation_ids: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizationConfig {
pub normalizer_version: String,
pub projection_version: String,
pub max_bytes: usize,
pub max_depth: usize,
pub max_nodes: usize,
pub max_collection_items: usize,
pub max_aliases: usize,
pub max_scalar_bytes: usize,
}
impl Default for NormalizationConfig {
fn default() -> Self {
Self {
normalizer_version: NORMALIZER_VERSION.to_owned(),
projection_version: PROJECTION_VERSION.to_owned(),
max_bytes: 256 * 1024,
max_depth: 64,
max_nodes: 50_000,
max_collection_items: 10_000,
max_aliases: 128,
max_scalar_bytes: 256 * 1024,
}
}
}
impl NormalizationConfig {
pub fn validate_versions(&self) -> Result<(), &'static str> {
if self.normalizer_version == NORMALIZER_VERSION
&& self.projection_version == PROJECTION_VERSION
{
Ok(())
} else {
Err("unsupported normalization contract version")
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceLocation {
/// RFC 6901 JSON Pointer. It is intentionally the only source location
/// exposed by the normalizer: no excerpts or parser diagnostics leak.
pub pointer: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UnresolvedReference {
pub uri: String,
pub location: SourceLocation,
}
/// An unresolved `$ref` preserved from the decoded source. This is deliberately
/// broader than schema references: path items, reusable parameters and other
/// object-level references remain available to a later resolution phase.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedReference {
pub construct_id: String,
pub uri: String,
pub location: SourceLocation,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct NormalizedFinding {
pub code: String,
pub severity: ImportFindingSeverity,
pub message: String,
pub construct_id: String,
pub location: SourceLocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_key: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CoverageDisposition {
Mapped,
Finding,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CoverageEntry {
pub construct_id: String,
pub location: SourceLocation,
pub disposition: CoverageDisposition,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct NormalizedOperation {
pub stable_id: String,
pub location: SourceLocation,
pub key: String,
pub method: HttpMethod,
pub path: String,
pub operation_id: Option<String>,
pub summary: Option<String>,
pub description: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub parameters: Vec<NormalizedParameter>,
pub request_body_schema: Option<NormalizedSchema>,
pub response_schema: Option<NormalizedSchema>,
#[serde(default)]
pub servers: Vec<String>,
#[serde(default)]
pub findings: Vec<NormalizedFinding>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct NormalizedParameter {
pub name: String,
pub location: RestParameterLocation,
pub required: bool,
pub description: Option<String>,
pub schema: Option<NormalizedSchema>,
pub construct_id: String,
pub source_location: SourceLocation,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct NormalizedSchema {
pub construct_id: String,
pub location: SourceLocation,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub kind: NormalizedSchemaKind,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum NormalizedSchemaKind {
Unknown,
Scalar {
scalar_type: NormalizedScalarKind,
format: Option<String>,
nullable: bool,
default_value: Option<NormalizedLiteral>,
enum_values: Vec<NormalizedLiteral>,
},
Object {
properties: BTreeMap<String, NormalizedSchema>,
required: Vec<String>,
},
Array {
items: Option<Box<NormalizedSchema>>,
},
Reference {
reference: UnresolvedReference,
},
Composition {
operator: String,
variants: Vec<NormalizedSchema>,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NormalizedScalarKind {
String,
Integer,
Number,
Boolean,
Null,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
pub enum NormalizedLiteral {
String(String),
Integer(i64),
Unsigned(u64),
Number(f64),
Boolean(bool),
Null,
}
/// Pure, canonical representation between parsing and preview projection.
/// `operations`, `findings` and `coverage` are sorted before construction.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct NormalizedIr {
pub normalizer_version: String,
pub projection_version: String,
pub source_identity: SourceIdentity,
pub source_syntax: SourceSyntax,
pub source_tree: CanonicalSourceNode,
pub metadata: NormalizedApiMetadata,
#[serde(default)]
pub base_path_candidates: Vec<String>,
#[serde(default)]
pub paths: Vec<NormalizedPath>,
#[serde(default)]
pub unresolved_references: Vec<NormalizedReference>,
pub source: ImportSourcePreview,
#[serde(default)]
pub operations: Vec<NormalizedOperation>,
#[serde(default)]
pub findings: Vec<NormalizedFinding>,
#[serde(default)]
pub coverage: Vec<CoverageEntry>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ImportFindingSeverity { pub enum ImportFindingSeverity {
@@ -80,7 +365,7 @@ pub struct RestImportCandidate {
pub wizard_state: Option<WizardState>, pub wizard_state: Option<WizardState>,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RestImportDocument { pub struct RestImportDocument {
pub format: String, pub format: String,
pub version: Option<String>, pub version: Option<String>,
@@ -88,9 +373,11 @@ pub struct RestImportDocument {
pub servers: Vec<String>, pub servers: Vec<String>,
pub operations: Vec<RestImportOperation>, pub operations: Vec<RestImportOperation>,
pub findings: Vec<ImportFinding>, pub findings: Vec<ImportFinding>,
#[serde(skip)]
pub internal_finding_locations: Vec<Option<SourceLocation>>,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RestImportOperation { pub struct RestImportOperation {
pub key: String, pub key: String,
pub method: HttpMethod, pub method: HttpMethod,
@@ -101,21 +388,36 @@ pub struct RestImportOperation {
pub tags: Vec<String>, pub tags: Vec<String>,
pub parameters: Vec<RestImportParameter>, pub parameters: Vec<RestImportParameter>,
pub request_body_schema: Option<Value>, pub request_body_schema: Option<Value>,
#[serde(skip)]
pub request_body_schema_location: Option<SourceLocation>,
pub response_schema: Option<Value>, pub response_schema: Option<Value>,
#[serde(skip)]
pub response_schema_location: Option<SourceLocation>,
pub servers: Vec<String>, pub servers: Vec<String>,
pub findings: Vec<ImportFinding>, pub findings: Vec<ImportFinding>,
} }
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RestImportParameter { pub struct RestImportParameter {
pub name: String, pub name: String,
pub location: RestParameterLocation, pub location: RestParameterLocation,
pub required: bool, pub required: bool,
pub description: Option<String>, pub description: Option<String>,
pub schema: Option<Value>, pub schema: Option<Value>,
#[serde(skip, default = "empty_source_location")]
pub source_location: SourceLocation,
#[serde(skip, default)]
pub schema_source_location: Option<SourceLocation>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] fn empty_source_location() -> SourceLocation {
SourceLocation {
pointer: String::new(),
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RestParameterLocation { pub enum RestParameterLocation {
Path, Path,
Query, Query,
+782 -32
View File
@@ -4,43 +4,156 @@ use serde_json::Value;
use thiserror::Error; use thiserror::Error;
use crate::rest::{ use crate::rest::{
model::{ImportGroupPreview, ImportPreview, RestImportDocument}, model::{
CoverageDisposition, CoverageEntry, ImportFinding, ImportFindingSeverity,
ImportGroupPreview, ImportPreview, ImportSourcePreview, NORMALIZER_VERSION,
NormalizationConfig, NormalizedApiMetadata, NormalizedFinding, NormalizedIr,
NormalizedLiteral, NormalizedOperation, NormalizedParameter, NormalizedScalarKind,
NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION, RestImportDocument,
RestImportOperation, RestImportParameter, SourceDigest, SourceIdentity, SourceLocation,
SourceSyntax,
},
openapi3, openapi3,
payload::candidate_from_operation, payload::candidate_from_operation,
swagger2, swagger2,
}; };
#[derive(Debug, Error)] use super::normalize_coverage;
use super::normalize_limits;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ImportParseError { pub enum ImportParseError {
#[error("document is not valid YAML or JSON: {0}")] #[error("document is not valid YAML or JSON")]
InvalidDocument(String), InvalidDocument,
#[error("unsupported OpenAPI document")] #[error("unsupported OpenAPI document")]
UnsupportedDocument, UnsupportedDocument,
#[error("OpenAPI document exceeds normalization limits")]
LimitExceeded,
#[error("OpenAPI document contains no supported operations")]
NoMethods,
} }
pub fn preview_document(document: &str) -> Result<ImportPreview, ImportParseError> { pub fn preview_document(document: &str) -> Result<ImportPreview, ImportParseError> {
let yaml: serde_yaml::Value = serde_yaml::from_str(document) let digest = SourceDigest::parse("0".repeat(64)).expect("fixed digest is valid");
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?; let ir = normalize_verified_document(document, digest, &NormalizationConfig::default())?;
let root = serde_json::to_value(yaml) Ok(preview_from_ir(&ir))
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?; }
let normalized = if root.get("openapi").is_some() { /// Compatibility projection for jobs created before `NormalizedIr`. It is
openapi3::parse_document(&root)? /// deliberately isolated from the v2 pipeline and may be removed only after
} else if root.get("swagger").and_then(Value::as_str) == Some("2.0") { /// the import-job TTL has elapsed.
swagger2::parse_document(&root)? pub fn preview_document_legacy_v1(document: &str) -> Result<ImportPreview, ImportParseError> {
let root = decode_legacy_v1(document)?;
let mut parsed = match root.get("openapi") {
Some(_) => openapi3::parse_document_legacy_v1(&root)?,
None if root.get("swagger").and_then(Value::as_str) == Some("2.0") => {
swagger2::parse_document_legacy_v1(&root)?
}
None => return Err(ImportParseError::UnsupportedDocument),
};
for operation in &mut parsed.operations {
for parameter in &mut operation.parameters {
if let Some(schema) = parameter.schema.take() {
parameter.schema = Some(resolve_local_ref(&root, &schema, 0));
}
}
if let Some(schema) = operation.request_body_schema.take() {
operation.request_body_schema = Some(resolve_local_ref(&root, &schema, 0));
}
if let Some(schema) = operation.response_schema.take() {
operation.response_schema = Some(resolve_local_ref(&root, &schema, 0));
}
}
Ok(preview_from_document(parsed))
}
fn decode_legacy_v1(document: &str) -> Result<Value, ImportParseError> {
let config = NormalizationConfig::default();
if document.len() > config.max_bytes
|| normalize_limits::alias_count(document) > config.max_aliases
{
return Err(ImportParseError::LimitExceeded);
}
let root = decode(document)?;
validate_value_limits(&root, &config)?;
Ok(root)
}
pub fn normalize_verified_document(
document: &str,
digest: SourceDigest,
config: &NormalizationConfig,
) -> Result<NormalizedIr, ImportParseError> {
config
.validate_versions()
.map_err(|_| ImportParseError::UnsupportedDocument)?;
if document.len() > config.max_bytes
|| normalize_limits::alias_count(document) > config.max_aliases
{
return Err(ImportParseError::LimitExceeded);
}
let source_syntax = if serde_json::from_str::<Value>(document).is_ok() {
SourceSyntax::Json
} else { } else {
return Err(ImportParseError::UnsupportedDocument); SourceSyntax::Yaml
};
let root = decode(document)?;
validate_value_limits(&root, config)?;
let parsed = match root.get("openapi").and_then(Value::as_str) {
Some(version) if supported_oas_version(version) => openapi3::parse_document(&root)?,
Some(_) => return Err(ImportParseError::UnsupportedDocument),
None if root.get("swagger").and_then(Value::as_str) == Some("2.0") => {
swagger2::parse_document(&root)?
}
None => return Err(ImportParseError::UnsupportedDocument),
}; };
Ok(preview_from_document(normalized)) if parsed.operations.is_empty() {
return Err(ImportParseError::NoMethods);
}
canonicalize(parsed, digest, config, root, source_syntax)
}
pub fn preview_from_ir(ir: &NormalizedIr) -> ImportPreview {
let operations = ir
.operations
.iter()
.map(legacy_operation_from_normalized)
.collect::<Vec<_>>();
let findings = ir
.findings
.iter()
.map(|finding| ImportFinding {
code: finding.code.clone(),
severity: finding.severity.clone(),
message: finding.message.clone(),
operation_key: finding.operation_key.clone(),
})
.collect();
preview_from_operations(&ir.source, operations.iter(), findings)
} }
fn preview_from_document(document: RestImportDocument) -> ImportPreview { fn preview_from_document(document: RestImportDocument) -> ImportPreview {
let source = ImportSourcePreview {
format: document.format,
version: document.version,
title: document.title,
servers: document.servers,
};
preview_from_operations(&source, document.operations.iter(), document.findings)
}
fn preview_from_operations<'a>(
source: &ImportSourcePreview,
operations: impl Iterator<Item = &'a crate::rest::model::RestImportOperation>,
mut findings: Vec<ImportFinding>,
) -> ImportPreview {
let mut groups: BTreeMap<String, ImportGroupPreview> = BTreeMap::new(); let mut groups: BTreeMap<String, ImportGroupPreview> = BTreeMap::new();
let mut used_names = BTreeSet::new(); let mut used_names = BTreeSet::new();
for operation in &document.operations { for operation in operations {
let candidate = candidate_from_operation(operation, &document.servers, &mut used_names); let candidate = candidate_from_operation(operation, &source.servers, &mut used_names);
let group_title = operation let group_title = operation
.tags .tags
.first() .first()
@@ -58,36 +171,674 @@ fn preview_from_document(document: RestImportDocument) -> ImportPreview {
.push(candidate); .push(candidate);
} }
sort_findings(&mut findings);
ImportPreview { ImportPreview {
source: crate::rest::model::ImportSourcePreview { source: source.clone(),
groups: groups.into_values().collect(),
findings,
}
}
fn decode(document: &str) -> Result<Value, ImportParseError> {
if let Ok(json) = serde_json::from_str(document) {
return Ok(json);
}
let yaml: serde_yaml::Value =
serde_yaml::from_str(document).map_err(|_| ImportParseError::InvalidDocument)?;
serde_json::to_value(yaml).map_err(|_| ImportParseError::InvalidDocument)
}
fn supported_oas_version(version: &str) -> bool {
["3.0.", "3.1."].into_iter().any(|prefix| {
version.strip_prefix(prefix).is_some_and(|patch| {
!patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit())
})
})
}
fn canonicalize(
document: RestImportDocument,
digest: SourceDigest,
config: &NormalizationConfig,
root: Value,
source_syntax: SourceSyntax,
) -> Result<NormalizedIr, ImportParseError> {
let source = ImportSourcePreview {
format: document.format, format: document.format,
version: document.version, version: document.version,
title: document.title, title: document.title,
servers: document.servers, servers: document.servers,
};
let version = source.version.as_deref().unwrap_or("unknown");
let mut coverage = Vec::new();
let source_tree = normalize_coverage::canonical_source_tree(&root, "", &mut coverage);
let unresolved_references = normalize_coverage::references_from_source_tree(&source_tree);
coverage.extend(unresolved_references.iter().map(|reference| CoverageEntry {
construct_id: reference.construct_id.clone(),
location: reference.location.clone(),
disposition: CoverageDisposition::Mapped,
}));
coverage.push(CoverageEntry {
construct_id: "document".to_owned(),
location: SourceLocation {
pointer: String::new(),
}, },
groups: groups.into_values().collect(), disposition: CoverageDisposition::Mapped,
findings: document.findings, });
coverage.extend(normalize_coverage::server_coverage(
&source,
&source_tree,
version,
)?);
let mut covered_paths = BTreeSet::new();
let mut covered_parameters = BTreeSet::new();
let internal_finding_locations = document.internal_finding_locations.clone();
let mut findings = document
.findings
.into_iter()
.enumerate()
.map(|(index, finding)| {
let location = internal_finding_locations.get(index).cloned().flatten();
NormalizedFinding {
code: finding.code.clone(),
severity: finding.severity,
message: finding.message,
construct_id: location
.as_ref()
.map(|location| {
if location.pointer.is_empty() {
"document".to_owned()
} else {
format!("{version}:{}", location.pointer)
}
})
.unwrap_or_else(|| "document".to_owned()),
location: location.unwrap_or(SourceLocation {
pointer: String::new(),
}),
operation_key: finding.operation_key,
}
})
.collect::<Vec<_>>();
let mut operations = document
.operations
.into_iter()
.map(|operation| {
let method = method_name(operation.method).to_ascii_lowercase();
let pointer = format!(
"/paths/{}/{}",
normalize_coverage::escape_pointer(&operation.path),
method
);
let stable_id = format!("{version}:{method}:{pointer}");
let location = SourceLocation {
pointer: pointer.clone(),
};
let mut operation_findings = Vec::new();
if covered_paths.insert(operation.path.clone()) {
coverage.push(CoverageEntry {
construct_id: format!("{version}:path:{}", operation.path),
location: SourceLocation {
pointer: format!(
"/paths/{}",
normalize_coverage::escape_pointer(&operation.path)
),
},
disposition: CoverageDisposition::Mapped,
});
}
if operation
.operation_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
coverage.push(CoverageEntry {
construct_id: format!("{stable_id}:operation_id"),
location: SourceLocation {
pointer: format!("{pointer}/operationId"),
},
disposition: CoverageDisposition::Mapped,
});
} else {
operation_findings.push(NormalizedFinding {
code: "missing_operation_id".to_owned(),
severity: ImportFindingSeverity::Warning,
message: "У метода нет operationId.".to_owned(),
construct_id: format!("{stable_id}:operation_id"),
location: SourceLocation {
pointer: format!("{pointer}/operationId"),
},
operation_key: Some(operation.key.clone()),
});
coverage.push(CoverageEntry {
construct_id: format!("{stable_id}:operation_id"),
location: SourceLocation {
pointer: format!("{pointer}/operationId"),
},
disposition: CoverageDisposition::Finding,
});
}
for (index, location) in
normalize_coverage::tag_locations(&root, &pointer, &operation.tags)?
.into_iter()
.enumerate()
{
coverage.push(CoverageEntry {
construct_id: format!("{stable_id}:tag:{index}"),
location,
disposition: CoverageDisposition::Mapped,
});
}
operation_findings.extend(operation.findings.iter().map(|finding| NormalizedFinding {
code: finding.code.clone(),
severity: finding.severity.clone(),
message: finding.message.clone(),
construct_id: stable_id.clone(),
location: location.clone(),
operation_key: Some(operation.key.clone()),
}));
let parameters = operation
.parameters
.into_iter()
.map(|parameter| {
let parameter_pointer = parameter.source_location.pointer;
let parameter_id = format!(
"parameter:{}",
normalize_coverage::escape_pointer(&parameter_pointer)
);
if covered_parameters.insert(parameter_id.clone()) {
coverage.push(CoverageEntry {
construct_id: parameter_id.clone(),
location: SourceLocation {
pointer: parameter_pointer.clone(),
},
disposition: CoverageDisposition::Mapped,
});
}
NormalizedParameter {
name: parameter.name,
location: parameter.location,
required: parameter.required,
description: parameter.description,
schema: parameter.schema.as_ref().map(|schema| {
normalize_coverage::typed_schema(
schema,
parameter
.schema_source_location
.as_ref()
.map(|location| location.pointer.as_str())
.unwrap_or(&format!("{parameter_pointer}/schema")),
&format!("{parameter_id}:schema"),
&mut coverage,
)
}),
construct_id: parameter_id,
source_location: SourceLocation {
pointer: parameter_pointer,
},
}
})
.collect::<Vec<_>>();
let request_body_schema = operation.request_body_schema.as_ref().map(|schema| {
let schema_location = operation
.request_body_schema_location
.as_ref()
.cloned()
.unwrap_or(SourceLocation {
pointer: format!("{pointer}/requestBody"),
});
normalize_coverage::typed_schema(
schema,
&schema_location.pointer,
&format!(
"{stable_id}:request:{}",
normalize_coverage::escape_pointer(&schema_location.pointer)
),
&mut coverage,
)
});
let response_schema = operation.response_schema.as_ref().map(|schema| {
let schema_location = operation
.response_schema_location
.as_ref()
.cloned()
.unwrap_or(SourceLocation {
pointer: format!("{pointer}/responses"),
});
normalize_coverage::typed_schema(
schema,
&schema_location.pointer,
&format!(
"{stable_id}:response:{}",
normalize_coverage::escape_pointer(&schema_location.pointer)
),
&mut coverage,
)
});
if normalize_coverage::has_reference_schema(request_body_schema.as_ref())
|| normalize_coverage::has_reference_schema(response_schema.as_ref())
|| parameters.iter().any(|parameter| {
normalize_coverage::has_reference_schema(parameter.schema.as_ref())
})
{
operation_findings.push(NormalizedFinding {
code: "unresolved_reference".to_owned(),
severity: ImportFindingSeverity::Error,
message: "Ссылка сохранена в NormalizedIR и будет разрешена отдельным этапом."
.to_owned(),
construct_id: stable_id.clone(),
location: location.clone(),
operation_key: Some(operation.key.clone()),
});
}
coverage.push(CoverageEntry {
construct_id: stable_id.clone(),
location: location.clone(),
disposition: CoverageDisposition::Mapped,
});
Ok::<_, ImportParseError>(NormalizedOperation {
stable_id,
location,
key: operation.key,
method: operation.method,
path: operation.path,
operation_id: operation.operation_id,
summary: operation.summary,
description: operation.description,
tags: operation.tags,
parameters,
request_body_schema,
response_schema,
servers: operation.servers,
findings: operation_findings,
})
})
.collect::<Result<Vec<_>, _>>()?;
operations.sort_by(|left, right| {
(
left.path.as_str(),
method_rank(left.method),
left.location.pointer.as_str(),
)
.cmp(&(
right.path.as_str(),
method_rank(right.method),
right.location.pointer.as_str(),
))
});
for operation in &mut operations {
sort_normalized_findings(&mut operation.findings);
}
let mut operation_id_counts = BTreeMap::new();
for operation in &operations {
if let Some(operation_id) = operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
*operation_id_counts
.entry(operation_id.to_owned())
.or_insert(0usize) += 1;
}
}
for operation in &mut operations {
if let Some(operation_id) = operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
&& operation_id_counts[operation_id] > 1
{
operation.findings.push(NormalizedFinding {
code: "duplicate_operation_id".to_owned(),
severity: ImportFindingSeverity::Warning,
message: "operationId повторяется в документе.".to_owned(),
construct_id: format!("{}:operation_id", operation.stable_id),
location: SourceLocation {
pointer: format!("{}/operationId", operation.location.pointer),
},
operation_key: Some(operation.key.clone()),
});
}
sort_normalized_findings(&mut operation.findings);
}
// Resolution is deliberately deferred. References already represented by
// an operation schema use that operation's blocker; all other references
// need a document-level blocker so omitted object-level semantics can
// never reach Apply silently.
findings.extend(
unresolved_references
.iter()
.filter(|reference| {
!operations.iter().any(|operation| {
operation
.parameters
.iter()
.filter_map(|parameter| parameter.schema.as_ref())
.chain(operation.request_body_schema.iter())
.chain(operation.response_schema.iter())
.any(|schema| schema_contains_reference(schema, &reference.location))
})
})
.map(|reference| NormalizedFinding {
code: "unresolved_reference".to_owned(),
severity: ImportFindingSeverity::Error,
message: "Ссылка требует разрешения перед применением импорта.".to_owned(),
construct_id: reference.construct_id.clone(),
location: reference.location.clone(),
operation_key: None,
}),
);
sort_normalized_findings(&mut findings);
for finding in &findings {
if !coverage.iter().any(|entry| {
entry.construct_id == finding.construct_id
&& entry.location == finding.location
&& entry.disposition == CoverageDisposition::Mapped
}) {
coverage.push(CoverageEntry {
construct_id: finding.construct_id.clone(),
location: finding.location.clone(),
disposition: CoverageDisposition::Finding,
});
}
}
coverage.sort_by(|left, right| {
(
left.construct_id.as_str(),
left.location.pointer.as_str(),
coverage_disposition_rank(&left.disposition),
)
.cmp(&(
right.construct_id.as_str(),
right.location.pointer.as_str(),
coverage_disposition_rank(&right.disposition),
))
});
coverage.dedup();
let ir = NormalizedIr {
normalizer_version: config.normalizer_version.clone(),
projection_version: config.projection_version.clone(),
source_identity: SourceIdentity { digest },
source_syntax,
source_tree,
metadata: NormalizedApiMetadata {
title: source.title.clone(),
version: root
.pointer("/info/version")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: root
.pointer("/info/description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
base_path_candidates: root
.get("basePath")
.and_then(Value::as_str)
.map(|value| vec![value.to_owned()])
.unwrap_or_default(),
paths: normalize_coverage::paths_from_operations(&operations, version),
unresolved_references,
source,
operations,
findings,
coverage,
};
normalize_coverage::validate_coverage(&ir)?;
Ok(ir)
}
pub fn validate_normalized_ir(ir: &NormalizedIr) -> Result<(), ImportParseError> {
if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION {
return Err(ImportParseError::InvalidDocument);
}
normalize_coverage::validate_coverage(ir)
}
fn coverage_disposition_rank(disposition: &CoverageDisposition) -> u8 {
match disposition {
CoverageDisposition::Mapped => 0,
CoverageDisposition::Finding => 1,
} }
} }
fn legacy_operation_from_normalized(operation: &NormalizedOperation) -> RestImportOperation {
RestImportOperation {
key: operation.key.clone(),
method: operation.method,
path: operation.path.clone(),
operation_id: operation.operation_id.clone(),
summary: operation.summary.clone(),
description: operation.description.clone(),
tags: operation.tags.clone(),
parameters: operation
.parameters
.iter()
.map(|parameter| RestImportParameter {
name: parameter.name.clone(),
location: parameter.location,
required: parameter.required,
description: parameter.description.clone(),
schema: parameter.schema.as_ref().map(legacy_schema_value),
source_location: parameter.source_location.clone(),
schema_source_location: None,
})
.collect(),
request_body_schema: operation
.request_body_schema
.as_ref()
.map(legacy_schema_value),
request_body_schema_location: None,
response_schema: operation.response_schema.as_ref().map(legacy_schema_value),
response_schema_location: None,
servers: operation.servers.clone(),
findings: operation
.findings
.iter()
.map(|finding| ImportFinding {
code: finding.code.clone(),
severity: finding.severity.clone(),
message: finding.message.clone(),
operation_key: finding.operation_key.clone(),
})
.collect(),
}
}
fn legacy_schema_value(schema: &NormalizedSchema) -> Value {
let mut value = match &schema.kind {
NormalizedSchemaKind::Reference { reference } => serde_json::json!({"$ref": reference.uri}),
NormalizedSchemaKind::Composition { operator, variants } => {
serde_json::json!({operator: variants.iter().map(legacy_schema_value).collect::<Vec<_>>() })
}
NormalizedSchemaKind::Object {
properties,
required,
} => {
serde_json::json!({"type":"object", "properties": properties.iter().map(|(name, schema)| (name.clone(), legacy_schema_value(schema))).collect::<serde_json::Map<_, _>>(), "required": required})
}
NormalizedSchemaKind::Array { items } => {
let mut value = serde_json::json!({"type":"array"});
if let Some(items) = items {
value["items"] = legacy_schema_value(items);
}
value
}
NormalizedSchemaKind::Scalar {
scalar_type,
format,
nullable,
default_value,
enum_values,
} => {
let scalar_type = match scalar_type {
NormalizedScalarKind::String => "string",
NormalizedScalarKind::Integer => "integer",
NormalizedScalarKind::Number => "number",
NormalizedScalarKind::Boolean => "boolean",
NormalizedScalarKind::Null => "null",
};
serde_json::json!({"type":scalar_type, "format":format, "nullable":nullable, "default":default_value.as_ref().map(legacy_literal_value), "enum":enum_values.iter().map(legacy_literal_value).collect::<Vec<_>>()})
}
NormalizedSchemaKind::Unknown => serde_json::json!({}),
};
if let Some(description) = &schema.description {
value["description"] = Value::String(description.clone());
}
value
}
fn legacy_literal_value(value: &NormalizedLiteral) -> Value {
match value {
NormalizedLiteral::String(value) => Value::String(value.clone()),
NormalizedLiteral::Integer(value) => Value::Number((*value).into()),
NormalizedLiteral::Unsigned(value) => Value::Number((*value).into()),
NormalizedLiteral::Number(value) => serde_json::Number::from_f64(*value)
.map(Value::Number)
.unwrap_or(Value::Null),
NormalizedLiteral::Boolean(value) => Value::Bool(*value),
NormalizedLiteral::Null => Value::Null,
}
}
fn schema_contains_reference(schema: &NormalizedSchema, location: &SourceLocation) -> bool {
match &schema.kind {
NormalizedSchemaKind::Reference { reference } => reference.location == *location,
NormalizedSchemaKind::Object { properties, .. } => properties
.values()
.any(|schema| schema_contains_reference(schema, location)),
NormalizedSchemaKind::Array { items } => items
.as_deref()
.is_some_and(|schema| schema_contains_reference(schema, location)),
NormalizedSchemaKind::Composition { variants, .. } => variants
.iter()
.any(|schema| schema_contains_reference(schema, location)),
_ => false,
}
}
fn validate_value_limits(
value: &Value,
config: &NormalizationConfig,
) -> Result<(), ImportParseError> {
fn visit(
value: &Value,
depth: usize,
nodes: &mut usize,
config: &NormalizationConfig,
) -> Result<(), ImportParseError> {
*nodes += 1;
if depth > config.max_depth || *nodes > config.max_nodes {
return Err(ImportParseError::LimitExceeded);
}
match value {
Value::String(text) if text.len() > config.max_scalar_bytes => {
Err(ImportParseError::LimitExceeded)
}
Value::Array(items) => {
if items.len() > config.max_collection_items {
return Err(ImportParseError::LimitExceeded);
}
for item in items {
visit(item, depth + 1, nodes, config)?;
}
Ok(())
}
Value::Object(object) => {
if object.len() > config.max_collection_items {
return Err(ImportParseError::LimitExceeded);
}
for (key, item) in object {
if key.len() > config.max_scalar_bytes {
return Err(ImportParseError::LimitExceeded);
}
visit(item, depth + 1, nodes, config)?;
}
Ok(())
}
_ => Ok(()),
}
}
visit(value, 0, &mut 0, config)
}
fn method_name(method: crank_core::HttpMethod) -> &'static str {
match method {
crank_core::HttpMethod::Get => "GET",
crank_core::HttpMethod::Post => "POST",
crank_core::HttpMethod::Put => "PUT",
crank_core::HttpMethod::Patch => "PATCH",
crank_core::HttpMethod::Delete => "DELETE",
}
}
fn method_rank(method: crank_core::HttpMethod) -> u8 {
match method {
crank_core::HttpMethod::Get => 0,
crank_core::HttpMethod::Post => 1,
crank_core::HttpMethod::Put => 2,
crank_core::HttpMethod::Patch => 3,
crank_core::HttpMethod::Delete => 4,
}
}
fn severity_rank(severity: &ImportFindingSeverity) -> u8 {
match severity {
ImportFindingSeverity::Error => 0,
ImportFindingSeverity::Warning => 1,
ImportFindingSeverity::Info => 2,
}
}
fn sort_findings(findings: &mut [ImportFinding]) {
findings.sort_by(|left, right| {
(
severity_rank(&left.severity),
left.operation_key.as_deref().unwrap_or(""),
left.code.as_str(),
)
.cmp(&(
severity_rank(&right.severity),
right.operation_key.as_deref().unwrap_or(""),
right.code.as_str(),
))
});
}
fn sort_normalized_findings(findings: &mut [NormalizedFinding]) {
findings.sort_by(|left, right| {
(
severity_rank(&left.severity),
left.construct_id.as_str(),
left.code.as_str(),
)
.cmp(&(
severity_rank(&right.severity),
right.construct_id.as_str(),
right.code.as_str(),
))
});
}
// Kept private for pending pre-v2 job replays. New normalization never calls
// it: refs are represented above and are resolved only in Story 2.3.
#[allow(dead_code)]
pub(crate) fn resolve_local_ref(root: &Value, value: &Value, depth: usize) -> Value { pub(crate) fn resolve_local_ref(root: &Value, value: &Value, depth: usize) -> Value {
if depth > 12 { if depth > 12 {
return value.clone(); return value.clone();
} }
if let Some(reference) = value.get("$ref").and_then(Value::as_str) { if let Some(reference) = value.get("$ref").and_then(Value::as_str)
if let Some(resolved) = pointer(root, reference) { && let Some(resolved) = pointer(root, reference)
{
return resolve_local_ref(root, resolved, depth + 1); return resolve_local_ref(root, resolved, depth + 1);
} }
return value.clone();
}
match value { match value {
Value::Object(map) => { Value::Object(map) => Value::Object(
let mut out = serde_json::Map::new(); map.iter()
for (key, item) in map { .map(|(key, item)| (key.clone(), resolve_local_ref(root, item, depth + 1)))
out.insert(key.clone(), resolve_local_ref(root, item, depth + 1)); .collect(),
} ),
Value::Object(out)
}
Value::Array(items) => Value::Array( Value::Array(items) => Value::Array(
items items
.iter() .iter()
@@ -104,8 +855,7 @@ fn pointer<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
} }
let mut current = root; let mut current = root;
for part in reference.trim_start_matches("#/").split('/') { for part in reference.trim_start_matches("#/").split('/') {
let part = part.replace("~1", "/").replace("~0", "~"); current = current.get(part.replace("~1", "/").replace("~0", "~"))?;
current = current.get(&part)?;
} }
Some(current) Some(current)
} }
@@ -0,0 +1,887 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::rest::model::{
CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition, CoverageEntry,
ImportSourcePreview, NORMALIZER_VERSION, NormalizedIr, NormalizedOperation, NormalizedPath,
NormalizedReference, NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION,
SourceLocation,
};
use super::ImportParseError;
pub(super) use super::normalize_schema::{has_reference_schema, typed_schema};
pub(super) fn validate_coverage(ir: &NormalizedIr) -> Result<(), ImportParseError> {
if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION {
return Err(ImportParseError::InvalidDocument);
}
let mut source_nodes = BTreeMap::new();
index_source_tree(&ir.source_tree, "", &mut source_nodes)?;
let version = validate_source_contract(ir, &source_nodes)?;
let mut expected = BTreeMap::<String, ExpectedCoverage>::new();
for node in source_nodes.values() {
insert_expected(
&mut expected,
node.construct_id.clone(),
node.location.clone(),
CoverageDisposition::Mapped,
)?;
}
insert_real_expected(
&mut expected,
&source_nodes,
"document".to_owned(),
SourceLocation {
pointer: String::new(),
},
CoverageDisposition::Mapped,
)?;
for (index, location) in server_locations(ir, &source_nodes)?.into_iter().enumerate() {
insert_real_expected(
&mut expected,
&source_nodes,
format!("{version}:server:{index}"),
location,
CoverageDisposition::Mapped,
)?;
}
let expected_references = references_from_source_tree(&ir.source_tree);
if ir.unresolved_references != expected_references {
return Err(ImportParseError::InvalidDocument);
}
for reference in &ir.unresolved_references {
insert_real_expected(
&mut expected,
&source_nodes,
reference.construct_id.clone(),
reference.location.clone(),
CoverageDisposition::Mapped,
)?;
}
let expected_paths = paths_from_operations(&ir.operations, &version);
if ir.paths != expected_paths {
return Err(ImportParseError::InvalidDocument);
}
for path in &ir.paths {
insert_real_expected(
&mut expected,
&source_nodes,
path.construct_id.clone(),
path.location.clone(),
CoverageDisposition::Mapped,
)?;
}
validate_operation_order(&ir.operations)?;
for operation in &ir.operations {
collect_operation_expectations(operation, &version, &source_nodes, &mut expected)?;
}
collect_finding_only_expectations(&version, ir, &source_nodes, &mut expected)?;
let findings = ir
.findings
.iter()
.chain(
ir.operations
.iter()
.flat_map(|operation| &operation.findings),
)
.collect::<Vec<_>>();
for finding in &findings {
let Some(target) = expected.get(&finding.construct_id) else {
return Err(ImportParseError::InvalidDocument);
};
if target.location != finding.location {
return Err(ImportParseError::InvalidDocument);
}
}
if expected.iter().any(|(construct_id, target)| {
target.disposition == CoverageDisposition::Finding
&& !findings.iter().any(|finding| {
finding.construct_id == *construct_id && finding.location == target.location
})
}) {
return Err(ImportParseError::InvalidDocument);
}
let mut actual = BTreeMap::new();
for entry in &ir.coverage {
if actual
.insert(
entry.construct_id.clone(),
ExpectedCoverage {
location: entry.location.clone(),
disposition: entry.disposition.clone(),
},
)
.is_some()
{
return Err(ImportParseError::InvalidDocument);
}
}
if actual != expected {
return Err(ImportParseError::InvalidDocument);
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ExpectedCoverage {
location: SourceLocation,
disposition: CoverageDisposition,
}
fn insert_expected(
expected: &mut BTreeMap<String, ExpectedCoverage>,
construct_id: String,
location: SourceLocation,
disposition: CoverageDisposition,
) -> Result<(), ImportParseError> {
let value = ExpectedCoverage {
location,
disposition,
};
if expected
.get(&construct_id)
.is_some_and(|existing| existing != &value)
{
return Err(ImportParseError::InvalidDocument);
}
expected.entry(construct_id).or_insert(value);
Ok(())
}
fn insert_real_expected(
expected: &mut BTreeMap<String, ExpectedCoverage>,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
construct_id: String,
location: SourceLocation,
disposition: CoverageDisposition,
) -> Result<(), ImportParseError> {
if !source_nodes.contains_key(&location.pointer) {
return Err(ImportParseError::InvalidDocument);
}
insert_expected(expected, construct_id, location, disposition)
}
fn index_source_tree<'a>(
node: &'a CanonicalSourceNode,
expected_pointer: &str,
nodes: &mut BTreeMap<String, &'a CanonicalSourceNode>,
) -> Result<(), ImportParseError> {
if node.location.pointer != expected_pointer
|| node.construct_id != format!("source:{expected_pointer}")
|| nodes.insert(expected_pointer.to_owned(), node).is_some()
{
return Err(ImportParseError::InvalidDocument);
}
match &node.value {
CanonicalSourceValue::Array(items) => {
for (index, child) in items.iter().enumerate() {
index_source_tree(child, &format!("{expected_pointer}/{index}"), nodes)?;
}
}
CanonicalSourceValue::Object(items) => {
for (key, child) in items {
index_source_tree(
child,
&format!("{expected_pointer}/{}", escape_pointer(key)),
nodes,
)?;
}
}
_ => {}
}
Ok(())
}
fn validate_source_contract(
ir: &NormalizedIr,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<String, ImportParseError> {
let version = ir
.source
.version
.as_deref()
.ok_or(ImportParseError::InvalidDocument)?;
let valid = match ir.source.format.as_str() {
"openapi" => string_at(nodes, "/openapi") == Some(version) && supported_oas(version),
"swagger" => version == "2.0" && string_at(nodes, "/swagger") == Some("2.0"),
_ => false,
};
if !valid
|| ir.metadata.title != ir.source.title
|| ir.source.title != string_at(nodes, "/info/title").unwrap_or("Imported API")
|| ir.metadata.version != string_at(nodes, "/info/version").map(ToOwned::to_owned)
|| ir.metadata.description != string_at(nodes, "/info/description").map(ToOwned::to_owned)
|| ir.base_path_candidates
!= string_at(nodes, "/basePath")
.map(|value| vec![value.to_owned()])
.unwrap_or_default()
{
return Err(ImportParseError::InvalidDocument);
}
Ok(version.to_owned())
}
fn supported_oas(version: &str) -> bool {
["3.0.", "3.1."].into_iter().any(|prefix| {
version.strip_prefix(prefix).is_some_and(|patch| {
!patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit())
})
})
}
fn string_at<'a>(
nodes: &'a BTreeMap<String, &CanonicalSourceNode>,
pointer: &str,
) -> Option<&'a str> {
match &nodes.get(pointer)?.value {
CanonicalSourceValue::String(value) => Some(value),
_ => None,
}
}
fn server_locations(
ir: &NormalizedIr,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<Vec<SourceLocation>, ImportParseError> {
server_locations_for_source(&ir.source, nodes)
}
fn server_locations_for_source(
source: &ImportSourcePreview,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<Vec<SourceLocation>, ImportParseError> {
let (urls, locations) = if source.format == "openapi" {
openapi_servers(nodes)
} else {
swagger_servers(nodes)
};
if urls != source.servers {
return Err(ImportParseError::InvalidDocument);
}
Ok(locations)
}
pub(super) fn server_coverage(
source: &ImportSourcePreview,
source_tree: &CanonicalSourceNode,
version: &str,
) -> Result<Vec<CoverageEntry>, ImportParseError> {
let mut nodes = BTreeMap::new();
index_source_tree(source_tree, "", &mut nodes)?;
Ok(server_locations_for_source(source, &nodes)?
.into_iter()
.enumerate()
.map(|(index, location)| CoverageEntry {
construct_id: format!("{version}:server:{index}"),
location,
disposition: CoverageDisposition::Mapped,
})
.collect())
}
pub(super) fn tag_locations(
root: &Value,
operation_pointer: &str,
tags: &[String],
) -> Result<Vec<SourceLocation>, ImportParseError> {
let source_tags = root
.pointer(&format!("{operation_pointer}/tags"))
.and_then(Value::as_array);
let locations = source_tags
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, value)| {
value.as_str().map(|value| {
(
value,
SourceLocation {
pointer: format!("{operation_pointer}/tags/{index}"),
},
)
})
})
.collect::<Vec<_>>();
if locations
.iter()
.map(|(value, _)| *value)
.ne(tags.iter().map(String::as_str))
{
return Err(ImportParseError::InvalidDocument);
}
Ok(locations
.into_iter()
.map(|(_, location)| location)
.collect())
}
fn openapi_servers(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> (Vec<String>, Vec<SourceLocation>) {
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::Array(items),
..
}) = nodes.get("/servers").copied()
else {
return (Vec::new(), Vec::new());
};
items
.iter()
.filter_map(|item| {
let CanonicalSourceValue::Object(fields) = &item.value else {
return None;
};
let CanonicalSourceValue::String(url) = &fields.get("url")?.value else {
return None;
};
Some((url.trim_end_matches('/').to_owned(), item.location.clone()))
})
.unzip()
}
fn swagger_servers(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> (Vec<String>, Vec<SourceLocation>) {
let Some(host) = string_at(nodes, "/host") else {
return (Vec::new(), Vec::new());
};
let base_path = string_at(nodes, "/basePath").unwrap_or("");
let schemes = nodes
.get("/schemes")
.and_then(|node| match &node.value {
CanonicalSourceValue::Array(items) => Some(
items
.iter()
.filter_map(|item| match &item.value {
CanonicalSourceValue::String(value) => Some((value.as_str(), item)),
_ => None,
})
.collect::<Vec<_>>(),
),
_ => None,
})
.filter(|items| !items.is_empty());
let schemes = schemes.unwrap_or_else(|| {
vec![(
"https",
*nodes.get("/host").expect("host was checked above"),
)]
});
schemes
.into_iter()
.map(|(scheme, node)| {
(
format!("{scheme}://{host}{base_path}")
.trim_end_matches('/')
.to_owned(),
node.location.clone(),
)
})
.unzip()
}
fn validate_operation_order(operations: &[NormalizedOperation]) -> Result<(), ImportParseError> {
if operations
.windows(2)
.any(|pair| operation_sort_key(&pair[0]) >= operation_sort_key(&pair[1]))
{
return Err(ImportParseError::InvalidDocument);
}
Ok(())
}
fn operation_sort_key(operation: &NormalizedOperation) -> (&str, u8, &str) {
(
&operation.path,
method_rank(operation.method),
&operation.location.pointer,
)
}
fn method_rank(method: crank_core::HttpMethod) -> u8 {
match method {
crank_core::HttpMethod::Get => 0,
crank_core::HttpMethod::Post => 1,
crank_core::HttpMethod::Put => 2,
crank_core::HttpMethod::Patch => 3,
crank_core::HttpMethod::Delete => 4,
}
}
fn method_name(method: crank_core::HttpMethod) -> &'static str {
match method {
crank_core::HttpMethod::Get => "get",
crank_core::HttpMethod::Post => "post",
crank_core::HttpMethod::Put => "put",
crank_core::HttpMethod::Patch => "patch",
crank_core::HttpMethod::Delete => "delete",
}
}
fn collect_operation_expectations(
operation: &NormalizedOperation,
version: &str,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let method = method_name(operation.method);
let pointer = format!("/paths/{}/{method}", escape_pointer(&operation.path));
let stable_id = format!("{version}:{method}:{pointer}");
if operation.location.pointer != pointer
|| operation.stable_id != stable_id
|| operation.key != format!("{} {}", method.to_ascii_uppercase(), operation.path)
{
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
stable_id.clone(),
operation.location.clone(),
CoverageDisposition::Mapped,
)?;
let operation_id_location = SourceLocation {
pointer: format!("{pointer}/operationId"),
};
let operation_id_disposition = match operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
Some(value) if string_at(source_nodes, &operation_id_location.pointer) == Some(value) => {
CoverageDisposition::Mapped
}
Some(_) => return Err(ImportParseError::InvalidDocument),
None => CoverageDisposition::Finding,
};
if operation_id_disposition == CoverageDisposition::Mapped
|| source_nodes.contains_key(&operation_id_location.pointer)
{
insert_real_expected(
expected,
source_nodes,
format!("{stable_id}:operation_id"),
operation_id_location,
operation_id_disposition,
)?;
} else {
insert_expected(
expected,
format!("{stable_id}:operation_id"),
operation_id_location,
CoverageDisposition::Finding,
)?;
}
let tag_locations = normalized_tag_locations(source_nodes, &pointer, &operation.tags)?;
for (index, location) in tag_locations.into_iter().enumerate() {
insert_real_expected(
expected,
source_nodes,
format!("{stable_id}:tag:{index}"),
location,
CoverageDisposition::Mapped,
)?;
}
for parameter in &operation.parameters {
if parameter.construct_id
!= format!(
"parameter:{}",
escape_pointer(&parameter.source_location.pointer)
)
{
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
parameter.construct_id.clone(),
parameter.source_location.clone(),
CoverageDisposition::Mapped,
)?;
if let Some(schema) = &parameter.schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
}
if let Some(schema) = &operation.request_body_schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
if let Some(schema) = &operation.response_schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
Ok(())
}
fn normalized_tag_locations(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
operation_pointer: &str,
tags: &[String],
) -> Result<Vec<SourceLocation>, ImportParseError> {
let tags_pointer = format!("{operation_pointer}/tags");
let source_tags = nodes.get(&tags_pointer).and_then(|node| match &node.value {
CanonicalSourceValue::Array(items) => Some(items),
_ => None,
});
let locations = source_tags
.into_iter()
.flatten()
.filter_map(|node| match &node.value {
CanonicalSourceValue::String(value) => Some((value, node.location.clone())),
_ => None,
})
.collect::<Vec<_>>();
if locations.iter().map(|(value, _)| *value).ne(tags.iter()) {
return Err(ImportParseError::InvalidDocument);
}
Ok(locations
.into_iter()
.map(|(_, location)| location)
.collect())
}
fn collect_schema_expectations(
schema: &NormalizedSchema,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
if schema.construct_id != format!("schema:{}", escape_pointer(&schema.location.pointer)) {
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
schema.construct_id.clone(),
schema.location.clone(),
CoverageDisposition::Mapped,
)?;
if schema.description
!= string_at(
source_nodes,
&format!("{}/description", schema.location.pointer),
)
.map(ToOwned::to_owned)
{
return Err(ImportParseError::InvalidDocument);
}
match &schema.kind {
NormalizedSchemaKind::Object { properties, .. } => {
for child in properties.values() {
collect_schema_expectations(child, source_nodes, expected)?;
}
}
NormalizedSchemaKind::Array { items: Some(child) } => {
collect_schema_expectations(child, source_nodes, expected)?;
}
NormalizedSchemaKind::Composition { variants, .. } => {
for child in variants {
collect_schema_expectations(child, source_nodes, expected)?;
}
}
_ => {}
}
Ok(())
}
fn collect_finding_only_expectations(
version: &str,
ir: &NormalizedIr,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
if ir.source.format == "openapi" && ir.source.servers.len() > 1 {
let location = SourceLocation {
pointer: "/servers".to_owned(),
};
insert_real_expected(
expected,
source_nodes,
format!("{version}:/servers"),
location,
CoverageDisposition::Finding,
)?;
}
if ir.source.format == "openapi"
&& let Some(servers) = source_nodes.get("/servers").copied()
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::Object(paths),
..
}) = source_nodes.get("/paths").copied()
else {
return Err(ImportParseError::InvalidDocument);
};
for path_item in paths.values() {
let CanonicalSourceValue::Object(methods) = &path_item.value else {
insert_finding_pointer(version, path_item, expected)?;
continue;
};
if ir.source.format == "openapi"
&& let Some(servers) = methods.get("servers")
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
if let Some(parameters) = methods.get("parameters") {
collect_dropped_parameter_findings(
version,
parameters,
ir.source.format.as_str(),
expected,
)?;
}
for method in ["head", "options", "trace", "connect"] {
if let Some(node) = methods.get(method) {
insert_finding_pointer(version, node, expected)?;
}
}
for method in ["get", "post", "put", "patch", "delete"] {
if let Some(node) = methods.get(method) {
match &node.value {
CanonicalSourceValue::Object(operation) => {
if ir.source.format == "openapi"
&& let Some(servers) = operation.get("servers")
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
if let Some(tags) = operation.get("tags") {
collect_invalid_tag_findings(version, tags, expected)?;
}
if let Some(parameters) = operation.get("parameters") {
collect_dropped_parameter_findings(
version,
parameters,
ir.source.format.as_str(),
expected,
)?;
}
}
_ => insert_finding_pointer(version, node, expected)?,
}
}
}
}
Ok(())
}
fn collect_invalid_openapi_server_findings(
version: &str,
servers: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &servers.value else {
return Ok(());
};
for server in items {
let valid = matches!(
&server.value,
CanonicalSourceValue::Object(fields)
if matches!(fields.get("url").map(|node| &node.value), Some(CanonicalSourceValue::String(_)))
);
if !valid {
insert_finding_pointer(version, server, expected)?;
}
}
Ok(())
}
fn collect_invalid_tag_findings(
version: &str,
tags: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &tags.value else {
return Ok(());
};
for tag in items {
if !matches!(tag.value, CanonicalSourceValue::String(_)) {
insert_finding_pointer(version, tag, expected)?;
}
}
Ok(())
}
fn collect_dropped_parameter_findings(
version: &str,
parameters: &CanonicalSourceNode,
format: &str,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &parameters.value else {
return Ok(());
};
for parameter in items {
let CanonicalSourceValue::Object(fields) = &parameter.value else {
insert_finding_pointer(version, parameter, expected)?;
continue;
};
if fields.contains_key("$ref") {
insert_finding_pointer(version, parameter, expected)?;
continue;
}
if !matches!(
fields.get("name").map(|node| &node.value),
Some(CanonicalSourceValue::String(_))
) || !matches!(
fields.get("in").map(|node| &node.value),
Some(CanonicalSourceValue::String(_))
) {
insert_finding_pointer(version, parameter, expected)?;
continue;
}
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::String(location),
..
}) = fields.get("in")
else {
return Err(ImportParseError::InvalidDocument);
};
let supported = match format {
"openapi" => matches!(location.as_str(), "path" | "query" | "header"),
"swagger" => matches!(location.as_str(), "path" | "query" | "header" | "body"),
_ => false,
};
if !supported {
insert_finding_pointer(version, parameter, expected)?;
}
}
Ok(())
}
fn insert_finding_pointer(
version: &str,
node: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
insert_expected(
expected,
format!("{version}:{}", node.location.pointer),
node.location.clone(),
CoverageDisposition::Finding,
)
}
pub(super) fn references_from_source_tree(
source_tree: &CanonicalSourceNode,
) -> Vec<NormalizedReference> {
let mut references = Vec::new();
collect_references(source_tree, &mut references);
references.sort_by(|left, right| left.construct_id.cmp(&right.construct_id));
references
}
fn collect_references(node: &CanonicalSourceNode, references: &mut Vec<NormalizedReference>) {
match &node.value {
CanonicalSourceValue::Array(items) => {
for item in items {
collect_references(item, references);
}
}
CanonicalSourceValue::Object(items) => {
if let Some(CanonicalSourceNode {
value: CanonicalSourceValue::String(uri),
location,
..
}) = items.get("$ref")
{
references.push(NormalizedReference {
construct_id: format!("reference:{}", escape_pointer(&location.pointer)),
uri: uri.clone(),
location: location.clone(),
});
}
for item in items.values() {
collect_references(item, references);
}
}
_ => {}
}
}
pub(super) fn canonical_source_tree(
value: &Value,
pointer: &str,
coverage: &mut Vec<CoverageEntry>,
) -> CanonicalSourceNode {
let id = format!("source:{pointer}");
let location = SourceLocation {
pointer: pointer.to_owned(),
};
coverage.push(CoverageEntry {
construct_id: id.clone(),
location: location.clone(),
disposition: CoverageDisposition::Mapped,
});
let value = match value {
Value::Null => CanonicalSourceValue::Null,
Value::Bool(value) => CanonicalSourceValue::Boolean(*value),
Value::Number(value) => CanonicalSourceValue::Number(value.to_string()),
Value::String(value) => CanonicalSourceValue::String(value.clone()),
Value::Array(items) => CanonicalSourceValue::Array(
items
.iter()
.enumerate()
.map(|(index, value)| {
canonical_source_tree(value, &format!("{pointer}/{index}"), coverage)
})
.collect(),
),
Value::Object(items) => CanonicalSourceValue::Object(
items
.iter()
.map(|(key, value)| {
(
key.clone(),
canonical_source_tree(
value,
&format!("{pointer}/{}", escape_pointer(key)),
coverage,
),
)
})
.collect(),
),
};
CanonicalSourceNode {
construct_id: id,
location,
value,
}
}
pub(super) fn paths_from_operations(
operations: &[NormalizedOperation],
version: &str,
) -> Vec<NormalizedPath> {
let mut paths = BTreeMap::<String, Vec<String>>::new();
for operation in operations {
paths
.entry(operation.path.clone())
.or_default()
.push(operation.stable_id.clone());
}
paths
.into_iter()
.map(|(path, operation_ids)| NormalizedPath {
construct_id: format!("{version}:path:{path}"),
location: SourceLocation {
pointer: format!("/paths/{}", escape_pointer(&path)),
},
path,
operation_ids,
})
.collect()
}
pub(super) fn escape_pointer(value: &str) -> String {
value.replace('~', "~0").replace('/', "~1")
}
@@ -0,0 +1,185 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AliasQuote {
Single,
Double,
}
#[derive(Clone, Copy, Debug)]
struct BlockScalar {
parent_indent: usize,
content_indent: Option<usize>,
}
pub(super) fn alias_count(document: &str) -> usize {
// YAML lexical preflight: aliases are counted before serde_yaml can expand
// them. This is deliberately a small lexer rather than a second YAML
// parser: it understands quote escapes, comments, and flow aliases while
// keeping the scan bounded by the source size.
//
// Block scalars are the one intentionally conservative exception. Their
// contents are opaque YAML text, and reproducing YAML's indentation rules
// here would create a second parser with its own correctness risks. If a
// content line contains `*`, fail closed by returning usize::MAX; this
// prevents an alias-looking token from being hidden in a literal block at
// the cost of rejecting that block before decode.
let mut count = 0;
let mut quote = None;
let mut block_scalar: Option<BlockScalar> = None;
for line in document.split('\n') {
if let Some(block) = block_scalar {
if is_blank_line(line) {
continue;
}
let indent = leading_spaces(line);
let is_content = match block.content_indent {
Some(content_indent) => indent >= content_indent,
None => indent > block.parent_indent,
};
if is_content {
if line.as_bytes().contains(&b'*') {
return usize::MAX;
}
if block.content_indent.is_none() {
block_scalar = Some(BlockScalar {
content_indent: Some(indent),
..block
});
}
continue;
}
block_scalar = None;
}
let bytes = line.as_bytes();
let line_indent = leading_spaces(line);
let mut comment = false;
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if comment {
break;
}
if let Some(current) = quote {
match current {
AliasQuote::Single if byte == b'\'' => {
// YAML escapes a single quote by doubling it. Consume
// both bytes so the second quote cannot reopen a
// scalar and desynchronise the scan.
if bytes.get(index + 1) == Some(&b'\'') {
index += 2;
} else {
quote = None;
index += 1;
}
}
AliasQuote::Double if byte == b'\\' => {
// A backslash escapes the following byte, including a
// quote. At end of line it only folds the YAML line;
// the quote remains open for the next line.
index += if index + 1 < bytes.len() { 2 } else { 1 };
}
AliasQuote::Double if byte == b'"' => {
quote = None;
index += 1;
}
_ => index += 1,
}
continue;
}
match byte {
b'\'' => {
quote = Some(AliasQuote::Single);
index += 1;
}
b'"' => {
quote = Some(AliasQuote::Double);
index += 1;
}
b'#' if index == 0
|| bytes
.get(index - 1)
.is_some_and(|previous| previous.is_ascii_whitespace()) =>
{
comment = true;
index += 1;
}
b'*' => {
let previous = index.checked_sub(1).and_then(|i| bytes.get(i)).copied();
let next = bytes.get(index + 1).copied();
if previous
.is_none_or(|byte| byte.is_ascii_whitespace() || b"[:,[{".contains(&byte))
&& next.is_some_and(is_alias_name_byte)
{
count += 1;
}
index += 1;
}
b'|' | b'>' if is_block_scalar_indicator(bytes, index) => {
block_scalar = Some(BlockScalar {
parent_indent: line_indent,
content_indent: block_scalar_indent(bytes, index, line_indent),
});
// The rest of a block-scalar header cannot contain YAML
// aliases; its content starts on the next line.
break;
}
_ => index += 1,
}
}
}
count
}
fn is_alias_name_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
}
fn leading_spaces(line: &str) -> usize {
line.bytes().take_while(|byte| *byte == b' ').count()
}
fn is_blank_line(line: &str) -> bool {
line.bytes()
.all(|byte| byte == b' ' || byte == b'\t' || byte == b'\r')
}
fn is_block_scalar_indicator(bytes: &[u8], index: usize) -> bool {
let mut previous_position = index;
while previous_position > 0
&& bytes
.get(previous_position - 1)
.is_some_and(|byte| byte.is_ascii_whitespace())
{
previous_position -= 1;
}
let previous_is_value_boundary = previous_position == 0
|| bytes
.get(previous_position - 1)
.is_some_and(|byte| *byte == b':' || *byte == b'-');
let next = bytes.get(index + 1).copied();
let next_is_header_suffix = next.is_none_or(|byte| {
byte.is_ascii_whitespace()
|| byte == b'#'
|| byte == b'+'
|| byte == b'-'
|| byte.is_ascii_digit()
});
previous_is_value_boundary && next_is_header_suffix
}
fn block_scalar_indent(bytes: &[u8], index: usize, parent_indent: usize) -> Option<usize> {
let mut position = index + 1;
while let Some(byte) = bytes.get(position).copied() {
if byte == b'+' || byte == b'-' {
position += 1;
continue;
}
if byte.is_ascii_digit() && byte != b'0' {
return Some(parent_indent + usize::from(byte - b'0'));
}
break;
}
None
}
@@ -0,0 +1,183 @@
use serde_json::Value;
use crate::rest::model::{
CoverageDisposition, CoverageEntry, NormalizedLiteral, NormalizedScalarKind, NormalizedSchema,
NormalizedSchemaKind, SourceLocation, UnresolvedReference,
};
use super::normalize_coverage::escape_pointer;
pub(super) fn typed_schema(
value: &Value,
pointer: &str,
_id: &str,
coverage: &mut Vec<CoverageEntry>,
) -> NormalizedSchema {
// IDs are derived from RFC 6901 locations rather than user-controlled
// property names, so escaped keys cannot collide with one another.
let id = format!("schema:{}", escape_pointer(pointer));
let location = SourceLocation {
pointer: pointer.to_owned(),
};
coverage.push(CoverageEntry {
construct_id: id.clone(),
location: location.clone(),
disposition: CoverageDisposition::Mapped,
});
let kind = if let Some(reference) = value.get("$ref").and_then(Value::as_str) {
NormalizedSchemaKind::Reference {
reference: UnresolvedReference {
uri: reference.to_owned(),
location: SourceLocation {
pointer: format!("{pointer}/$ref"),
},
},
}
} else if let Some((operator, variants)) =
["allOf", "oneOf", "anyOf"]
.into_iter()
.find_map(|operator| {
value
.get(operator)
.and_then(Value::as_array)
.map(|items| (operator, items))
})
{
NormalizedSchemaKind::Composition {
operator: operator.to_owned(),
variants: variants
.iter()
.enumerate()
.map(|(index, item)| {
typed_schema(
item,
&format!("{pointer}/{operator}/{index}"),
&format!(
"schema:{}",
escape_pointer(&format!("{pointer}/{operator}/{index}"))
),
coverage,
)
})
.collect(),
}
} else if value.get("properties").is_some()
|| value.get("type").and_then(Value::as_str) == Some("object")
{
let required = value
.get("required")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default();
let properties = value
.get("properties")
.and_then(Value::as_object)
.map(|properties| {
properties
.iter()
.map(|(name, schema)| {
(
name.clone(),
typed_schema(
schema,
&format!("{pointer}/properties/{}", escape_pointer(name)),
&format!(
"schema:{}",
escape_pointer(&format!(
"{pointer}/properties/{}",
escape_pointer(name)
))
),
coverage,
),
)
})
.collect()
})
.unwrap_or_default();
NormalizedSchemaKind::Object {
properties,
required,
}
} else if value.get("type").and_then(Value::as_str) == Some("array") {
NormalizedSchemaKind::Array {
items: value.get("items").map(|items| {
Box::new(typed_schema(
items,
&format!("{pointer}/items"),
&format!("schema:{}", escape_pointer(&format!("{pointer}/items"))),
coverage,
))
}),
}
} else if let Some(raw_type) = value.get("type").and_then(Value::as_str) {
NormalizedSchemaKind::Scalar {
scalar_type: match raw_type {
"integer" => NormalizedScalarKind::Integer,
"number" => NormalizedScalarKind::Number,
"boolean" => NormalizedScalarKind::Boolean,
"null" => NormalizedScalarKind::Null,
_ => NormalizedScalarKind::String,
},
format: value
.get("format")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
nullable: value
.get("nullable")
.and_then(Value::as_bool)
.unwrap_or(false),
default_value: value.get("default").and_then(normalized_literal),
enum_values: value
.get("enum")
.and_then(Value::as_array)
.map(|items| items.iter().filter_map(normalized_literal).collect())
.unwrap_or_default(),
}
} else {
NormalizedSchemaKind::Unknown
};
NormalizedSchema {
construct_id: id,
location,
description: value
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
kind,
}
}
pub(super) fn has_reference_schema(schema: Option<&NormalizedSchema>) -> bool {
match schema.map(|schema| &schema.kind) {
Some(NormalizedSchemaKind::Reference { .. }) => true,
Some(NormalizedSchemaKind::Object { properties, .. }) => properties
.values()
.any(|schema| has_reference_schema(Some(schema))),
Some(NormalizedSchemaKind::Array { items }) => has_reference_schema(items.as_deref()),
Some(NormalizedSchemaKind::Composition { variants, .. }) => variants
.iter()
.any(|schema| has_reference_schema(Some(schema))),
_ => false,
}
}
fn normalized_literal(value: &Value) -> Option<NormalizedLiteral> {
match value {
Value::String(value) => Some(NormalizedLiteral::String(value.clone())),
Value::Bool(value) => Some(NormalizedLiteral::Boolean(*value)),
Value::Null => Some(NormalizedLiteral::Null),
Value::Number(value) => value
.as_i64()
.map(NormalizedLiteral::Integer)
.or_else(|| value.as_u64().map(NormalizedLiteral::Unsigned))
.or_else(|| value.as_f64().map(NormalizedLiteral::Number)),
_ => None,
}
}
+624 -76
View File
@@ -3,14 +3,18 @@ use serde_json::Value;
use crate::rest::{ use crate::rest::{
model::{ model::{
ImportFinding, RestImportDocument, RestImportOperation, RestImportParameter, ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation,
RestParameterLocation, RestImportParameter, RestParameterLocation,
}, },
normalize::{ImportParseError, resolve_local_ref}, normalize::ImportParseError,
recommendations::document_finding, recommendations::{document_blocker, document_finding},
}; };
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> { pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
parse_document_v2(root)
}
fn parse_document_v2(root: &Value) -> Result<RestImportDocument, ImportParseError> {
let version = root let version = root
.get("openapi") .get("openapi")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -20,17 +24,196 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or("Imported API") .unwrap_or("Imported API")
.to_owned(); .to_owned();
let servers = root let mut findings = Vec::new();
.get("servers") let mut internal_finding_locations = Vec::new();
.and_then(Value::as_array) let servers = parse_servers(root.get("servers"), "/servers");
.map(|items| { append_findings_with_locations(
items &mut findings,
.iter() &mut internal_finding_locations,
.filter_map(|item| item.get("url").and_then(Value::as_str)) servers.findings,
.map(|url| url.trim_end_matches('/').to_owned()) );
.collect::<Vec<_>>() let servers = servers.servers;
if servers.is_empty() {
findings.push(document_finding(
"missing_servers",
"В документе не указаны servers, base URL нужно будет выбрать вручную.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: String::new(),
}));
} else if servers.len() > 1 {
findings.push(document_finding(
"multiple_servers",
"В документе несколько servers, при импорте нужно выбрать нужный base URL.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: "/servers".to_owned(),
}));
}
let mut operations = Vec::new();
let paths = root
.get("paths")
.and_then(Value::as_object)
.ok_or(ImportParseError::UnsupportedDocument)?;
for (path, path_item) in paths {
if !path_item.is_object() {
findings.push(path_item_blocker(path));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!("/paths/{}", path.replace('~', "~0").replace('/', "~1")),
}));
continue;
}
for method_name in ["head", "options", "trace", "connect"] {
if path_item.get(method_name).is_some() {
findings.push(document_blocker(
"unsupported_http_method",
format!("Метод {method_name} для пути {path} пока не поддерживается."),
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!(
"/paths/{}/{}",
path.replace('~', "~0").replace('/', "~1"),
method_name
),
}));
}
}
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
let path_parameters = parameters(
path_item.get("parameters"),
&format!("{path_pointer}/parameters"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
path_parameters.findings,
);
let path_parameters = path_parameters.parameters;
let path_servers =
parse_servers(path_item.get("servers"), &format!("{path_pointer}/servers"));
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
path_servers.findings,
);
let path_servers = path_servers.servers;
for method_name in ["get", "post", "put", "patch", "delete"] {
let Some(operation_value) = path_item.get(method_name) else {
continue;
};
if !operation_value.is_object() {
findings.push(document_blocker(
"invalid_operation",
"Операция имеет неверную структуру и не была интерпретирована.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!(
"/paths/{}/{}",
path.replace('~', "~0").replace('/', "~1"),
method_name
),
}));
continue;
}
let Some(method) = method_from_lower(method_name) else {
continue;
};
let operation_pointer = format!("{path_pointer}/{method_name}");
let mut operation_parameters = path_parameters.clone();
let parsed_parameters = parameters(
operation_value.get("parameters"),
&format!("{path_pointer}/{method_name}/parameters"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
parsed_parameters.findings,
);
operation_parameters.extend(parsed_parameters.parameters);
let operation_servers = parse_servers(
operation_value.get("servers"),
&format!("{operation_pointer}/servers"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
operation_servers.findings,
);
let operation_servers = operation_servers.servers;
let tags = tags(
operation_value.get("tags"),
&format!("{operation_pointer}/tags"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
tags.findings,
);
operations.push(RestImportOperation {
key: format!("{} {}", method_name.to_uppercase(), path),
method,
path: path.clone(),
operation_id: operation_value
.get("operationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: operation_value
.get("summary")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: operation_value
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
tags: tags.tags,
parameters: operation_parameters,
request_body_schema: request_body_schema(operation_value, &operation_pointer)
.map(|(schema, _)| schema),
request_body_schema_location: request_body_schema(
operation_value,
&operation_pointer,
)
.map(|(_, location)| location),
response_schema: response_schema(operation_value, &operation_pointer)
.map(|(schema, _)| schema),
response_schema_location: response_schema(operation_value, &operation_pointer)
.map(|(_, location)| location),
servers: if operation_servers.is_empty() {
path_servers.clone()
} else {
operation_servers
},
findings: operation_findings(operation_value),
});
}
}
Ok(RestImportDocument {
format: "openapi".to_owned(),
version,
title,
servers,
operations,
findings,
internal_finding_locations,
}) })
.unwrap_or_default(); }
pub fn parse_document_legacy_v1(root: &Value) -> Result<RestImportDocument, ImportParseError> {
let version = root
.get("openapi")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let title = root
.pointer("/info/title")
.and_then(Value::as_str)
.unwrap_or("Imported API")
.to_owned();
let servers = legacy_servers(root.get("servers"));
let mut findings = Vec::new(); let mut findings = Vec::new();
if servers.is_empty() { if servers.is_empty() {
findings.push(document_finding( findings.push(document_finding(
@@ -49,49 +232,80 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.get("paths") .get("paths")
.and_then(Value::as_object) .and_then(Value::as_object)
.ok_or(ImportParseError::UnsupportedDocument)?; .ok_or(ImportParseError::UnsupportedDocument)?;
for (path, path_item) in paths { for (path, path_item) in paths {
let path_parameters = parameters(root, path_item.get("parameters")); let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
let path_parameters = legacy_parameters(
root,
path_item.get("parameters"),
&format!("{path_pointer}/parameters"),
);
for method_name in ["get", "post", "put", "patch", "delete"] { for method_name in ["get", "post", "put", "patch", "delete"] {
let Some(operation_value) = path_item.get(method_name) else { let Some(operation) = path_item.get(method_name) else {
continue; continue;
}; };
let Some(method) = method_from_lower(method_name) else { let Some(method) = method_from_lower(method_name) else {
continue; continue;
}; };
let operation_pointer = format!("{path_pointer}/{method_name}");
let mut parameters = path_parameters.clone();
parameters.extend(legacy_parameters(
root,
operation.get("parameters"),
&format!("{operation_pointer}/parameters"),
));
operations.push(RestImportOperation {
key: format!("{} {}", method_name.to_uppercase(), path),
method,
path: path.clone(),
operation_id: operation
.get("operationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: operation
.get("summary")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: operation
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
tags: legacy_tags(operation.get("tags")),
parameters,
request_body_schema: legacy_request_body_schema(root, operation),
request_body_schema_location: None,
response_schema: legacy_response_schema(root, operation),
response_schema_location: None,
servers: legacy_servers(operation.get("servers")),
findings: legacy_operation_findings(operation),
});
}
}
Ok(RestImportDocument {
format: "openapi".to_owned(),
version,
title,
servers,
operations,
findings,
internal_finding_locations: Vec::new(),
})
}
let mut operation_parameters = path_parameters.clone(); fn legacy_servers(value: Option<&Value>) -> Vec<String> {
operation_parameters.extend(parameters(root, operation_value.get("parameters"))); value
let operation_servers = operation_value
.get("servers")
.and_then(Value::as_array) .and_then(Value::as_array)
.map(|items| { .map(|items| {
items items
.iter() .iter()
.filter_map(|item| item.get("url").and_then(Value::as_str)) .filter_map(|item| item.get("url").and_then(Value::as_str))
.map(|url| url.trim_end_matches('/').to_owned()) .map(|url| url.trim_end_matches('/').to_owned())
.collect::<Vec<_>>() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default()
}
operations.push(RestImportOperation { fn legacy_tags(value: Option<&Value>) -> Vec<String> {
key: format!("{} {}", method_name.to_uppercase(), path), value
method,
path: path.clone(),
operation_id: operation_value
.get("operationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: operation_value
.get("summary")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: operation_value
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
tags: operation_value
.get("tags")
.and_then(Value::as_array) .and_then(Value::as_array)
.map(|items| { .map(|items| {
items items
@@ -100,34 +314,22 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
.collect() .collect()
}) })
.unwrap_or_default(), .unwrap_or_default()
parameters: operation_parameters,
request_body_schema: request_body_schema(root, operation_value),
response_schema: response_schema(root, operation_value),
servers: operation_servers,
findings: operation_findings(operation_value),
});
}
}
Ok(RestImportDocument {
format: "openapi".to_owned(),
version,
title,
servers,
operations,
findings,
})
} }
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> { fn legacy_parameters(
root: &Value,
value: Option<&Value>,
base_pointer: &str,
) -> Vec<RestImportParameter> {
value value
.and_then(Value::as_array) .and_then(Value::as_array)
.map(|items| { .map(|items| {
items items
.iter() .iter()
.filter_map(|item| { .enumerate()
let item = resolve_local_ref(root, item, 0); .filter_map(|(index, item)| {
let item = crate::rest::normalize::resolve_local_ref(root, item, 0);
let location = match item.get("in").and_then(Value::as_str)? { let location = match item.get("in").and_then(Value::as_str)? {
"path" => RestParameterLocation::Path, "path" => RestParameterLocation::Path,
"query" => RestParameterLocation::Query, "query" => RestParameterLocation::Query,
@@ -146,9 +348,13 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.get("description") .get("description")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
schema: item schema: item.get("schema").map(|schema| {
.get("schema") crate::rest::normalize::resolve_local_ref(root, schema, 0)
.map(|schema| resolve_local_ref(root, schema, 0)), }),
source_location: crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
},
schema_source_location: None,
}) })
}) })
.collect() .collect()
@@ -156,31 +362,31 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.unwrap_or_default() .unwrap_or_default()
} }
fn request_body_schema(root: &Value, operation: &Value) -> Option<Value> { fn legacy_request_body_schema(root: &Value, operation: &Value) -> Option<Value> {
let body = resolve_local_ref(root, operation.get("requestBody")?, 0); let body = crate::rest::normalize::resolve_local_ref(root, operation.get("requestBody")?, 0);
let content = body.get("content")?.as_object()?; let content = body.get("content")?.as_object()?;
for content_type in ["application/json", "application/*+json"] { for content_type in ["application/json", "application/*+json"] {
if let Some(schema) = content if let Some(schema) = content
.get(content_type) .get(content_type)
.and_then(|media| media.get("schema")) .and_then(|media| media.get("schema"))
{ {
return Some(resolve_local_ref(root, schema, 0)); return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
} }
} }
content content
.iter() .iter()
.find(|(content_type, _)| content_type.contains("json")) .find(|(content_type, _)| content_type.contains("json"))
.and_then(|(_, media)| media.get("schema")) .and_then(|(_, media)| media.get("schema"))
.map(|schema| resolve_local_ref(root, schema, 0)) .map(|schema| crate::rest::normalize::resolve_local_ref(root, schema, 0))
} }
fn response_schema(root: &Value, operation: &Value) -> Option<Value> { fn legacy_response_schema(root: &Value, operation: &Value) -> Option<Value> {
let responses = operation.get("responses")?.as_object()?; let responses = operation.get("responses")?.as_object()?;
for code in ["200", "201", "202", "default"] { for code in ["200", "201", "202", "default"] {
let Some(response) = responses.get(code) else { let Some(response) = responses.get(code) else {
continue; continue;
}; };
let response = resolve_local_ref(root, response, 0); let response = crate::rest::normalize::resolve_local_ref(root, response, 0);
let Some(content) = response.get("content").and_then(Value::as_object) else { let Some(content) = response.get("content").and_then(Value::as_object) else {
continue; continue;
}; };
@@ -189,7 +395,7 @@ fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
.get(content_type) .get(content_type)
.and_then(|media| media.get("schema")) .and_then(|media| media.get("schema"))
{ {
return Some(resolve_local_ref(root, schema, 0)); return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
} }
} }
if let Some(schema) = content if let Some(schema) = content
@@ -197,17 +403,350 @@ fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
.find(|(content_type, _)| content_type.contains("json")) .find(|(content_type, _)| content_type.contains("json"))
.and_then(|(_, media)| media.get("schema")) .and_then(|(_, media)| media.get("schema"))
{ {
return Some(resolve_local_ref(root, schema, 0)); return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
} }
} }
None None
} }
fn legacy_operation_findings(operation: &Value) -> Vec<ImportFinding> {
if operation.get("requestBody").is_some()
&& legacy_request_body_schema(&Value::Null, operation).is_none()
{
vec![document_finding(
"unsupported_request_body",
"У метода есть requestBody, но JSON schema не найдена.",
)]
} else {
Vec::new()
}
}
struct ParsedParameters {
parameters: Vec<RestImportParameter>,
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
}
struct ParsedServers {
servers: Vec<String>,
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
}
fn parse_servers(value: Option<&Value>, base_pointer: &str) -> ParsedServers {
let mut findings = Vec::new();
let servers = value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.enumerate()
.filter_map(|(index, item)| {
let pointer = format!("{base_pointer}/{index}");
let Some(object) = item.as_object() else {
findings.push(server_blocker(&pointer));
return None;
};
let Some(url) = object.get("url").and_then(Value::as_str) else {
findings.push(server_blocker(&pointer));
return None;
};
Some(url.trim_end_matches('/').to_owned())
})
.collect()
})
.unwrap_or_default();
ParsedServers { servers, findings }
}
struct ParsedTags {
tags: Vec<String>,
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
}
fn tags(value: Option<&Value>, base_pointer: &str) -> ParsedTags {
let mut findings = Vec::new();
let tags = value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.enumerate()
.filter_map(|(index, item)| match item.as_str() {
Some(tag) => Some(tag.to_owned()),
None => {
findings.push((
document_finding(
"invalid_tag",
"Тег должен быть строкой и не был импортирован.",
),
crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
},
));
None
}
})
.collect()
})
.unwrap_or_default();
ParsedTags { tags, findings }
}
fn server_blocker(pointer: &str) -> (ImportFinding, crate::rest::model::SourceLocation) {
(
document_blocker(
"invalid_server",
"Server должен быть объектом со строковым url и не был импортирован.",
),
crate::rest::model::SourceLocation {
pointer: pointer.to_owned(),
},
)
}
fn parameters(value: Option<&Value>, base_pointer: &str) -> ParsedParameters {
let mut findings = Vec::new();
let parameters = value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.enumerate()
.filter_map(|(index, item)| {
let pointer = format!("{base_pointer}/{index}");
let Some(object) = item.as_object() else {
findings.push(parameter_blocker(
"invalid_parameter",
"Параметр должен быть объектом и не был интерпретирован.",
&pointer,
));
return None;
};
if object.contains_key("$ref") {
findings.push(parameter_blocker(
"unresolved_parameter_reference",
"Параметр по $ref требует разрешения перед импортом.",
&pointer,
));
return None;
}
if !object.get("name").is_some_and(Value::is_string) {
findings.push(parameter_blocker(
"missing_parameter_name",
"У параметра отсутствует строковое поле name.",
&pointer,
));
return None;
}
if !object.get("in").is_some_and(Value::is_string) {
findings.push(parameter_blocker(
"missing_parameter_location",
"У параметра отсутствует строковое поле in.",
&pointer,
));
return None;
}
let item = item.clone();
let location = match item.get("in").and_then(Value::as_str)? {
"path" => RestParameterLocation::Path,
"query" => RestParameterLocation::Query,
"header" => RestParameterLocation::Header,
"cookie" => {
findings.push(parameter_blocker(
"unsupported_cookie_parameter",
"Cookie parameter пока не поддерживается и не был импортирован.",
&pointer,
));
return None;
}
_ => {
findings.push(parameter_blocker(
"unsupported_parameter_location",
"Параметр использует неподдерживаемое значение in и не был импортирован.",
&pointer,
));
return None;
}
};
Some(RestImportParameter {
name: item.get("name").and_then(Value::as_str)?.to_owned(),
location,
required: item
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
|| location == RestParameterLocation::Path,
description: item
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
schema: item.get("schema").cloned(),
schema_source_location: item.get("schema").map(|_| {
crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}/schema"),
}
}),
source_location: crate::rest::model::SourceLocation {
pointer,
},
})
})
.collect()
})
.unwrap_or_default();
ParsedParameters {
parameters,
findings,
}
}
fn parameter_blocker(
code: &str,
message: &str,
pointer: &str,
) -> (ImportFinding, crate::rest::model::SourceLocation) {
(
document_blocker(code, message),
crate::rest::model::SourceLocation {
pointer: pointer.to_owned(),
},
)
}
fn append_findings_with_locations(
findings: &mut Vec<ImportFinding>,
locations: &mut Vec<Option<crate::rest::model::SourceLocation>>,
parameter_findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
) {
for (finding, location) in parameter_findings {
findings.push(finding);
locations.push(Some(location));
}
}
fn request_body_schema(
operation: &Value,
operation_pointer: &str,
) -> Option<(Value, crate::rest::model::SourceLocation)> {
let body = operation.get("requestBody")?;
let content = body.get("content")?.as_object()?;
for content_type in ["application/json", "application/*+json"] {
if let Some(schema) = content
.get(content_type)
.and_then(|media| media.get("schema"))
{
return Some((
schema.clone(),
crate::rest::model::SourceLocation {
pointer: format!(
"{operation_pointer}/requestBody/content/{}/schema",
content_type.replace('~', "~0").replace('/', "~1")
),
},
));
}
}
content
.iter()
.find(|(content_type, _)| content_type.contains("json"))
.and_then(|(content_type, media)| {
media.get("schema").cloned().map(|schema| {
(
schema,
crate::rest::model::SourceLocation {
pointer: format!(
"{operation_pointer}/requestBody/content/{}/schema",
content_type.replace('~', "~0").replace('/', "~1")
),
},
)
})
})
}
fn response_schema(
operation: &Value,
operation_pointer: &str,
) -> Option<(Value, crate::rest::model::SourceLocation)> {
let responses = operation.get("responses")?.as_object()?;
let mut numeric = responses
.iter()
.filter_map(|(code, response)| {
(code.len() == 3)
.then(|| code.parse::<u16>().ok())
.flatten()
.filter(|status| (200..300).contains(status))
.map(|status| (status, code.as_str(), response))
})
.collect::<Vec<_>>();
numeric.sort_by(|left, right| (left.0, left.1).cmp(&(right.0, right.1)));
for (_, code, response) in numeric {
if let Some(schema) = response_json_schema(response, operation_pointer, code) {
return Some(schema);
}
}
let mut wildcard = responses
.iter()
.filter(|(code, _)| code.eq_ignore_ascii_case("2xx"))
.collect::<Vec<_>>();
wildcard.sort_by(|left, right| left.0.cmp(right.0));
for (code, response) in wildcard {
if let Some(schema) = response_json_schema(response, operation_pointer, code) {
return Some(schema);
}
}
responses
.get("default")
.and_then(|response| response_json_schema(response, operation_pointer, "default"))
}
fn response_json_schema(
response: &Value,
operation_pointer: &str,
code: &str,
) -> Option<(Value, crate::rest::model::SourceLocation)> {
let content = response.get("content")?.as_object()?;
for content_type in ["application/json", "application/*+json"] {
if let Some(schema) = content
.get(content_type)
.and_then(|media| media.get("schema"))
{
return Some((
schema.clone(),
crate::rest::model::SourceLocation {
pointer: format!(
"{operation_pointer}/responses/{}/content/{}/schema",
code.replace('~', "~0").replace('/', "~1"),
content_type.replace('~', "~0").replace('/', "~1")
),
},
));
}
}
content
.iter()
.find(|(content_type, _)| content_type.contains("json"))
.and_then(|(content_type, media)| {
media.get("schema").map(|schema| {
(
schema.clone(),
crate::rest::model::SourceLocation {
pointer: format!(
"{operation_pointer}/responses/{}/content/{}/schema",
code.replace('~', "~0").replace('/', "~1"),
content_type.replace('~', "~0").replace('/', "~1")
),
},
)
})
})
}
fn operation_findings(operation: &Value) -> Vec<ImportFinding> { fn operation_findings(operation: &Value) -> Vec<ImportFinding> {
let mut findings = Vec::new(); let mut findings = Vec::new();
if operation.get("requestBody").is_some() if operation.get("requestBody").is_some() && request_body_schema(operation, "").is_none() {
&& request_body_schema(&Value::Null, operation).is_none()
{
findings.push(document_finding( findings.push(document_finding(
"unsupported_request_body", "unsupported_request_body",
"У метода есть requestBody, но JSON schema не найдена.", "У метода есть requestBody, но JSON schema не найдена.",
@@ -226,3 +765,12 @@ fn method_from_lower(value: &str) -> Option<HttpMethod> {
_ => None, _ => None,
} }
} }
fn path_item_blocker(_path: &str) -> ImportFinding {
ImportFinding {
code: "invalid_path_item".to_owned(),
severity: ImportFindingSeverity::Error,
message: "Path Item имеет неверную структуру и не был интерпретирован.".to_owned(),
operation_key: None,
}
}
@@ -11,6 +11,15 @@ pub fn document_finding(code: &str, message: impl Into<String>) -> ImportFinding
} }
} }
pub fn document_blocker(code: &str, message: impl Into<String>) -> ImportFinding {
ImportFinding {
code: code.to_owned(),
severity: ImportFindingSeverity::Error,
message: message.into(),
operation_key: None,
}
}
pub fn operation_finding( pub fn operation_finding(
operation_key: &str, operation_key: &str,
code: &str, code: &str,
@@ -45,6 +54,9 @@ pub fn operation_recommendations(operation: &RestImportOperation) -> Vec<ImportF
.unwrap_or_default() .unwrap_or_default()
.trim() .trim()
.is_empty() .is_empty()
&& !findings
.iter()
.any(|finding| finding.code == "missing_operation_id")
{ {
findings.push(operation_finding( findings.push(operation_finding(
&operation.key, &operation.key,
+13 -11
View File
@@ -32,6 +32,8 @@ pub fn schema_from_openapi(
let Some(value) = value else { let Some(value) = value else {
return primitive(SchemaKind::String, required, description); return primitive(SchemaKind::String, required, description);
}; };
// NormalizedIR keeps composition typed; the legacy preview adapter retains
// its historical first-branch projection for pending v1 jobs.
let resolved = collapse_composition(value); let resolved = collapse_composition(value);
if let Some(values) = resolved.get("enum").and_then(Value::as_array) { if let Some(values) = resolved.get("enum").and_then(Value::as_array) {
@@ -100,6 +102,17 @@ pub fn schema_from_openapi(
} }
} }
fn collapse_composition(value: &Value) -> Value {
for key in ["allOf", "oneOf", "anyOf"] {
if let Some(items) = value.get(key).and_then(Value::as_array)
&& let Some(first) = items.first()
{
return first.clone();
}
}
value.clone()
}
pub fn object_with_fields(description: Option<String>, fields: BTreeMap<String, Schema>) -> Schema { pub fn object_with_fields(description: Option<String>, fields: BTreeMap<String, Schema>) -> Schema {
Schema { Schema {
kind: SchemaKind::Object, kind: SchemaKind::Object,
@@ -190,14 +203,3 @@ fn text(value: &Value, key: &str) -> Option<String> {
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
fn collapse_composition(value: &Value) -> Value {
for key in ["allOf", "oneOf", "anyOf"] {
if let Some(items) = value.get(key).and_then(Value::as_array)
&& let Some(first) = items.first()
{
return first.clone();
}
}
value.clone()
}
+491 -41
View File
@@ -2,12 +2,19 @@ use crank_core::HttpMethod;
use serde_json::Value; use serde_json::Value;
use crate::rest::{ use crate::rest::{
model::{RestImportDocument, RestImportOperation, RestImportParameter, RestParameterLocation}, model::{
normalize::{ImportParseError, resolve_local_ref}, ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation,
recommendations::{document_finding, operation_finding}, RestImportParameter, RestParameterLocation,
},
normalize::ImportParseError,
recommendations::{document_blocker, document_finding, operation_finding},
}; };
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> { pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
parse_document_v2(root)
}
fn parse_document_v2(root: &Value) -> Result<RestImportDocument, ImportParseError> {
let title = root let title = root
.pointer("/info/title") .pointer("/info/title")
.and_then(Value::as_str) .and_then(Value::as_str)
@@ -15,11 +22,15 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.to_owned(); .to_owned();
let servers = swagger_servers(root); let servers = swagger_servers(root);
let mut findings = Vec::new(); let mut findings = Vec::new();
let mut internal_finding_locations = Vec::new();
if servers.is_empty() { if servers.is_empty() {
findings.push(document_finding( findings.push(document_finding(
"missing_servers", "missing_servers",
"В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.", "В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.",
)); ));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: String::new(),
}));
} }
let mut operations = Vec::new(); let mut operations = Vec::new();
@@ -29,20 +40,95 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.ok_or(ImportParseError::UnsupportedDocument)?; .ok_or(ImportParseError::UnsupportedDocument)?;
for (path, path_item) in paths { for (path, path_item) in paths {
let path_parameters = parameters(root, path_item.get("parameters")); if !path_item.is_object() {
findings.push(path_item_blocker(path));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!("/paths/{}", path.replace('~', "~0").replace('/', "~1")),
}));
continue;
}
for method_name in ["head", "options", "trace", "connect"] {
if path_item.get(method_name).is_some() {
findings.push(document_blocker(
"unsupported_http_method",
format!("Метод {method_name} для пути {path} пока не поддерживается."),
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!(
"/paths/{}/{}",
path.replace('~', "~0").replace('/', "~1"),
method_name
),
}));
}
}
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
let path_parameters = parameters(
root,
path_item.get("parameters"),
&format!("{path_pointer}/parameters"),
);
append_parameter_findings(
&mut findings,
&mut internal_finding_locations,
path_parameters.findings,
);
let path_parameters = path_parameters.parameters;
for method_name in ["get", "post", "put", "patch", "delete"] { for method_name in ["get", "post", "put", "patch", "delete"] {
let Some(operation_value) = path_item.get(method_name) else { let Some(operation_value) = path_item.get(method_name) else {
continue; continue;
}; };
if !operation_value.is_object() {
findings.push(document_blocker(
"invalid_operation",
"Операция имеет неверную структуру и не была интерпретирована.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!(
"/paths/{}/{}",
path.replace('~', "~0").replace('/', "~1"),
method_name
),
}));
continue;
}
let Some(method) = method_from_lower(method_name) else { let Some(method) = method_from_lower(method_name) else {
continue; continue;
}; };
let mut operation_parameters = path_parameters.clone(); let mut operation_parameters = path_parameters.clone();
operation_parameters.extend(parameters(root, operation_value.get("parameters"))); let parsed_parameters = parameters(
root,
operation_value.get("parameters"),
&format!("{path_pointer}/{method_name}/parameters"),
);
append_parameter_findings(
&mut findings,
&mut internal_finding_locations,
parsed_parameters.findings,
);
operation_parameters.extend(parsed_parameters.parameters);
let tags = tags(
operation_value.get("tags"),
&format!("{path_pointer}/{method_name}/tags"),
);
append_parameter_findings(
&mut findings,
&mut internal_finding_locations,
tags.findings,
);
let request_body_schema = operation_parameters let request_body_schema = operation_parameters
.iter() .iter()
.find(|parameter| parameter.name == "body") .find(|parameter| parameter.name == "body")
.and_then(|parameter| parameter.schema.clone()); .and_then(|parameter| {
parameter.schema.clone().map(|schema| {
(
schema,
crate::rest::model::SourceLocation {
pointer: format!("{}/schema", parameter.source_location.pointer),
},
)
})
});
let operation_parameters = operation_parameters let operation_parameters = operation_parameters
.into_iter() .into_iter()
.filter(|parameter| parameter.name != "body") .filter(|parameter| parameter.name != "body")
@@ -64,20 +150,22 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.get("description") .get("description")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
tags: operation_value tags: tags.tags,
.get("tags")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default(),
parameters: operation_parameters, parameters: operation_parameters,
request_body_schema, request_body_schema: request_body_schema
response_schema: response_schema(root, operation_value), .as_ref()
.map(|(schema, _)| schema.clone()),
request_body_schema_location: request_body_schema.map(|(_, location)| location),
response_schema: response_schema(
operation_value,
&format!("{path_pointer}/{method_name}"),
)
.map(|(schema, _)| schema),
response_schema_location: response_schema(
operation_value,
&format!("{path_pointer}/{method_name}"),
)
.map(|(_, location)| location),
servers: Vec::new(), servers: Vec::new(),
findings: swagger_operation_findings(path, operation_value), findings: swagger_operation_findings(path, operation_value),
}); });
@@ -91,9 +179,201 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
servers, servers,
operations, operations,
findings, findings,
internal_finding_locations,
}) })
} }
pub fn parse_document_legacy_v1(root: &Value) -> Result<RestImportDocument, ImportParseError> {
let title = root
.pointer("/info/title")
.and_then(Value::as_str)
.unwrap_or("Imported API")
.to_owned();
let servers = swagger_servers(root);
let mut findings = Vec::new();
if servers.is_empty() {
findings.push(document_finding(
"missing_servers",
"В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.",
));
}
let mut operations = Vec::new();
let paths = root
.get("paths")
.and_then(Value::as_object)
.ok_or(ImportParseError::UnsupportedDocument)?;
for (path, path_item) in paths {
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
let path_parameters = legacy_parameters(
root,
path_item.get("parameters"),
&format!("{path_pointer}/parameters"),
);
for method_name in ["get", "post", "put", "patch", "delete"] {
let Some(operation) = path_item.get(method_name) else {
continue;
};
let Some(method) = method_from_lower(method_name) else {
continue;
};
let operation_pointer = format!("{path_pointer}/{method_name}");
let mut parameters = path_parameters.clone();
parameters.extend(legacy_parameters(
root,
operation.get("parameters"),
&format!("{operation_pointer}/parameters"),
));
let request_body_schema = parameters
.iter()
.find(|parameter| parameter.name == "body")
.and_then(|parameter| parameter.schema.clone());
let parameters = parameters
.into_iter()
.filter(|parameter| parameter.name != "body")
.collect();
operations.push(RestImportOperation {
key: format!("{} {}", method_name.to_uppercase(), path),
method,
path: path.clone(),
operation_id: operation
.get("operationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: operation
.get("summary")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: operation
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
tags: legacy_tags(operation.get("tags")),
parameters,
request_body_schema,
request_body_schema_location: None,
response_schema: legacy_response_schema(root, operation),
response_schema_location: None,
servers: Vec::new(),
findings: swagger_operation_findings(path, operation),
});
}
}
Ok(RestImportDocument {
format: "swagger".to_owned(),
version: Some("2.0".to_owned()),
title,
servers,
operations,
findings,
internal_finding_locations: Vec::new(),
})
}
fn legacy_tags(value: Option<&Value>) -> Vec<String> {
value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default()
}
fn legacy_parameters(
root: &Value,
value: Option<&Value>,
base_pointer: &str,
) -> Vec<RestImportParameter> {
value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.enumerate()
.filter_map(|(index, item)| {
let item = crate::rest::normalize::resolve_local_ref(root, item, 0);
let raw_location = item.get("in").and_then(Value::as_str)?;
let pointer = format!("{base_pointer}/{index}");
if raw_location == "body" {
return Some(RestImportParameter {
name: "body".to_owned(),
location: RestParameterLocation::Query,
required: item
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false),
description: item
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
schema: item.get("schema").map(|schema| {
crate::rest::normalize::resolve_local_ref(root, schema, 0)
}),
source_location: crate::rest::model::SourceLocation { pointer },
schema_source_location: None,
});
}
let location = match raw_location {
"path" => RestParameterLocation::Path,
"query" => RestParameterLocation::Query,
"header" => RestParameterLocation::Header,
_ => return None,
};
Some(RestImportParameter {
name: item.get("name").and_then(Value::as_str)?.to_owned(),
location,
required: item
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
|| location == RestParameterLocation::Path,
description: item
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
schema: legacy_parameter_schema(root, &item),
source_location: crate::rest::model::SourceLocation { pointer },
schema_source_location: None,
})
})
.collect()
})
.unwrap_or_default()
}
fn legacy_parameter_schema(root: &Value, parameter: &Value) -> Option<Value> {
if let Some(schema) = parameter.get("schema") {
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
}
let mut schema = serde_json::Map::new();
for key in ["type", "format", "items", "enum", "default", "description"] {
if let Some(value) = parameter.get(key) {
schema.insert(
key.to_owned(),
crate::rest::normalize::resolve_local_ref(root, value, 0),
);
}
}
(!schema.is_empty()).then_some(Value::Object(schema))
}
fn legacy_response_schema(root: &Value, operation: &Value) -> Option<Value> {
let responses = operation.get("responses")?.as_object()?;
for code in ["200", "201", "202", "default"] {
let Some(response) = responses.get(code) else {
continue;
};
let response = crate::rest::normalize::resolve_local_ref(root, response, 0);
if let Some(schema) = response.get("schema") {
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
}
}
None
}
fn swagger_servers(root: &Value) -> Vec<String> { fn swagger_servers(root: &Value) -> Vec<String> {
let Some(host) = root.get("host").and_then(Value::as_str) else { let Some(host) = root.get("host").and_then(Value::as_str) else {
return Vec::new(); return Vec::new();
@@ -116,14 +396,51 @@ fn swagger_servers(root: &Value) -> Vec<String> {
.collect() .collect()
} }
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> { fn parameters(_root: &Value, value: Option<&Value>, base_pointer: &str) -> ParsedParameters {
value let mut findings = Vec::new();
let parameters = value
.and_then(Value::as_array) .and_then(Value::as_array)
.map(|items| { .map(|items| {
items items
.iter() .iter()
.filter_map(|item| { .enumerate()
let item = resolve_local_ref(root, item, 0); .filter_map(|(index, item)| {
let pointer = format!("{base_pointer}/{index}");
{
let Some(object) = item.as_object() else {
findings.push(parameter_blocker(
"invalid_parameter",
"Параметр должен быть объектом и не был интерпретирован.",
&pointer,
));
return None;
};
if object.contains_key("$ref") {
findings.push(parameter_blocker(
"unresolved_parameter_reference",
"Параметр по $ref требует разрешения перед импортом.",
&pointer,
));
return None;
}
if !object.get("name").is_some_and(Value::is_string) {
findings.push(parameter_blocker(
"missing_parameter_name",
"У параметра отсутствует строковое поле name.",
&pointer,
));
return None;
}
if !object.get("in").is_some_and(Value::is_string) {
findings.push(parameter_blocker(
"missing_parameter_location",
"У параметра отсутствует строковое поле in.",
&pointer,
));
return None;
}
}
let item = item.clone();
let raw_location = item.get("in").and_then(Value::as_str)?; let raw_location = item.get("in").and_then(Value::as_str)?;
if raw_location == "body" { if raw_location == "body" {
return Some(RestImportParameter { return Some(RestImportParameter {
@@ -137,16 +454,37 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.get("description") .get("description")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
schema: item schema: item.get("schema").cloned(),
.get("schema") schema_source_location: item.get("schema").map(|_| {
.map(|schema| resolve_local_ref(root, schema, 0)), crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}/schema"),
}
}),
source_location: crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
},
}); });
} }
let location = match raw_location { let location = match raw_location {
"path" => RestParameterLocation::Path, "path" => RestParameterLocation::Path,
"query" => RestParameterLocation::Query, "query" => RestParameterLocation::Query,
"header" => RestParameterLocation::Header, "header" => RestParameterLocation::Header,
_ => return None, "cookie" => {
findings.push(parameter_blocker(
"unsupported_cookie_parameter",
"Cookie parameter пока не поддерживается и не был импортирован.",
&pointer,
));
return None;
}
_ => {
findings.push(parameter_blocker(
"unsupported_parameter_location",
"Параметр использует неподдерживаемое значение in и не был импортирован.",
&pointer,
));
return None;
}
}; };
Some(RestImportParameter { Some(RestImportParameter {
name: item.get("name").and_then(Value::as_str)?.to_owned(), name: item.get("name").and_then(Value::as_str)?.to_owned(),
@@ -160,22 +498,95 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.get("description") .get("description")
.and_then(Value::as_str) .and_then(Value::as_str)
.map(ToOwned::to_owned), .map(ToOwned::to_owned),
schema: swagger_parameter_schema(root, &item), schema: swagger_parameter_schema(_root, &item),
schema_source_location: Some(crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
}),
source_location: crate::rest::model::SourceLocation {
pointer,
},
}) })
}) })
.collect() .collect()
}) })
.unwrap_or_default() .unwrap_or_default();
ParsedParameters {
parameters,
findings,
}
} }
fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option<Value> { struct ParsedParameters {
parameters: Vec<RestImportParameter>,
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
}
struct ParsedTags {
tags: Vec<String>,
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
}
fn tags(value: Option<&Value>, base_pointer: &str) -> ParsedTags {
let mut findings = Vec::new();
let tags = value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.enumerate()
.filter_map(|(index, item)| match item.as_str() {
Some(tag) => Some(tag.to_owned()),
None => {
findings.push((
document_finding(
"invalid_tag",
"Тег должен быть строкой и не был импортирован.",
),
crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
},
));
None
}
})
.collect()
})
.unwrap_or_default();
ParsedTags { tags, findings }
}
fn parameter_blocker(
code: &str,
message: &str,
pointer: &str,
) -> (ImportFinding, crate::rest::model::SourceLocation) {
(
document_blocker(code, message),
crate::rest::model::SourceLocation {
pointer: pointer.to_owned(),
},
)
}
fn append_parameter_findings(
findings: &mut Vec<ImportFinding>,
locations: &mut Vec<Option<crate::rest::model::SourceLocation>>,
parameter_findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
) {
for (finding, location) in parameter_findings {
findings.push(finding);
locations.push(Some(location));
}
}
fn swagger_parameter_schema(_root: &Value, parameter: &Value) -> Option<Value> {
if let Some(schema) = parameter.get("schema") { if let Some(schema) = parameter.get("schema") {
return Some(resolve_local_ref(root, schema, 0)); return Some(schema.clone());
} }
let mut schema = serde_json::Map::new(); let mut schema = serde_json::Map::new();
for key in ["type", "format", "items", "enum", "default", "description"] { for key in ["type", "format", "items", "enum", "default", "description"] {
if let Some(value) = parameter.get(key) { if let Some(value) = parameter.get(key) {
schema.insert(key.to_owned(), resolve_local_ref(root, value, 0)); schema.insert(key.to_owned(), value.clone());
} }
} }
if schema.is_empty() { if schema.is_empty() {
@@ -185,18 +596,48 @@ fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option<Value> {
} }
} }
fn response_schema(root: &Value, operation: &Value) -> Option<Value> { fn response_schema(
operation: &Value,
operation_pointer: &str,
) -> Option<(Value, crate::rest::model::SourceLocation)> {
let responses = operation.get("responses")?.as_object()?; let responses = operation.get("responses")?.as_object()?;
for code in ["200", "201", "202", "default"] { let mut numeric = responses
let Some(response) = responses.get(code) else { .iter()
continue; .filter_map(|(code, response)| {
}; (code.len() == 3)
let response = resolve_local_ref(root, response, 0); .then(|| code.parse::<u16>().ok())
if let Some(schema) = response.get("schema") { .flatten()
return Some(resolve_local_ref(root, schema, 0)); .filter(|status| (200..300).contains(status))
.map(|status| (status, code.as_str(), response))
})
.collect::<Vec<_>>();
numeric.sort_by(|left, right| (left.0, left.1).cmp(&(right.0, right.1)));
for (_, code, response) in numeric {
if let Some(schema) = response_schema_at(response, operation_pointer, code) {
return Some(schema);
} }
} }
None responses
.get("default")
.and_then(|response| response_schema_at(response, operation_pointer, "default"))
}
fn response_schema_at(
response: &Value,
operation_pointer: &str,
code: &str,
) -> Option<(Value, crate::rest::model::SourceLocation)> {
response.get("schema").map(|schema| {
(
schema.clone(),
crate::rest::model::SourceLocation {
pointer: format!(
"{operation_pointer}/responses/{}/schema",
code.replace('~', "~0").replace('/', "~1")
),
},
)
})
} }
fn swagger_operation_findings( fn swagger_operation_findings(
@@ -232,3 +673,12 @@ fn method_from_lower(value: &str) -> Option<HttpMethod> {
_ => None, _ => None,
} }
} }
fn path_item_blocker(_path: &str) -> ImportFinding {
ImportFinding {
code: "invalid_path_item".to_owned(),
severity: ImportFindingSeverity::Error,
message: "Path Item имеет неверную структуру и не был интерпретирован.".to_owned(),
operation_key: None,
}
}
+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"));
}
}
+509 -172
View File
@@ -1,44 +1,8 @@
mod unit { mod unit {
use crank_core::HttpMethod; use crank_import::rest::{
use crank_import::rest::preview_document; ImportFindingSeverity, ImportParseError, NormalizationConfig, normalize_verified_document,
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
"#;
const SWAGGER2: &str = r#" const SWAGGER2: &str = r#"
swagger: "2.0" swagger: "2.0"
@@ -74,170 +38,543 @@ definitions:
type: string type: string
"#; "#;
#[test] fn normalize_document(
fn previews_openapi3_rest_operations_grouped_by_tag() { document: &str,
let preview = preview_document(OPENAPI3).unwrap(); 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"); fn matrix_source() -> String {
assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]); "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()
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] #[test]
fn previews_swagger2_and_resolves_definitions() { fn normalizes_supported_versions_with_canonical_order_and_stable_ids() {
let preview = preview_document(SWAGGER2).unwrap(); let fixture_matrix = [
(include_str!("fixtures/openapi-3.0.yaml"), "3.0.3"),
assert_eq!(preview.source.format, "swagger"); (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!( assert_eq!(
preview.source.servers, normalize_document(fixture, &NormalizationConfig::default())
vec!["https://petstore.example.com/api"] .unwrap()
); .source
let operation = &preview.groups[0].operations[0]; .version
assert_eq!(operation.suggested_name, "get_pet"); .as_deref(),
assert_eq!(operation.input_fields, 1); Some(version)
assert_eq!(operation.output_fields, 2);
assert_eq!(operation.draft.target.path_template, "/pets/{id}");
assert_eq!(
operation.draft.input_mapping.rules[0].target,
"$.request.path.id"
); );
} }
let openapi_31_json = r#"{
#[test] "openapi":"3.1.0", "info":{"title":"Canonical"},
fn reports_missing_descriptions_as_recommendations() { "paths": {
let document = r#" "/z":{"post":{"responses":{"201":{"description":"ok"}}}},
"/a":{"get":{"responses":{"200":{"description":"ok"}}}}
}
}"#;
let openapi_30_yaml = r#"
openapi: 3.0.3 openapi: 3.0.3
info: { title: Minimal API } info: { title: Canonical }
paths: paths:
/items: /a:
get: get:
responses: responses: { '200': { description: ok } }
'204': { description: Empty } "#;
let swagger = r#"
swagger: '2.0'
info: { title: Legacy }
paths:
/a:
get:
responses: { '200': { description: ok } }
"#; "#;
let preview = preview_document(document).unwrap(); let first = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap();
let operation = &preview.groups[0].operations[0]; let second = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap();
let codes = operation assert_eq!(
serde_json::to_vec(&first).unwrap(),
serde_json::to_vec(&second).unwrap()
);
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!(
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 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 .findings
.iter() .iter()
.map(|finding| finding.code.as_str()) .any(|finding| finding.code == "unresolved_reference")
.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] #[test]
fn expands_json_request_body_object_into_tool_inputs() { fn preserves_valid_operations_when_another_path_item_is_malformed() {
let document = r#" let document = r#"
openapi: 3.0.3 openapi: 3.0.3
info: { title: CRM API } info: { title: Partial }
servers:
- url: https://crm.example.test
paths: paths:
/leads: /valid:
post: get:
operationId: createLead responses: { '200': { description: ok } }
summary: Создать лид /broken: invalid
description: Создает лид в CRM. "#;
tags: [crm] let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
requestBody: assert_eq!(ir.operations.len(), 1);
required: true assert!(ir.findings.iter().any(|finding| {
content: finding.code == "invalid_path_item"
application/json: && finding.severity == crank_import::rest::ImportFindingSeverity::Error
schema: }));
type: object assert!(ir.findings.iter().any(|finding| {
required: [email] finding.code == "invalid_path_item"
properties: && finding.location.pointer == "/paths/~1broken"
email: { type: string } && finding.construct_id.contains("/paths/~1broken")
name: { type: string } }));
responses: }
'201':
description: Created #[test]
content: fn malformed_method_values_are_blockers_while_valid_siblings_survive() {
application/json: for (format, document) in [
schema: (
type: object "openapi",
properties: "openapi: 3.0.3\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }",
id: { type: string } ),
"#; (
"swagger",
let preview = preview_document(document).unwrap(); "swagger: '2.0'\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }",
let operation = &preview.groups[0].operations[0]; ),
let input_fields = &operation.draft.input_schema.fields; ] {
let targets = operation let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
.draft assert_eq!(ir.operations.len(), 1, "{format}");
.input_mapping assert_eq!(ir.operations[0].key, "GET /mixed", "{format}");
.rules let finding = ir
.iter() .findings
.map(|rule| rule.target.as_str()) .iter()
.collect::<Vec<_>>(); .find(|finding| finding.code == "invalid_operation")
.unwrap();
assert!(input_fields.contains_key("email")); assert_eq!(finding.severity, ImportFindingSeverity::Error, "{format}");
assert!(input_fields.contains_key("name")); assert_eq!(finding.location.pointer, "/paths/~1mixed/post", "{format}");
assert!(targets.contains(&"$.request.body.email")); }
assert!(targets.contains(&"$.request.body.name")); }
}
#[test]
#[test] fn rejects_hostile_input_at_configured_limits() {
fn reports_tool_quality_recommendations_for_imported_operations() { let document = r#"
let document = r#" openapi: 3.0.3
openapi: 3.0.3 info: { title: Too deep }
info: { title: Wide API } paths: {}
paths: "#;
/items: 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:
/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: get:
operationId: getItems
summary: Get items
description: Get items.
parameters:
- name: page
in: query
schema: { type: integer }
responses: responses:
'200': '200':
description: OK description: ok
content: content:
application/json: application/json:
schema: schema:
type: object type: object
properties: properties:
field01: { type: string } count: { type: integer, default: 7, enum: [1, 2] }
field02: { type: string } enabled: { type: boolean, default: true, enum: [true, false] }
field03: { type: string } ratio: { type: number, default: 1.5, enum: [0.5, 1.5] }
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 ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
let preview = preview_document(document).unwrap(); let preview = preview_document(document).unwrap();
let operation = &preview.groups[0].operations[0]; let fields = &preview.groups[0].operations[0].draft.output_schema.fields;
let codes = operation 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 .findings
.iter() .iter()
.map(|finding| finding.code.as_str()) .filter(|finding| finding.code == "invalid_path_item")
.collect::<Vec<_>>(); .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")); #[test]
assert!(codes.contains(&"weak_tool_description")); fn canonical_matrix_covers_all_supported_versions_syntaxes_and_concurrency() {
assert!(codes.contains(&"weak_tool_name")); let matrix = [
assert!(codes.contains(&"too_many_output_fields")); (
"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 { .. })
));
}
} }
} }