From 6c2a3712d8bd2f96216b973c406d2488cea38edc Mon Sep 17 00:00:00 2001 From: bsodfather Date: Sat, 29 Aug 2026 04:15:37 +0300 Subject: [PATCH] feat(import): add deterministic OpenAPI normalized IR --- apps/admin-api/src/service/imports.rs | 126 ++- .../tests/integration/openapi_import.rs | 259 +++++ crates/crank-import/src/rest/mod.rs | 15 +- crates/crank-import/src/rest/model.rs | 312 +++++- crates/crank-import/src/rest/normalize.rs | 824 +++++++++++++++- .../src/rest/normalize_coverage.rs | 887 ++++++++++++++++++ .../crank-import/src/rest/normalize_limits.rs | 185 ++++ .../crank-import/src/rest/normalize_schema.rs | 183 ++++ crates/crank-import/src/rest/openapi3.rs | 688 ++++++++++++-- .../crank-import/src/rest/recommendations.rs | 12 + crates/crank-import/src/rest/schema.rs | 24 +- crates/crank-import/src/rest/swagger2.rs | 532 ++++++++++- crates/crank-import/tests/coverage.rs | 275 ++++++ .../fuzz-regressions/invalid-root.json | 1 + .../fuzz-regressions/unsupported-version.yaml | 3 + .../fuzz-regressions/wrong-version-shape.yaml | 3 + .../tests/fixtures/openapi-3.0.yaml | 6 + .../tests/fixtures/openapi-3.1.json | 9 + .../tests/fixtures/swagger-2.0.yaml | 6 + .../tests/normalization_details.rs | 767 +++++++++++++++ crates/crank-import/tests/preview.rs | 615 ++++++++++++ crates/crank-import/tests/unit.rs | 671 +++++++++---- 22 files changed, 6051 insertions(+), 352 deletions(-) create mode 100644 crates/crank-import/src/rest/normalize_coverage.rs create mode 100644 crates/crank-import/src/rest/normalize_limits.rs create mode 100644 crates/crank-import/src/rest/normalize_schema.rs create mode 100644 crates/crank-import/tests/coverage.rs create mode 100644 crates/crank-import/tests/fixtures/fuzz-regressions/invalid-root.json create mode 100644 crates/crank-import/tests/fixtures/fuzz-regressions/unsupported-version.yaml create mode 100644 crates/crank-import/tests/fixtures/fuzz-regressions/wrong-version-shape.yaml create mode 100644 crates/crank-import/tests/fixtures/openapi-3.0.yaml create mode 100644 crates/crank-import/tests/fixtures/openapi-3.1.json create mode 100644 crates/crank-import/tests/fixtures/swagger-2.0.yaml create mode 100644 crates/crank-import/tests/normalization_details.rs create mode 100644 crates/crank-import/tests/preview.rs diff --git a/apps/admin-api/src/service/imports.rs b/apps/admin-api/src/service/imports.rs index fd0f033..b57b128 100644 --- a/apps/admin-api/src/service/imports.rs +++ b/apps/admin-api/src/service/imports.rs @@ -6,7 +6,8 @@ use crank_core::{ WorkspaceId, }; 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::{ ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity, @@ -94,14 +95,22 @@ impl AdminService { detach_guard.detach_now().await; 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, Err(error) => { detach_guard.detach_now().await; return Err(error); } }; - if preview + if parsed + .preview .groups .iter() .all(|group| group.operations.is_empty()) @@ -113,7 +122,7 @@ impl AdminService { source_id, 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()))?; let preview_digest = preview_digest(&preview_value)?; let preview_payload = json!({ @@ -123,6 +132,11 @@ impl AdminService { }, "preview": preview_value, "preview_digest": preview_digest, + "normalization": { + "normalizer_version": NORMALIZER_VERSION, + "projection_version": PROJECTION_VERSION, + "ir_fingerprint": parsed.ir_fingerprint, + }, }); if let Err(error) = self @@ -131,8 +145,8 @@ impl AdminService { id: &job_id, workspace_id, kind: ImportJobKind::OpenApi, - source_format: &preview.source.format, - source_version: preview.source.version.as_deref(), + source_format: &parsed.preview.source.format, + source_version: parsed.preview.source.version.as_deref(), status: ImportJobStatus::Pending, source: &source_envelope, preview_payload: &preview_payload, @@ -151,7 +165,7 @@ impl AdminService { expires_at: expires_at .format(&Rfc3339) .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 { return Err(ApiError::openapi_upload(locale, "source_integrity")); } - let preview = parse_verified_preview(verified.bytes, locale).await?; - verify_preview_contract(&job.preview_payload, &preview, locale)?; + let legacy_v1 = job.preview_payload.get("normalization").is_none(); + 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(); - for group in &preview.groups { + for group in &parsed.preview.groups { for operation in &group.operations { candidates.insert(operation.key.clone(), operation); } @@ -441,10 +465,17 @@ fn validate_openapi_upload(upload: &OpenApiUpload) -> Result<(), ApiError> { Ok(()) } +struct ParsedOpenApiPreview { + preview: crank_import::rest::ImportPreview, + ir_fingerprint: String, +} + async fn parse_verified_preview( bytes: Vec, + digest: String, locale: OpenApiUploadLocale, -) -> Result { + legacy_v1: bool, +) -> Result { let started = tokio::time::Instant::now(); let permit = tokio::time::timeout(OPENAPI_PARSE_DEADLINE, OPENAPI_PARSE_SLOTS.acquire()) .await @@ -457,8 +488,32 @@ async fn parse_verified_preview( let _permit = permit; let document = std::str::from_utf8(&bytes) .map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?; - crank_import::rest::preview_document(document) - .map_err(|_| ApiError::openapi_upload(locale, "invalid_document")) + if legacy_v1 { + 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 .checked_sub(started.elapsed()) @@ -499,28 +554,63 @@ fn preview_digest(preview: &serde_json::Value) -> Result { fn verify_preview_contract( payload: &serde_json::Value, - preview: &crank_import::rest::ImportPreview, + parsed: &ParsedOpenApiPreview, locale: OpenApiUploadLocale, ) -> Result<(), ApiError> { // Pre-fingerprint jobs are legacy rolling-upgrade records. They retain the // old reparse behavior; new jobs fail closed if parser output drifts or a // persisted preview has been changed. - let Some(expected) = payload + let expected = payload .get("preview_digest") - .and_then(serde_json::Value::as_str) - else { + .and_then(serde_json::Value::as_str); + if payload.get("normalization").is_some() && expected.is_none() { + return Err(ApiError::openapi_upload(locale, "source_integrity")); + } + let Some(expected) = expected else { return Ok(()); }; 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 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(()) } else { 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( payload: &serde_json::Value, locale: OpenApiUploadLocale, diff --git a/apps/admin-api/tests/integration/openapi_import.rs b/apps/admin-api/tests/integration/openapi_import.rs index 9eda604..572254c 100644 --- a/apps/admin-api/tests/integration/openapi_import.rs +++ b/apps/admin-api/tests/integration/openapi_import.rs @@ -60,12 +60,26 @@ async fn previews_openapi_and_creates_draft_operations() { preview.preview.groups[0].operations[0].suggested_name, "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 .get_import_job(&workspace_id, &preview.job_id.as_str().into()) .await .unwrap() .unwrap(); 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 .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 { OpenApiUpload { bytes: OPENAPI3.as_bytes().to_vec(), diff --git a/crates/crank-import/src/rest/mod.rs b/crates/crank-import/src/rest/mod.rs index 04a510d..6b19b63 100644 --- a/crates/crank-import/src/rest/mod.rs +++ b/crates/crank-import/src/rest/mod.rs @@ -2,6 +2,9 @@ mod mapping; pub mod model; mod naming; mod normalize; +mod normalize_coverage; +mod normalize_limits; +mod normalize_schema; mod openapi3; mod payload; mod recommendations; @@ -10,8 +13,14 @@ mod swagger2; pub use model::{ ImportFinding, ImportFindingSeverity, ImportGroupPreview, ImportOperationCandidate, - ImportPreview, ImportSourcePreview, RestImportCandidate, RestImportDocument, - RestImportOperation, RestImportParameter, RestParameterLocation, + ImportPreview, ImportSourcePreview, NORMALIZER_VERSION, NormalizationConfig, NormalizedFinding, + 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; diff --git a/crates/crank-import/src/rest/model.rs b/crates/crank-import/src/rest/model.rs index a46a6e7..05f5176 100644 --- a/crates/crank-import/src/rest/model.rs +++ b/crates/crank-import/src/rest/model.rs @@ -1,9 +1,294 @@ +use std::collections::BTreeMap; + use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState}; use crank_mapping::MappingSet; use crank_schema::Schema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, de}; 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) -> Result { + 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(deserializer: D) -> Result + 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), + Object(BTreeMap), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NormalizedApiMetadata { + pub title: String, + pub version: Option, + pub description: Option, +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NormalizedPath { + pub construct_id: String, + pub location: SourceLocation, + pub path: String, + pub operation_ids: Vec, +} + +#[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, +} + +#[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, + pub summary: Option, + pub description: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub parameters: Vec, + pub request_body_schema: Option, + pub response_schema: Option, + #[serde(default)] + pub servers: Vec, + #[serde(default)] + pub findings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct NormalizedParameter { + pub name: String, + pub location: RestParameterLocation, + pub required: bool, + pub description: Option, + pub schema: Option, + 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, + 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, + nullable: bool, + default_value: Option, + enum_values: Vec, + }, + Object { + properties: BTreeMap, + required: Vec, + }, + Array { + items: Option>, + }, + Reference { + reference: UnresolvedReference, + }, + Composition { + operator: String, + variants: Vec, + }, +} + +#[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, + #[serde(default)] + pub paths: Vec, + #[serde(default)] + pub unresolved_references: Vec, + pub source: ImportSourcePreview, + #[serde(default)] + pub operations: Vec, + #[serde(default)] + pub findings: Vec, + #[serde(default)] + pub coverage: Vec, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ImportFindingSeverity { @@ -80,7 +365,7 @@ pub struct RestImportCandidate { pub wizard_state: Option, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RestImportDocument { pub format: String, pub version: Option, @@ -88,9 +373,11 @@ pub struct RestImportDocument { pub servers: Vec, pub operations: Vec, pub findings: Vec, + #[serde(skip)] + pub internal_finding_locations: Vec>, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RestImportOperation { pub key: String, pub method: HttpMethod, @@ -101,21 +388,36 @@ pub struct RestImportOperation { pub tags: Vec, pub parameters: Vec, pub request_body_schema: Option, + #[serde(skip)] + pub request_body_schema_location: Option, pub response_schema: Option, + #[serde(skip)] + pub response_schema_location: Option, pub servers: Vec, pub findings: Vec, } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RestImportParameter { pub name: String, pub location: RestParameterLocation, pub required: bool, pub description: Option, pub schema: Option, + #[serde(skip, default = "empty_source_location")] + pub source_location: SourceLocation, + #[serde(skip, default)] + pub schema_source_location: Option, } -#[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 { Path, Query, diff --git a/crates/crank-import/src/rest/normalize.rs b/crates/crank-import/src/rest/normalize.rs index 45ca924..46f90b6 100644 --- a/crates/crank-import/src/rest/normalize.rs +++ b/crates/crank-import/src/rest/normalize.rs @@ -4,43 +4,156 @@ use serde_json::Value; use thiserror::Error; 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, payload::candidate_from_operation, swagger2, }; -#[derive(Debug, Error)] +use super::normalize_coverage; +use super::normalize_limits; + +#[derive(Debug, Error, PartialEq, Eq)] pub enum ImportParseError { - #[error("document is not valid YAML or JSON: {0}")] - InvalidDocument(String), + #[error("document is not valid YAML or JSON")] + InvalidDocument, #[error("unsupported OpenAPI document")] UnsupportedDocument, + #[error("OpenAPI document exceeds normalization limits")] + LimitExceeded, + #[error("OpenAPI document contains no supported operations")] + NoMethods, } pub fn preview_document(document: &str) -> Result { - let yaml: serde_yaml::Value = serde_yaml::from_str(document) - .map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?; - let root = serde_json::to_value(yaml) - .map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?; + let digest = SourceDigest::parse("0".repeat(64)).expect("fixed digest is valid"); + let ir = normalize_verified_document(document, digest, &NormalizationConfig::default())?; + Ok(preview_from_ir(&ir)) +} - let normalized = if root.get("openapi").is_some() { - openapi3::parse_document(&root)? - } else if root.get("swagger").and_then(Value::as_str) == Some("2.0") { - swagger2::parse_document(&root)? +/// Compatibility projection for jobs created before `NormalizedIr`. It is +/// deliberately isolated from the v2 pipeline and may be removed only after +/// the import-job TTL has elapsed. +pub fn preview_document_legacy_v1(document: &str) -> Result { + 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 { + 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 { + 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::(document).is_ok() { + SourceSyntax::Json } 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::>(); + 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 { + 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, + mut findings: Vec, +) -> ImportPreview { let mut groups: BTreeMap = BTreeMap::new(); let mut used_names = BTreeSet::new(); - for operation in &document.operations { - let candidate = candidate_from_operation(operation, &document.servers, &mut used_names); + for operation in operations { + let candidate = candidate_from_operation(operation, &source.servers, &mut used_names); let group_title = operation .tags .first() @@ -58,36 +171,674 @@ fn preview_from_document(document: RestImportDocument) -> ImportPreview { .push(candidate); } + sort_findings(&mut findings); ImportPreview { - source: crate::rest::model::ImportSourcePreview { - format: document.format, - version: document.version, - title: document.title, - servers: document.servers, - }, + source: source.clone(), groups: groups.into_values().collect(), - findings: document.findings, + findings, } } +fn decode(document: &str) -> Result { + 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 { + let source = ImportSourcePreview { + format: document.format, + version: document.version, + title: document.title, + 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(), + }, + disposition: CoverageDisposition::Mapped, + }); + 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::>(); + 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(¶meter_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::>(); + 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::, _>>()?; + 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::>() }) + } + NormalizedSchemaKind::Object { + properties, + required, + } => { + serde_json::json!({"type":"object", "properties": properties.iter().map(|(name, schema)| (name.clone(), legacy_schema_value(schema))).collect::>(), "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::>()}) + } + 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 { if depth > 12 { return value.clone(); } - if let Some(reference) = value.get("$ref").and_then(Value::as_str) { - if let Some(resolved) = pointer(root, reference) { - return resolve_local_ref(root, resolved, depth + 1); - } - return value.clone(); + if let Some(reference) = value.get("$ref").and_then(Value::as_str) + && let Some(resolved) = pointer(root, reference) + { + return resolve_local_ref(root, resolved, depth + 1); } match value { - Value::Object(map) => { - let mut out = serde_json::Map::new(); - for (key, item) in map { - out.insert(key.clone(), resolve_local_ref(root, item, depth + 1)); - } - Value::Object(out) - } + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, item)| (key.clone(), resolve_local_ref(root, item, depth + 1))) + .collect(), + ), Value::Array(items) => Value::Array( items .iter() @@ -104,8 +855,7 @@ fn pointer<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> { } let mut current = root; for part in reference.trim_start_matches("#/").split('/') { - let part = part.replace("~1", "/").replace("~0", "~"); - current = current.get(&part)?; + current = current.get(part.replace("~1", "/").replace("~0", "~"))?; } Some(current) } diff --git a/crates/crank-import/src/rest/normalize_coverage.rs b/crates/crank-import/src/rest/normalize_coverage.rs new file mode 100644 index 0000000..f734c65 --- /dev/null +++ b/crates/crank-import/src/rest/normalize_coverage.rs @@ -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::::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::>(); + 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, + 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, + source_nodes: &BTreeMap, + 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, +) -> 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, +) -> Result { + 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, + pointer: &str, +) -> Option<&'a str> { + match &nodes.get(pointer)?.value { + CanonicalSourceValue::String(value) => Some(value), + _ => None, + } +} + +fn server_locations( + ir: &NormalizedIr, + nodes: &BTreeMap, +) -> Result, ImportParseError> { + server_locations_for_source(&ir.source, nodes) +} + +fn server_locations_for_source( + source: &ImportSourcePreview, + nodes: &BTreeMap, +) -> Result, 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, 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, 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::>(); + 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, +) -> (Vec, Vec) { + 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, +) -> (Vec, Vec) { + 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::>(), + ), + _ => 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, + expected: &mut BTreeMap, +) -> 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(¶meter.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) = ¶meter.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, + operation_pointer: &str, + tags: &[String], +) -> Result, 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::>(); + 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, + expected: &mut BTreeMap, +) -> 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, + expected: &mut BTreeMap, +) -> 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, +) -> 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, +) -> 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, +) -> Result<(), ImportParseError> { + let CanonicalSourceValue::Array(items) = ¶meters.value else { + return Ok(()); + }; + for parameter in items { + let CanonicalSourceValue::Object(fields) = ¶meter.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, +) -> 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 { + 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) { + 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, +) -> 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 { + let mut paths = BTreeMap::>::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") +} diff --git a/crates/crank-import/src/rest/normalize_limits.rs b/crates/crank-import/src/rest/normalize_limits.rs new file mode 100644 index 0000000..ac366c7 --- /dev/null +++ b/crates/crank-import/src/rest/normalize_limits.rs @@ -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, +} + +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 = 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 { + 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 +} diff --git a/crates/crank-import/src/rest/normalize_schema.rs b/crates/crank-import/src/rest/normalize_schema.rs new file mode 100644 index 0000000..81dee1c --- /dev/null +++ b/crates/crank-import/src/rest/normalize_schema.rs @@ -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, +) -> 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 { + 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, + } +} diff --git a/crates/crank-import/src/rest/openapi3.rs b/crates/crank-import/src/rest/openapi3.rs index de3395a..9d23cac 100644 --- a/crates/crank-import/src/rest/openapi3.rs +++ b/crates/crank-import/src/rest/openapi3.rs @@ -3,14 +3,18 @@ use serde_json::Value; use crate::rest::{ model::{ - ImportFinding, RestImportDocument, RestImportOperation, RestImportParameter, - RestParameterLocation, + ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation, + RestImportParameter, RestParameterLocation, }, - normalize::{ImportParseError, resolve_local_ref}, - recommendations::document_finding, + normalize::ImportParseError, + recommendations::{document_blocker, document_finding}, }; pub fn parse_document(root: &Value) -> Result { + parse_document_v2(root) +} + +fn parse_document_v2(root: &Value) -> Result { let version = root .get("openapi") .and_then(Value::as_str) @@ -20,17 +24,196 @@ pub fn parse_document(root: &Value) -> Result>() - }) - .unwrap_or_default(); + let mut findings = Vec::new(); + let mut internal_finding_locations = Vec::new(); + let servers = parse_servers(root.get("servers"), "/servers"); + append_findings_with_locations( + &mut findings, + &mut internal_finding_locations, + servers.findings, + ); + 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, + }) +} + +pub fn parse_document_legacy_v1(root: &Value) -> Result { + 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(); if servers.is_empty() { findings.push(document_finding( @@ -49,67 +232,54 @@ pub fn parse_document(root: &Value) -> Result>() - }) - .unwrap_or_default(); - + 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_value + operation_id: operation .get("operationId") .and_then(Value::as_str) .map(ToOwned::to_owned), - summary: operation_value + summary: operation .get("summary") .and_then(Value::as_str) .map(ToOwned::to_owned), - description: operation_value + description: operation .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), - tags: operation_value - .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, - request_body_schema: request_body_schema(root, operation_value), - response_schema: response_schema(root, operation_value), - servers: operation_servers, - findings: operation_findings(operation_value), + 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, @@ -117,17 +287,49 @@ pub fn parse_document(root: &Value) -> Result) -> Vec { +fn legacy_servers(value: Option<&Value>) -> Vec { value .and_then(Value::as_array) .map(|items| { items .iter() - .filter_map(|item| { - let item = resolve_local_ref(root, item, 0); + .filter_map(|item| item.get("url").and_then(Value::as_str)) + .map(|url| url.trim_end_matches('/').to_owned()) + .collect() + }) + .unwrap_or_default() +} + +fn legacy_tags(value: Option<&Value>) -> Vec { + 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 { + 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 location = match item.get("in").and_then(Value::as_str)? { "path" => RestParameterLocation::Path, "query" => RestParameterLocation::Query, @@ -146,9 +348,13 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec { .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), - schema: item - .get("schema") - .map(|schema| resolve_local_ref(root, schema, 0)), + schema: item.get("schema").map(|schema| { + crate::rest::normalize::resolve_local_ref(root, schema, 0) + }), + source_location: crate::rest::model::SourceLocation { + pointer: format!("{base_pointer}/{index}"), + }, + schema_source_location: None, }) }) .collect() @@ -156,31 +362,31 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec { .unwrap_or_default() } -fn request_body_schema(root: &Value, operation: &Value) -> Option { - let body = resolve_local_ref(root, operation.get("requestBody")?, 0); +fn legacy_request_body_schema(root: &Value, operation: &Value) -> Option { + let body = crate::rest::normalize::resolve_local_ref(root, operation.get("requestBody")?, 0); 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(resolve_local_ref(root, schema, 0)); + return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0)); } } content .iter() .find(|(content_type, _)| content_type.contains("json")) .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 { +fn legacy_response_schema(root: &Value, operation: &Value) -> Option { let responses = operation.get("responses")?.as_object()?; for code in ["200", "201", "202", "default"] { let Some(response) = responses.get(code) else { 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 { continue; }; @@ -189,7 +395,7 @@ fn response_schema(root: &Value, operation: &Value) -> Option { .get(content_type) .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 @@ -197,17 +403,350 @@ fn response_schema(root: &Value, operation: &Value) -> Option { .find(|(content_type, _)| content_type.contains("json")) .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 } +fn legacy_operation_findings(operation: &Value) -> Vec { + 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, + findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>, +} + +struct ParsedServers { + servers: Vec, + 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, + 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, + locations: &mut Vec>, + 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::().ok()) + .flatten() + .filter(|status| (200..300).contains(status)) + .map(|status| (status, code.as_str(), response)) + }) + .collect::>(); + 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::>(); + 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 { let mut findings = Vec::new(); - if operation.get("requestBody").is_some() - && request_body_schema(&Value::Null, operation).is_none() - { + if operation.get("requestBody").is_some() && request_body_schema(operation, "").is_none() { findings.push(document_finding( "unsupported_request_body", "У метода есть requestBody, но JSON schema не найдена.", @@ -226,3 +765,12 @@ fn method_from_lower(value: &str) -> Option { _ => 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, + } +} diff --git a/crates/crank-import/src/rest/recommendations.rs b/crates/crank-import/src/rest/recommendations.rs index 6460319..66e4bec 100644 --- a/crates/crank-import/src/rest/recommendations.rs +++ b/crates/crank-import/src/rest/recommendations.rs @@ -11,6 +11,15 @@ pub fn document_finding(code: &str, message: impl Into) -> ImportFinding } } +pub fn document_blocker(code: &str, message: impl Into) -> ImportFinding { + ImportFinding { + code: code.to_owned(), + severity: ImportFindingSeverity::Error, + message: message.into(), + operation_key: None, + } +} + pub fn operation_finding( operation_key: &str, code: &str, @@ -45,6 +54,9 @@ pub fn operation_recommendations(operation: &RestImportOperation) -> Vec 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, fields: BTreeMap) -> Schema { Schema { kind: SchemaKind::Object, @@ -190,14 +203,3 @@ fn text(value: &Value, key: &str) -> Option { .and_then(Value::as_str) .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() -} diff --git a/crates/crank-import/src/rest/swagger2.rs b/crates/crank-import/src/rest/swagger2.rs index 61bb401..5d736d6 100644 --- a/crates/crank-import/src/rest/swagger2.rs +++ b/crates/crank-import/src/rest/swagger2.rs @@ -2,12 +2,19 @@ use crank_core::HttpMethod; use serde_json::Value; use crate::rest::{ - model::{RestImportDocument, RestImportOperation, RestImportParameter, RestParameterLocation}, - normalize::{ImportParseError, resolve_local_ref}, - recommendations::{document_finding, operation_finding}, + model::{ + ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation, + RestImportParameter, RestParameterLocation, + }, + normalize::ImportParseError, + recommendations::{document_blocker, document_finding, operation_finding}, }; pub fn parse_document(root: &Value) -> Result { + parse_document_v2(root) +} + +fn parse_document_v2(root: &Value) -> Result { let title = root .pointer("/info/title") .and_then(Value::as_str) @@ -15,11 +22,15 @@ pub fn parse_document(root: &Value) -> Result Result Result Result Result { + 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 { + 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 { + 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 { + 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 { + 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 { let Some(host) = root.get("host").and_then(Value::as_str) else { return Vec::new(); @@ -116,14 +396,51 @@ fn swagger_servers(root: &Value) -> Vec { .collect() } -fn parameters(root: &Value, value: Option<&Value>) -> Vec { - value +fn parameters(_root: &Value, value: Option<&Value>, base_pointer: &str) -> ParsedParameters { + let mut findings = Vec::new(); + let parameters = value .and_then(Value::as_array) .map(|items| { items .iter() - .filter_map(|item| { - let item = resolve_local_ref(root, item, 0); + .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 raw_location = item.get("in").and_then(Value::as_str)?; if raw_location == "body" { return Some(RestImportParameter { @@ -137,16 +454,37 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec { .get("description") .and_then(Value::as_str) .map(ToOwned::to_owned), - schema: item - .get("schema") - .map(|schema| resolve_local_ref(root, schema, 0)), + 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: format!("{base_pointer}/{index}"), + }, }); } let location = match raw_location { "path" => RestParameterLocation::Path, "query" => RestParameterLocation::Query, "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 { name: item.get("name").and_then(Value::as_str)?.to_owned(), @@ -160,22 +498,95 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec { .get("description") .and_then(Value::as_str) .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() }) - .unwrap_or_default() + .unwrap_or_default(); + ParsedParameters { + parameters, + findings, + } } -fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option { +struct ParsedParameters { + parameters: Vec, + findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>, +} + +struct ParsedTags { + tags: Vec, + 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, + locations: &mut Vec>, + 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 { 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(); for key in ["type", "format", "items", "enum", "default", "description"] { 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() { @@ -185,18 +596,48 @@ fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option { } } -fn response_schema(root: &Value, operation: &Value) -> Option { +fn response_schema( + operation: &Value, + operation_pointer: &str, +) -> Option<(Value, crate::rest::model::SourceLocation)> { let responses = operation.get("responses")?.as_object()?; - for code in ["200", "201", "202", "default"] { - let Some(response) = responses.get(code) else { - continue; - }; - let response = resolve_local_ref(root, response, 0); - if let Some(schema) = response.get("schema") { - return Some(resolve_local_ref(root, schema, 0)); + let mut numeric = responses + .iter() + .filter_map(|(code, response)| { + (code.len() == 3) + .then(|| code.parse::().ok()) + .flatten() + .filter(|status| (200..300).contains(status)) + .map(|status| (status, code.as_str(), response)) + }) + .collect::>(); + 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( @@ -232,3 +673,12 @@ fn method_from_lower(value: &str) -> Option { _ => 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, + } +} diff --git a/crates/crank-import/tests/coverage.rs b/crates/crank-import/tests/coverage.rs new file mode 100644 index 0000000..002b60f --- /dev/null +++ b/crates/crank-import/tests/coverage.rs @@ -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::(&format!("\"{}\"", "a".repeat(64))).is_ok()); + assert!(serde_json::from_str::(&format!("\"{}\"", "A".repeat(64))).is_err()); + assert!(serde_json::from_str::("\"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, + } +} diff --git a/crates/crank-import/tests/fixtures/fuzz-regressions/invalid-root.json b/crates/crank-import/tests/fixtures/fuzz-regressions/invalid-root.json new file mode 100644 index 0000000..7ff4b4d --- /dev/null +++ b/crates/crank-import/tests/fixtures/fuzz-regressions/invalid-root.json @@ -0,0 +1 @@ +["not", "an", "openapi", "root"] diff --git a/crates/crank-import/tests/fixtures/fuzz-regressions/unsupported-version.yaml b/crates/crank-import/tests/fixtures/fuzz-regressions/unsupported-version.yaml new file mode 100644 index 0000000..1db8eda --- /dev/null +++ b/crates/crank-import/tests/fixtures/fuzz-regressions/unsupported-version.yaml @@ -0,0 +1,3 @@ +openapi: 3.2.0 +info: { title: unsupported } +paths: {} diff --git a/crates/crank-import/tests/fixtures/fuzz-regressions/wrong-version-shape.yaml b/crates/crank-import/tests/fixtures/fuzz-regressions/wrong-version-shape.yaml new file mode 100644 index 0000000..595b297 --- /dev/null +++ b/crates/crank-import/tests/fixtures/fuzz-regressions/wrong-version-shape.yaml @@ -0,0 +1,3 @@ +openapi: [wrong-root-field] +info: { title: Invalid } +paths: {} diff --git a/crates/crank-import/tests/fixtures/openapi-3.0.yaml b/crates/crank-import/tests/fixtures/openapi-3.0.yaml new file mode 100644 index 0000000..afc19e2 --- /dev/null +++ b/crates/crank-import/tests/fixtures/openapi-3.0.yaml @@ -0,0 +1,6 @@ +openapi: 3.0.3 +info: { title: Fixture OpenAPI 3.0 } +paths: + /health: + get: + responses: { '200': { description: OK } } diff --git a/crates/crank-import/tests/fixtures/openapi-3.1.json b/crates/crank-import/tests/fixtures/openapi-3.1.json new file mode 100644 index 0000000..dfc4066 --- /dev/null +++ b/crates/crank-import/tests/fixtures/openapi-3.1.json @@ -0,0 +1,9 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Fixture OpenAPI 3.1" }, + "paths": { + "/health": { + "get": { "responses": { "200": { "description": "OK" } } } + } + } +} diff --git a/crates/crank-import/tests/fixtures/swagger-2.0.yaml b/crates/crank-import/tests/fixtures/swagger-2.0.yaml new file mode 100644 index 0000000..6f5ecd5 --- /dev/null +++ b/crates/crank-import/tests/fixtures/swagger-2.0.yaml @@ -0,0 +1,6 @@ +swagger: '2.0' +info: { title: Fixture Swagger 2.0 } +paths: + /health: + get: + responses: { '200': { description: OK } } diff --git a/crates/crank-import/tests/normalization_details.rs b/crates/crank-import/tests/normalization_details.rs new file mode 100644 index 0000000..6b7f082 --- /dev/null +++ b/crates/crank-import/tests/normalization_details.rs @@ -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 { + normalize_verified_document( + document, + crank_import::rest::SourceDigest::parse("b".repeat(64)).unwrap(), + config, + ) + } + + #[test] + fn operation_identity_does_not_depend_on_operation_id() { + let document = r#" +openapi: 3.0.3 +info: { title: Identity } +paths: + /same: + get: + operationId: duplicate + responses: { '200': { description: ok } } + post: + operationId: duplicate + responses: { '201': { description: ok } } +"#; + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_ne!(ir.operations[0].stable_id, ir.operations[1].stable_id); + assert_eq!(ir.operations[0].key, "GET /same"); + assert_eq!(ir.operations[1].key, "POST /same"); + } + + #[test] + fn blank_and_duplicate_operation_ids_have_exact_findings_and_path_servers_win() { + let document = r#" +openapi: 3.0.3 +info: { title: IDs } +servers: [{ url: https://document.test }] +paths: + /one: + servers: [{ url: https://path.test }] + get: + operationId: duplicate + responses: { '200': { description: ok } } + /two: + get: + operationId: duplicate + responses: { '200': { description: ok } } + /blank: + get: + operationId: ' ' + responses: { '200': { description: ok } } +"#; + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + let duplicate = ir + .operations + .iter() + .filter(|operation| { + operation + .findings + .iter() + .any(|finding| finding.code == "duplicate_operation_id") + }) + .collect::>(); + assert_eq!(duplicate.len(), 2); + assert!(duplicate.iter().all(|operation| { + operation + .findings + .iter() + .any(|finding| finding.location.pointer.ends_with("/operationId")) + })); + let blank = ir + .operations + .iter() + .find(|operation| operation.path == "/blank") + .unwrap(); + assert!( + blank + .findings + .iter() + .any(|finding| finding.code == "missing_operation_id") + ); + let preview = preview_document(document).unwrap(); + let blank_preview = preview + .groups + .iter() + .flat_map(|group| &group.operations) + .find(|operation| operation.path == "/blank") + .unwrap(); + assert_eq!( + blank_preview + .findings + .iter() + .filter(|finding| finding.code == "missing_operation_id") + .count(), + 1 + ); + assert_eq!( + preview + .groups + .iter() + .flat_map(|group| &group.operations) + .find(|operation| operation.path == "/one") + .unwrap() + .server_urls, + vec!["https://path.test"] + ); + } + + #[test] + fn parameters_keep_exact_path_and_operation_source_pointers() { + let openapi = r#" +openapi: 3.0.3 +info: { title: Parameter locations } +paths: + /items/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: getItem + parameters: + - { name: filter, in: query, schema: { type: string } } + responses: { '200': { description: ok } } +"#; + let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap(); + let parameters = &ir.operations[0].parameters; + assert_eq!( + parameters[0].source_location.pointer, + "/paths/~1items~1{id}/parameters/0" + ); + assert_eq!( + parameters[1].source_location.pointer, + "/paths/~1items~1{id}/get/parameters/0" + ); + assert_ne!(parameters[0].construct_id, parameters[1].construct_id); + + let swagger = r#" +swagger: '2.0' +info: { title: Swagger parameter locations } +paths: + /items/{id}: + parameters: + - { name: id, in: path, required: true, type: string } + post: + operationId: updateItem + parameters: + - { name: body, in: body, schema: { type: object } } + responses: { '200': { description: ok } } +"#; + let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap(); + let parameter = &ir.operations[0].parameters[0]; + assert_eq!( + parameter.source_location.pointer, + "/paths/~1items~1{id}/parameters/0" + ); + assert!(ir.operations[0].request_body_schema.is_some()); + } + + #[test] + fn openapi_parameter_omissions_are_errors_at_the_dropped_item_pointer() { + let document = r#" +openapi: 3.1.0 +info: { title: Parameter findings } +servers: [{ url: https://example.test }] +paths: + /items/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: path_cookie, in: cookie } + - not-an-object + - { $ref: '#/components/parameters/Id' } + get: + operationId: getItem + parameters: + - { in: query } + - { name: missing_location } + - { name: form_value, in: formData } + - { name: operation_cookie, in: cookie } + - { name: query, in: query, schema: { type: string } } + responses: { '200': { description: ok } } +components: + parameters: + Id: { name: id, in: path, required: true, schema: { type: string } } +"#; + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_eq!( + ir.operations[0] + .parameters + .iter() + .map(|parameter| parameter.name.as_str()) + .collect::>(), + vec!["id", "query"] + ); + let errors = ir + .findings + .iter() + .filter(|finding| { + [ + "invalid_parameter", + "missing_parameter_name", + "missing_parameter_location", + "unsupported_parameter_location", + "unsupported_cookie_parameter", + "unresolved_parameter_reference", + ] + .contains(&finding.code.as_str()) + }) + .map(|finding| (finding.code.as_str(), finding.location.pointer.as_str())) + .collect::>(); + assert_eq!( + errors, + std::collections::BTreeSet::from([ + ( + "unsupported_cookie_parameter", + "/paths/~1items~1{id}/parameters/1", + ), + ("invalid_parameter", "/paths/~1items~1{id}/parameters/2",), + ( + "unresolved_parameter_reference", + "/paths/~1items~1{id}/parameters/3", + ), + ( + "missing_parameter_name", + "/paths/~1items~1{id}/get/parameters/0", + ), + ( + "missing_parameter_location", + "/paths/~1items~1{id}/get/parameters/1", + ), + ( + "unsupported_parameter_location", + "/paths/~1items~1{id}/get/parameters/2", + ), + ( + "unsupported_cookie_parameter", + "/paths/~1items~1{id}/get/parameters/3", + ), + ]) + ); + assert!( + ir.findings + .iter() + .filter(|finding| { + [ + "invalid_parameter", + "missing_parameter_name", + "missing_parameter_location", + "unsupported_parameter_location", + "unsupported_cookie_parameter", + "unresolved_parameter_reference", + ] + .contains(&finding.code.as_str()) + }) + .all(|finding| finding.severity == ImportFindingSeverity::Error) + ); + + let legacy = preview_document_legacy_v1(document).unwrap(); + assert!(!legacy.findings.iter().any(|finding| { + [ + "invalid_parameter", + "missing_parameter_name", + "missing_parameter_location", + "unsupported_parameter_location", + "unresolved_parameter_reference", + ] + .contains(&finding.code.as_str()) + })); + assert!( + !legacy.groups[0].operations[0] + .findings + .iter() + .any(|finding| finding.code == "unsupported_cookie_parameter") + ); + } + + #[test] + fn swagger_parameter_omissions_are_errors_at_the_dropped_item_pointer() { + let document = r#" +swagger: '2.0' +info: { title: Swagger parameter findings } +host: example.test +paths: + /items/{id}: + parameters: + - { name: id, in: path, required: true, type: string } + - { name: path_cookie, in: cookie } + - not-an-object + - { $ref: '#/parameters/Id' } + get: + operationId: getItem + parameters: + - { in: query } + - { name: missing_location } + - { name: form_value, in: formData } + - { name: operation_cookie, in: cookie } + - { name: query, in: query, type: string } + responses: { '200': { description: ok } } +parameters: + Id: { name: id, in: path, required: true, type: string } +"#; + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_eq!( + ir.operations[0] + .parameters + .iter() + .map(|parameter| parameter.name.as_str()) + .collect::>(), + vec!["id", "query"] + ); + let errors = ir + .findings + .iter() + .filter(|finding| { + [ + "invalid_parameter", + "missing_parameter_name", + "missing_parameter_location", + "unsupported_parameter_location", + "unsupported_cookie_parameter", + "unresolved_parameter_reference", + ] + .contains(&finding.code.as_str()) + }) + .map(|finding| (finding.code.as_str(), finding.location.pointer.as_str())) + .collect::>(); + assert_eq!( + errors, + std::collections::BTreeSet::from([ + ( + "unsupported_cookie_parameter", + "/paths/~1items~1{id}/parameters/1", + ), + ("invalid_parameter", "/paths/~1items~1{id}/parameters/2",), + ( + "unresolved_parameter_reference", + "/paths/~1items~1{id}/parameters/3", + ), + ( + "missing_parameter_name", + "/paths/~1items~1{id}/get/parameters/0", + ), + ( + "missing_parameter_location", + "/paths/~1items~1{id}/get/parameters/1", + ), + ( + "unsupported_parameter_location", + "/paths/~1items~1{id}/get/parameters/2", + ), + ( + "unsupported_cookie_parameter", + "/paths/~1items~1{id}/get/parameters/3", + ), + ]) + ); + assert!( + ir.findings + .iter() + .filter(|finding| { + [ + "invalid_parameter", + "missing_parameter_name", + "missing_parameter_location", + "unsupported_parameter_location", + "unsupported_cookie_parameter", + "unresolved_parameter_reference", + ] + .contains(&finding.code.as_str()) + }) + .all(|finding| finding.severity == ImportFindingSeverity::Error) + ); + } + + #[test] + fn openapi_invalid_servers_and_tags_keep_valid_siblings_with_exact_findings() { + let document = r#" +openapi: 3.1.0 +info: { title: Server and tag findings } +servers: + - { url: https://document.test/ } + - not-an-object + - {} + - { url: 42 } +paths: + /items: + servers: + - { url: https://path.test/ } + - false + - {} + get: + operationId: getItems + servers: + - { url: https://operation.test/ } + - { url: false } + tags: [items, 42, {}, null] + responses: { '200': { description: ok } } +"#; + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_eq!(ir.source.servers, vec!["https://document.test"]); + assert_eq!(ir.operations[0].servers, vec!["https://operation.test"]); + assert_eq!(ir.operations[0].tags, vec!["items"]); + + let server_errors = ir + .findings + .iter() + .filter(|finding| finding.code == "invalid_server") + .map(|finding| finding.location.pointer.as_str()) + .collect::>(); + assert_eq!( + server_errors, + std::collections::BTreeSet::from([ + "/servers/1", + "/servers/2", + "/servers/3", + "/paths/~1items/servers/1", + "/paths/~1items/servers/2", + "/paths/~1items/get/servers/1", + ]) + ); + assert!( + ir.findings + .iter() + .filter(|finding| finding.code == "invalid_server") + .all(|finding| finding.severity == ImportFindingSeverity::Error) + ); + let tag_warnings = ir + .findings + .iter() + .filter(|finding| finding.code == "invalid_tag") + .map(|finding| finding.location.pointer.as_str()) + .collect::>(); + assert_eq!( + tag_warnings, + std::collections::BTreeSet::from([ + "/paths/~1items/get/tags/1", + "/paths/~1items/get/tags/2", + "/paths/~1items/get/tags/3", + ]) + ); + assert!( + ir.findings + .iter() + .filter(|finding| finding.code == "invalid_tag") + .all(|finding| finding.severity == ImportFindingSeverity::Warning) + ); + + let legacy = preview_document_legacy_v1(document).unwrap(); + assert_eq!(legacy.source.servers, vec!["https://document.test"]); + assert_eq!( + legacy.groups[0].operations[0].server_urls, + vec!["https://operation.test"] + ); + assert_eq!(legacy.groups[0].operations[0].category, "items"); + assert!( + !legacy.findings.iter().any(|finding| { + ["invalid_server", "invalid_tag"].contains(&finding.code.as_str()) + }) + ); + } + + #[test] + fn swagger_invalid_tags_keep_valid_siblings_with_exact_warning_pointers() { + let document = r#" +swagger: '2.0' +info: { title: Swagger tag findings } +host: example.test +paths: + /items: + get: + operationId: getItems + tags: [items, 42, {}, null] + responses: { '200': { description: ok } } +"#; + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_eq!(ir.operations[0].tags, vec!["items"]); + let warnings = ir + .findings + .iter() + .filter(|finding| finding.code == "invalid_tag") + .map(|finding| finding.location.pointer.as_str()) + .collect::>(); + assert_eq!( + warnings, + std::collections::BTreeSet::from([ + "/paths/~1items/get/tags/1", + "/paths/~1items/get/tags/2", + "/paths/~1items/get/tags/3", + ]) + ); + assert!( + ir.findings + .iter() + .filter(|finding| finding.code == "invalid_tag") + .all(|finding| finding.severity == ImportFindingSeverity::Warning) + ); + + let legacy = preview_document_legacy_v1(document).unwrap(); + assert_eq!(legacy.groups[0].operations[0].category, "items"); + assert!( + !legacy + .findings + .iter() + .any(|finding| finding.code == "invalid_tag") + ); + } + + #[test] + fn v2_response_selection_uses_all_success_codes_then_openapi_wildcard_and_default() { + let openapi = r#" +openapi: 3.1.0 +info: { title: Response priority } +paths: + /numeric: + get: + operationId: numeric + responses: + '200': { description: no schema } + '203': { description: accepted, content: { application/json: { schema: { type: string } } } } + '206': { description: partial, content: { application/json: { schema: { type: integer } } } } + /wildcard: + get: + operationId: wildcard + responses: + '200': { description: unsupported, content: { text/plain: { schema: { type: string } } } } + '2xX': { description: wildcard, content: { application/json: { schema: { type: boolean } } } } + default: { description: fallback, content: { application/json: { schema: { type: string } } } } + /default: + get: + operationId: fallback + responses: + '203': { description: no schema, content: { application/json: {} } } + default: { description: fallback, content: { application/json: { schema: { type: number } } } } +"#; + let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap(); + let response_pointer = |path: &str| { + ir.operations + .iter() + .find(|operation| operation.path == path) + .unwrap() + .response_schema + .as_ref() + .unwrap() + .location + .pointer + .as_str() + }; + assert_eq!( + response_pointer("/numeric"), + "/paths/~1numeric/get/responses/203/content/application~1json/schema" + ); + assert_eq!( + response_pointer("/wildcard"), + "/paths/~1wildcard/get/responses/2xX/content/application~1json/schema" + ); + assert_eq!( + response_pointer("/default"), + "/paths/~1default/get/responses/default/content/application~1json/schema" + ); + + let swagger = r#" +swagger: '2.0' +info: { title: Swagger response priority } +paths: + /numeric: + get: + operationId: numeric + responses: + '200': { description: no schema } + '206': { description: partial, schema: { type: integer } } + default: { description: fallback, schema: { type: string } } + /no-wildcard: + get: + operationId: noWildcard + responses: + '2XX': { description: ignored wildcard, schema: { type: string } } + default: { description: fallback, schema: { type: boolean } } +"#; + let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap(); + let response_pointer = |path: &str| { + ir.operations + .iter() + .find(|operation| operation.path == path) + .unwrap() + .response_schema + .as_ref() + .unwrap() + .location + .pointer + .as_str() + }; + assert_eq!( + response_pointer("/numeric"), + "/paths/~1numeric/get/responses/206/schema" + ); + assert_eq!( + response_pointer("/no-wildcard"), + "/paths/~1no-wildcard/get/responses/default/schema" + ); + } + + #[test] + fn schemas_keep_selected_source_pointers_and_pointer_derived_ids() { + let openapi = r#" +openapi: 3.1.0 +info: { title: Schema locations } +paths: + /items: + post: + operationId: updateItems + requestBody: + content: + application/vnd.example+json: + schema: + type: object + properties: + a/b: { type: string } + a~1b: { type: string } + responses: + '201': + description: created + content: + application/vnd.example+json: + schema: { type: integer } +"#; + let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap(); + let operation = &ir.operations[0]; + assert_eq!( + operation + .request_body_schema + .as_ref() + .unwrap() + .location + .pointer, + "/paths/~1items/post/requestBody/content/application~1vnd.example+json/schema" + ); + assert_eq!( + operation.response_schema.as_ref().unwrap().location.pointer, + "/paths/~1items/post/responses/201/content/application~1vnd.example+json/schema" + ); + let crank_import::rest::NormalizedSchemaKind::Object { properties, .. } = + &operation.request_body_schema.as_ref().unwrap().kind + else { + panic!("request schema must be object"); + }; + assert_ne!( + properties["a/b"].construct_id, + properties["a~1b"].construct_id + ); + + let swagger = r#" +swagger: '2.0' +info: { title: Swagger schema locations } +paths: + /items: + post: + operationId: updateItems + parameters: + - in: body + name: body + schema: { type: object } + responses: + '201': { description: created, schema: { type: integer } } +"#; + let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap(); + assert_eq!( + ir.operations[0] + .request_body_schema + .as_ref() + .unwrap() + .location + .pointer, + "/paths/~1items/post/parameters/0/schema" + ); + assert_eq!( + ir.operations[0] + .response_schema + .as_ref() + .unwrap() + .location + .pointer, + "/paths/~1items/post/responses/201/schema" + ); + } + + #[test] + fn preserves_every_unresolved_reference_from_the_full_source_tree() { + let document = r#" +openapi: 3.1.0 +info: { title: References } +paths: + /ok: + parameters: + - { $ref: '#/components/parameters/Id' } + get: + operationId: getOk + requestBody: { $ref: '#/components/requestBodies/Body' } + responses: + '200': { $ref: '#/components/responses/Ok' } + /reused: + $ref: '#/components/pathItems/Reusable' +components: + schemas: + Loop: { $ref: '#/components/schemas/Loop' } + Remote: { $ref: 'https://example.test/schema.json#/Remote' } + parameters: + Id: { $ref: '#/components/parameters/Id' } + requestBodies: + Body: { $ref: '#/components/requestBodies/Body' } + responses: + Ok: { $ref: '#/components/responses/Ok' } + pathItems: + Reusable: { $ref: '#/components/pathItems/Reusable' } +"#; + let first = normalize_document(document, &NormalizationConfig::default()).unwrap(); + let second = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_eq!( + serde_json::to_vec(&first).unwrap(), + serde_json::to_vec(&second).unwrap() + ); + assert_eq!(first.unresolved_references.len(), 10); + let references = first + .unresolved_references + .iter() + .map(|reference| (reference.uri.as_str(), reference.location.pointer.as_str())) + .collect::>(); + assert!(references.contains(&( + "#/components/parameters/Id", + "/paths/~1ok/parameters/0/$ref" + ))); + assert!(references.contains(&( + "#/components/requestBodies/Body", + "/paths/~1ok/get/requestBody/$ref" + ))); + assert!(references.contains(&( + "#/components/responses/Ok", + "/paths/~1ok/get/responses/200/$ref" + ))); + assert!(references.contains(&("#/components/pathItems/Reusable", "/paths/~1reused/$ref"))); + assert!( + references.contains(&("#/components/schemas/Loop", "/components/schemas/Loop/$ref")) + ); + assert!(references.contains(&( + "https://example.test/schema.json#/Remote", + "/components/schemas/Remote/$ref" + ))); + assert!(first.unresolved_references.iter().all(|reference| { + reference.construct_id + == format!( + "reference:{}", + reference + .location + .pointer + .replace('~', "~0") + .replace('/', "~1") + ) + && first.coverage.iter().any(|entry| { + entry.construct_id == reference.construct_id + && entry.location == reference.location + }) + })); + } +} diff --git a/crates/crank-import/tests/preview.rs b/crates/crank-import/tests/preview.rs new file mode 100644 index 0000000..be83a0f --- /dev/null +++ b/crates/crank-import/tests/preview.rs @@ -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::>() + .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::>(); + 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::>(); + + 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::>(); + + 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::>(); + + 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")); + } +} diff --git a/crates/crank-import/tests/unit.rs b/crates/crank-import/tests/unit.rs index 456654a..840d9b0 100644 --- a/crates/crank-import/tests/unit.rs +++ b/crates/crank-import/tests/unit.rs @@ -1,44 +1,8 @@ mod unit { - use crank_core::HttpMethod; - use crank_import::rest::preview_document; - - const OPENAPI3: &str = r#" -openapi: 3.0.3 -info: - title: Frankfurter API -servers: - - url: https://api.frankfurter.dev -paths: - /v2/latest: - get: - operationId: getLatestRates - summary: Получить последние курсы - description: Возвращает последние курсы валют для базовой валюты. - tags: [currency] - parameters: - - name: base - in: query - required: true - schema: - type: string - - name: symbols - in: query - schema: - type: string - responses: - '200': - description: OK - content: - application/json: - schema: - type: object - required: [amount, base] - properties: - amount: - type: number - base: - type: string -"#; + use crank_import::rest::{ + ImportFindingSeverity, ImportParseError, NormalizationConfig, normalize_verified_document, + preview_document, + }; const SWAGGER2: &str = r#" swagger: "2.0" @@ -74,170 +38,543 @@ definitions: type: string "#; - #[test] - fn previews_openapi3_rest_operations_grouped_by_tag() { - let preview = preview_document(OPENAPI3).unwrap(); + fn normalize_document( + document: &str, + config: &NormalizationConfig, + ) -> Result { + normalize_verified_document( + document, + crank_import::rest::SourceDigest::parse("b".repeat(64)).unwrap(), + config, + ) + } - assert_eq!(preview.source.format, "openapi"); - assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]); - assert_eq!(preview.groups.len(), 1); - assert_eq!(preview.groups[0].key, "currency"); - let operation = &preview.groups[0].operations[0]; - assert_eq!(operation.method, HttpMethod::Get); - assert_eq!(operation.suggested_name, "get_latest_rates"); - assert_eq!(operation.input_fields, 2); - assert_eq!(operation.output_fields, 2); - assert_eq!(operation.draft.target.path_template, "/v2/latest"); - assert_eq!(operation.draft.input_mapping.rules.len(), 2); + fn matrix_source() -> String { + "openapi: 3.1.0\ninfo: { title: Matrix }\npaths:\n /ok:\n get:\n operationId: getOk\n tags: [matrix]\n parameters: [{ name: q, in: query, schema: { type: string } }]\n responses: { '200': { description: ok, content: { application/json: { schema: { type: object, properties: { value: { type: string } } } } } } }".to_owned() } #[test] - fn previews_swagger2_and_resolves_definitions() { - let preview = preview_document(SWAGGER2).unwrap(); + fn normalizes_supported_versions_with_canonical_order_and_stable_ids() { + let fixture_matrix = [ + (include_str!("fixtures/openapi-3.0.yaml"), "3.0.3"), + (include_str!("fixtures/openapi-3.1.json"), "3.1.0"), + (include_str!("fixtures/swagger-2.0.yaml"), "2.0"), + ]; + for (fixture, version) in fixture_matrix { + assert_eq!( + normalize_document(fixture, &NormalizationConfig::default()) + .unwrap() + .source + .version + .as_deref(), + Some(version) + ); + } + let openapi_31_json = r#"{ + "openapi":"3.1.0", "info":{"title":"Canonical"}, + "paths": { + "/z":{"post":{"responses":{"201":{"description":"ok"}}}}, + "/a":{"get":{"responses":{"200":{"description":"ok"}}}} + } + }"#; + let openapi_30_yaml = r#" +openapi: 3.0.3 +info: { title: Canonical } +paths: + /a: + get: + responses: { '200': { description: ok } } +"#; + let swagger = r#" +swagger: '2.0' +info: { title: Legacy } +paths: + /a: + get: + responses: { '200': { description: ok } } +"#; - assert_eq!(preview.source.format, "swagger"); + let first = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap(); + let second = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap(); assert_eq!( - preview.source.servers, - vec!["https://petstore.example.com/api"] + serde_json::to_vec(&first).unwrap(), + serde_json::to_vec(&second).unwrap() ); - let operation = &preview.groups[0].operations[0]; - assert_eq!(operation.suggested_name, "get_pet"); - assert_eq!(operation.input_fields, 1); - assert_eq!(operation.output_fields, 2); - assert_eq!(operation.draft.target.path_template, "/pets/{id}"); + assert_eq!(first.operations[0].path, "/a"); + assert_eq!(first.operations[0].stable_id, "3.1.0:get:/paths/~1a/get"); + assert!(first.coverage.len() >= 2); assert_eq!( - operation.draft.input_mapping.rules[0].target, - "$.request.path.id" + normalize_document(openapi_30_yaml, &NormalizationConfig::default()) + .unwrap() + .source + .version + .as_deref(), + Some("3.0.3") + ); + assert_eq!( + normalize_document(swagger, &NormalizationConfig::default()) + .unwrap() + .source + .version + .as_deref(), + Some("2.0") ); } #[test] - fn reports_missing_descriptions_as_recommendations() { - let document = r#" -openapi: 3.0.3 -info: { title: Minimal API } -paths: - /items: - get: - responses: - '204': { description: Empty } -"#; - - let preview = preview_document(document).unwrap(); - let operation = &preview.groups[0].operations[0]; - let codes = operation - .findings - .iter() - .map(|finding| finding.code.as_str()) - .collect::>(); - - assert!(codes.contains(&"missing_operation_id")); - assert!(codes.contains(&"missing_summary")); - assert!(codes.contains(&"missing_description")); - assert!(codes.contains(&"missing_response_schema")); + fn keeps_references_typed_without_resolving_them() { + let ir = normalize_document(SWAGGER2, &NormalizationConfig::default()).unwrap(); + let operation = &ir.operations[0]; + assert!(matches!( + operation + .response_schema + .as_ref() + .map(|schema| &schema.kind), + Some(crank_import::rest::NormalizedSchemaKind::Reference { .. }) + )); + assert!( + operation + .findings + .iter() + .any(|finding| finding.code == "unresolved_reference") + ); } #[test] - fn expands_json_request_body_object_into_tool_inputs() { + fn preserves_valid_operations_when_another_path_item_is_malformed() { let document = r#" openapi: 3.0.3 -info: { title: CRM API } -servers: - - url: https://crm.example.test +info: { title: Partial } paths: - /leads: - post: - operationId: createLead - summary: Создать лид - description: Создает лид в CRM. - tags: [crm] - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [email] - properties: - email: { type: string } - name: { type: string } - responses: - '201': - description: Created - content: - application/json: - schema: - type: object - properties: - id: { type: string } + /valid: + get: + responses: { '200': { description: ok } } + /broken: invalid "#; - - let preview = preview_document(document).unwrap(); - let operation = &preview.groups[0].operations[0]; - let input_fields = &operation.draft.input_schema.fields; - let targets = operation - .draft - .input_mapping - .rules - .iter() - .map(|rule| rule.target.as_str()) - .collect::>(); - - assert!(input_fields.contains_key("email")); - assert!(input_fields.contains_key("name")); - assert!(targets.contains(&"$.request.body.email")); - assert!(targets.contains(&"$.request.body.name")); + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_eq!(ir.operations.len(), 1); + assert!(ir.findings.iter().any(|finding| { + finding.code == "invalid_path_item" + && finding.severity == crank_import::rest::ImportFindingSeverity::Error + })); + assert!(ir.findings.iter().any(|finding| { + finding.code == "invalid_path_item" + && finding.location.pointer == "/paths/~1broken" + && finding.construct_id.contains("/paths/~1broken") + })); } #[test] - fn reports_tool_quality_recommendations_for_imported_operations() { + fn malformed_method_values_are_blockers_while_valid_siblings_survive() { + for (format, document) in [ + ( + "openapi", + "openapi: 3.0.3\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }", + ), + ( + "swagger", + "swagger: '2.0'\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }", + ), + ] { + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); + assert_eq!(ir.operations.len(), 1, "{format}"); + assert_eq!(ir.operations[0].key, "GET /mixed", "{format}"); + let finding = ir + .findings + .iter() + .find(|finding| finding.code == "invalid_operation") + .unwrap(); + assert_eq!(finding.severity, ImportFindingSeverity::Error, "{format}"); + assert_eq!(finding.location.pointer, "/paths/~1mixed/post", "{format}"); + } + } + + #[test] + fn rejects_hostile_input_at_configured_limits() { let document = r#" openapi: 3.0.3 -info: { title: Wide API } +info: { title: Too deep } +paths: {} +"#; + let config = NormalizationConfig { + max_bytes: 10, + ..NormalizationConfig::default() + }; + assert_eq!( + normalize_document(document, &config), + Err(ImportParseError::LimitExceeded) + ); + + let unsupported = "openapi: 3.2.0\ninfo: { title: Nope }\npaths: {}\n"; + assert_eq!( + normalize_document(unsupported, &NormalizationConfig::default()), + Err(ImportParseError::UnsupportedDocument) + ); + } + + #[test] + fn checked_in_hostile_regressions_are_bounded_and_redacted() { + // Minimal regression corpus distilled from malformed/fuzzed inputs. + // Assertions deliberately compare typed errors, never parser text. + for document in [ + include_str!("fixtures/fuzz-regressions/invalid-root.json"), + include_str!("fixtures/fuzz-regressions/unsupported-version.yaml"), + include_str!("fixtures/fuzz-regressions/wrong-version-shape.yaml"), + ] { + let error = normalize_document(document, &NormalizationConfig::default()).unwrap_err(); + assert!(matches!( + error, + ImportParseError::InvalidDocument | ImportParseError::UnsupportedDocument + )); + assert!(!error.to_string().contains("wrong-root-field")); + } + } + + #[test] + fn verified_digest_stays_inside_ir_and_unknown_contract_fails_closed() { + let digest = crank_import::rest::SourceDigest::parse("a".repeat(64)).unwrap(); + let ir = crank_import::rest::normalize_verified_document( + include_str!("fixtures/openapi-3.1.json"), + digest, + &NormalizationConfig::default(), + ) + .unwrap(); + assert_eq!(ir.source_identity.digest.as_str(), "a".repeat(64)); + let preview = crank_import::rest::preview_from_ir(&ir); + assert!( + !serde_json::to_string(&preview) + .unwrap() + .contains("source_identity") + ); + assert!( + !serde_json::to_string(&preview) + .unwrap() + .contains(&"a".repeat(64)) + ); + let config = NormalizationConfig { + normalizer_version: "future".to_owned(), + ..NormalizationConfig::default() + }; + assert_eq!( + normalize_document(include_str!("fixtures/openapi-3.1.json"), &config), + Err(ImportParseError::UnsupportedDocument) + ); + } + + #[test] + fn yaml_alias_preflight_counts_inline_aliases_but_not_quotes_or_comments() { + let mut document = + "openapi: 3.0.3\ninfo: { title: aliases }\npaths: {}\nitems: [".to_owned(); + document.push_str( + &std::iter::repeat_n("*a", 129) + .collect::>() + .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::>() + .join(", "), + ); + escaped_quote.push(']'); + assert_eq!( + normalize_document(&escaped_quote, &NormalizationConfig::default()), + Err(ImportParseError::LimitExceeded) + ); + + let harmless = r#"openapi: 3.0.3 +info: + title: 'it''s *not_an_alias' + description: "escaped \" *also_not_an_alias" paths: - /items: + /ok: + get: + responses: { '200': { description: ok } } +"#; + assert!(normalize_document(harmless, &NormalizationConfig::default()).is_ok()); + } + + #[test] + fn yaml_alias_preflight_fails_closed_for_stars_in_block_scalars() { + let hidden_alias = r#"openapi: 3.0.3 +info: + title: Block scalar + description: | + this text contains *an_alias_like_token +paths: + /ok: + get: + responses: { '200': { description: ok } } +"#; + assert_eq!( + normalize_document(hidden_alias, &NormalizationConfig::default()), + Err(ImportParseError::LimitExceeded) + ); + + let safe_block = r#"openapi: 3.0.3 +info: + title: Block scalar + description: | + this text contains no alias-looking marker +paths: + /ok: + get: + responses: { '200': { description: ok } } +"#; + assert!(normalize_document(safe_block, &NormalizationConfig::default()).is_ok()); + } + + #[test] + fn typed_scalar_literals_round_trip_without_stringification() { + let document = r#" +openapi: 3.1.0 +info: { title: Scalars } +paths: + /value: get: - operationId: getItems - summary: Get items - description: Get items. - parameters: - - name: page - in: query - schema: { type: integer } responses: '200': - description: OK + description: ok content: application/json: schema: type: object properties: - field01: { type: string } - field02: { type: string } - field03: { type: string } - field04: { type: string } - field05: { type: string } - field06: { type: string } - field07: { type: string } - field08: { type: string } - field09: { type: string } - field10: { type: string } - field11: { type: string } - field12: { type: string } - field13: { type: string } + count: { type: integer, default: 7, enum: [1, 2] } + enabled: { type: boolean, default: true, enum: [true, false] } + ratio: { type: number, default: 1.5, enum: [0.5, 1.5] } "#; - + let ir = normalize_document(document, &NormalizationConfig::default()).unwrap(); let preview = preview_document(document).unwrap(); - let operation = &preview.groups[0].operations[0]; - let codes = operation + let fields = &preview.groups[0].operations[0].draft.output_schema.fields; + assert_eq!(fields["count"].kind, crank_schema::SchemaKind::Integer); + assert_eq!(fields["enabled"].kind, crank_schema::SchemaKind::Boolean); + let rendered = serde_json::to_value(&ir.operations[0].response_schema).unwrap(); + assert!(rendered.to_string().contains("integer")); + assert!(rendered.to_string().contains("1.5")); + } + + #[test] + fn coverage_validator_rejects_nested_gap_and_duplicate_findings() { + let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap(); + let nested = ir.operations[0] + .response_schema + .as_ref() + .unwrap() + .construct_id + .clone(); + ir.coverage.retain(|entry| entry.construct_id != nested); + assert_eq!( + crank_import::rest::validate_normalized_ir(&ir), + Err(ImportParseError::InvalidDocument) + ); + let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap(); + ir.coverage + .retain(|entry| entry.construct_id != "source:/paths/~1ok/get/responses/200"); + assert_eq!( + crank_import::rest::validate_normalized_ir(&ir), + Err(ImportParseError::InvalidDocument) + ); + let ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap(); + let findings = &ir.operations[0].findings; + assert_eq!( + findings.len(), + findings + .iter() + .map(|finding| (&finding.code, &finding.construct_id)) + .collect::>() + .len() + ); + } + + #[test] + fn two_malformed_paths_keep_distinct_internal_locations() { + let ir = normalize_document( + "openapi: 3.0.3\ninfo: { title: broken }\npaths: { /one: bad, /two: bad }", + &NormalizationConfig::default(), + ) + .unwrap_err(); + assert_eq!(ir, ImportParseError::NoMethods); + // Structural parser findings require at least one valid operation to return IR. + let ir = normalize_document("openapi: 3.0.3\ninfo: { title: broken }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } }, /one: bad, /two: bad }", &NormalizationConfig::default()).unwrap(); + let pointers = ir .findings .iter() - .map(|finding| finding.code.as_str()) - .collect::>(); + .filter(|finding| finding.code == "invalid_path_item") + .map(|finding| finding.location.pointer.as_str()) + .collect::>(); + assert_eq!( + pointers, + std::collections::BTreeSet::from(["/paths/~1one", "/paths/~1two"]) + ); + } - assert!(codes.contains(&"parameter_descriptions_missing")); - assert!(codes.contains(&"weak_tool_description")); - assert!(codes.contains(&"weak_tool_name")); - assert!(codes.contains(&"too_many_output_fields")); + #[test] + fn canonical_matrix_covers_all_supported_versions_syntaxes_and_concurrency() { + let matrix = [ + ( + "oas30-yaml", + "openapi: 3.0.3\ninfo: { title: OAS30 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }", + ), + ( + "oas30-json", + r#"{"openapi":"3.0.3","info":{"title":"OAS30"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, + ), + ( + "oas31-yaml", + "openapi: 3.1.0\ninfo: { title: OAS31 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }", + ), + ( + "oas31-json", + r#"{"openapi":"3.1.0","info":{"title":"OAS31"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, + ), + ( + "swagger-yaml", + "swagger: '2.0'\ninfo: { title: Swagger }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }", + ), + ( + "swagger-json", + r#"{"swagger":"2.0","info":{"title":"Swagger"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#, + ), + ]; + for (name, source) in matrix { + let first = normalize_document(source, &NormalizationConfig::default()).unwrap(); + let second = normalize_document(source, &NormalizationConfig::default()).unwrap(); + assert_eq!( + serde_json::to_vec(&first).unwrap(), + serde_json::to_vec(&second).unwrap(), + "{name}" + ); + } + let source = std::sync::Arc::new(matrix_source()); + let results = (0..8) + .map(|_| { + let source = std::sync::Arc::clone(&source); + std::thread::spawn(move || { + serde_json::to_vec( + &normalize_document(&source, &NormalizationConfig::default()).unwrap(), + ) + .unwrap() + }) + }) + .map(|thread| thread.join().unwrap()) + .collect::>(); + 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::>() + .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 { .. }) + )); + } } }