feat(import): add deterministic OpenAPI normalized IR
This commit is contained in:
@@ -6,7 +6,8 @@ use crank_core::{
|
|||||||
WorkspaceId,
|
WorkspaceId,
|
||||||
};
|
};
|
||||||
use crank_import::rest::{
|
use crank_import::rest::{
|
||||||
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, operation_draft_from_candidate,
|
ImportFinding, ImportFindingSeverity, ImportOperationCandidate, NORMALIZER_VERSION,
|
||||||
|
PROJECTION_VERSION, operation_draft_from_candidate,
|
||||||
};
|
};
|
||||||
use crank_registry::{
|
use crank_registry::{
|
||||||
ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity,
|
ApplyImportJobRequest, ArtifactSourceId, ArtifactSourceSensitivity,
|
||||||
@@ -94,14 +95,22 @@ impl AdminService {
|
|||||||
detach_guard.detach_now().await;
|
detach_guard.detach_now().await;
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||||
}
|
}
|
||||||
let preview = match parse_verified_preview(verified.bytes, locale).await {
|
let parsed = match parse_verified_preview(
|
||||||
|
verified.bytes,
|
||||||
|
artifact.artifact_ref().digest_hex().to_owned(),
|
||||||
|
locale,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(preview) => preview,
|
Ok(preview) => preview,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
detach_guard.detach_now().await;
|
detach_guard.detach_now().await;
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if preview
|
if parsed
|
||||||
|
.preview
|
||||||
.groups
|
.groups
|
||||||
.iter()
|
.iter()
|
||||||
.all(|group| group.operations.is_empty())
|
.all(|group| group.operations.is_empty())
|
||||||
@@ -113,7 +122,7 @@ impl AdminService {
|
|||||||
source_id,
|
source_id,
|
||||||
digest: artifact.artifact_ref().clone(),
|
digest: artifact.artifact_ref().clone(),
|
||||||
};
|
};
|
||||||
let preview_value = serde_json::to_value(&preview)
|
let preview_value = serde_json::to_value(&parsed.preview)
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?;
|
.map_err(|error| ApiError::internal(error.to_string()))?;
|
||||||
let preview_digest = preview_digest(&preview_value)?;
|
let preview_digest = preview_digest(&preview_value)?;
|
||||||
let preview_payload = json!({
|
let preview_payload = json!({
|
||||||
@@ -123,6 +132,11 @@ impl AdminService {
|
|||||||
},
|
},
|
||||||
"preview": preview_value,
|
"preview": preview_value,
|
||||||
"preview_digest": preview_digest,
|
"preview_digest": preview_digest,
|
||||||
|
"normalization": {
|
||||||
|
"normalizer_version": NORMALIZER_VERSION,
|
||||||
|
"projection_version": PROJECTION_VERSION,
|
||||||
|
"ir_fingerprint": parsed.ir_fingerprint,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Err(error) = self
|
if let Err(error) = self
|
||||||
@@ -131,8 +145,8 @@ impl AdminService {
|
|||||||
id: &job_id,
|
id: &job_id,
|
||||||
workspace_id,
|
workspace_id,
|
||||||
kind: ImportJobKind::OpenApi,
|
kind: ImportJobKind::OpenApi,
|
||||||
source_format: &preview.source.format,
|
source_format: &parsed.preview.source.format,
|
||||||
source_version: preview.source.version.as_deref(),
|
source_version: parsed.preview.source.version.as_deref(),
|
||||||
status: ImportJobStatus::Pending,
|
status: ImportJobStatus::Pending,
|
||||||
source: &source_envelope,
|
source: &source_envelope,
|
||||||
preview_payload: &preview_payload,
|
preview_payload: &preview_payload,
|
||||||
@@ -151,7 +165,7 @@ impl AdminService {
|
|||||||
expires_at: expires_at
|
expires_at: expires_at
|
||||||
.format(&Rfc3339)
|
.format(&Rfc3339)
|
||||||
.map_err(|error| ApiError::internal(error.to_string()))?,
|
.map_err(|error| ApiError::internal(error.to_string()))?,
|
||||||
preview,
|
preview: parsed.preview,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,10 +291,20 @@ impl AdminService {
|
|||||||
if verified.source.blob.artifact_ref != source.digest {
|
if verified.source.blob.artifact_ref != source.digest {
|
||||||
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||||
}
|
}
|
||||||
let preview = parse_verified_preview(verified.bytes, locale).await?;
|
let legacy_v1 = job.preview_payload.get("normalization").is_none();
|
||||||
verify_preview_contract(&job.preview_payload, &preview, locale)?;
|
let parsed = parse_verified_preview(
|
||||||
|
verified.bytes,
|
||||||
|
source.digest.digest_hex().to_owned(),
|
||||||
|
locale,
|
||||||
|
legacy_v1,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
verify_preview_contract(&job.preview_payload, &parsed, locale)?;
|
||||||
|
if preview_has_blocker(&parsed.preview) {
|
||||||
|
return Err(ApiError::openapi_upload(locale, "invalid_document"));
|
||||||
|
}
|
||||||
let mut candidates = BTreeMap::new();
|
let mut candidates = BTreeMap::new();
|
||||||
for group in &preview.groups {
|
for group in &parsed.preview.groups {
|
||||||
for operation in &group.operations {
|
for operation in &group.operations {
|
||||||
candidates.insert(operation.key.clone(), operation);
|
candidates.insert(operation.key.clone(), operation);
|
||||||
}
|
}
|
||||||
@@ -441,10 +465,17 @@ fn validate_openapi_upload(upload: &OpenApiUpload) -> Result<(), ApiError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ParsedOpenApiPreview {
|
||||||
|
preview: crank_import::rest::ImportPreview,
|
||||||
|
ir_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
async fn parse_verified_preview(
|
async fn parse_verified_preview(
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
|
digest: String,
|
||||||
locale: OpenApiUploadLocale,
|
locale: OpenApiUploadLocale,
|
||||||
) -> Result<crank_import::rest::ImportPreview, ApiError> {
|
legacy_v1: bool,
|
||||||
|
) -> Result<ParsedOpenApiPreview, ApiError> {
|
||||||
let started = tokio::time::Instant::now();
|
let started = tokio::time::Instant::now();
|
||||||
let permit = tokio::time::timeout(OPENAPI_PARSE_DEADLINE, OPENAPI_PARSE_SLOTS.acquire())
|
let permit = tokio::time::timeout(OPENAPI_PARSE_DEADLINE, OPENAPI_PARSE_SLOTS.acquire())
|
||||||
.await
|
.await
|
||||||
@@ -457,8 +488,32 @@ async fn parse_verified_preview(
|
|||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let document = std::str::from_utf8(&bytes)
|
let document = std::str::from_utf8(&bytes)
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?;
|
.map_err(|_| ApiError::openapi_upload(locale, "invalid_utf8"))?;
|
||||||
crank_import::rest::preview_document(document)
|
if legacy_v1 {
|
||||||
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"))
|
return crank_import::rest::preview_document_legacy_v1(document)
|
||||||
|
.map(|preview| ParsedOpenApiPreview {
|
||||||
|
preview,
|
||||||
|
ir_fingerprint: String::new(),
|
||||||
|
})
|
||||||
|
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"));
|
||||||
|
}
|
||||||
|
let digest = crank_import::rest::SourceDigest::parse(digest)
|
||||||
|
.map_err(|_| ApiError::openapi_upload(locale, "source_integrity"))?;
|
||||||
|
let ir =
|
||||||
|
crank_import::rest::normalize_verified_document(document, digest, &Default::default())
|
||||||
|
.map_err(|error| match error {
|
||||||
|
crank_import::rest::ImportParseError::NoMethods => {
|
||||||
|
ApiError::openapi_upload(locale, "no_methods")
|
||||||
|
}
|
||||||
|
_ => ApiError::openapi_upload(locale, "invalid_document"),
|
||||||
|
})?;
|
||||||
|
let ir_fingerprint = preview_digest(
|
||||||
|
&serde_json::to_value(&ir)
|
||||||
|
.map_err(|_| ApiError::openapi_upload(locale, "invalid_document"))?,
|
||||||
|
)?;
|
||||||
|
Ok::<_, ApiError>(ParsedOpenApiPreview {
|
||||||
|
preview: crank_import::rest::preview_from_ir(&ir),
|
||||||
|
ir_fingerprint,
|
||||||
|
})
|
||||||
});
|
});
|
||||||
let remaining = OPENAPI_PARSE_DEADLINE
|
let remaining = OPENAPI_PARSE_DEADLINE
|
||||||
.checked_sub(started.elapsed())
|
.checked_sub(started.elapsed())
|
||||||
@@ -499,28 +554,63 @@ fn preview_digest(preview: &serde_json::Value) -> Result<String, ApiError> {
|
|||||||
|
|
||||||
fn verify_preview_contract(
|
fn verify_preview_contract(
|
||||||
payload: &serde_json::Value,
|
payload: &serde_json::Value,
|
||||||
preview: &crank_import::rest::ImportPreview,
|
parsed: &ParsedOpenApiPreview,
|
||||||
locale: OpenApiUploadLocale,
|
locale: OpenApiUploadLocale,
|
||||||
) -> Result<(), ApiError> {
|
) -> Result<(), ApiError> {
|
||||||
// Pre-fingerprint jobs are legacy rolling-upgrade records. They retain the
|
// Pre-fingerprint jobs are legacy rolling-upgrade records. They retain the
|
||||||
// old reparse behavior; new jobs fail closed if parser output drifts or a
|
// old reparse behavior; new jobs fail closed if parser output drifts or a
|
||||||
// persisted preview has been changed.
|
// persisted preview has been changed.
|
||||||
let Some(expected) = payload
|
let expected = payload
|
||||||
.get("preview_digest")
|
.get("preview_digest")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str);
|
||||||
else {
|
if payload.get("normalization").is_some() && expected.is_none() {
|
||||||
|
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||||
|
}
|
||||||
|
let Some(expected) = expected else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
let actual = preview_digest(
|
let actual = preview_digest(
|
||||||
&serde_json::to_value(preview).map_err(|error| ApiError::internal(error.to_string()))?,
|
&serde_json::to_value(&parsed.preview)
|
||||||
|
.map_err(|error| ApiError::internal(error.to_string()))?,
|
||||||
)?;
|
)?;
|
||||||
if actual == expected {
|
if actual == expected {
|
||||||
|
if let Some(normalization) = payload.get("normalization") {
|
||||||
|
let normalizer = normalization
|
||||||
|
.get("normalizer_version")
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
let projection = normalization
|
||||||
|
.get("projection_version")
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
let fingerprint = normalization
|
||||||
|
.get("ir_fingerprint")
|
||||||
|
.and_then(serde_json::Value::as_str);
|
||||||
|
if normalizer != Some(NORMALIZER_VERSION)
|
||||||
|
|| projection != Some(PROJECTION_VERSION)
|
||||||
|
|| fingerprint != Some(parsed.ir_fingerprint.as_str())
|
||||||
|
{
|
||||||
|
return Err(ApiError::openapi_upload(locale, "source_integrity"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
Err(ApiError::openapi_upload(locale, "source_integrity"))
|
Err(ApiError::openapi_upload(locale, "source_integrity"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn preview_has_blocker(preview: &crank_import::rest::ImportPreview) -> bool {
|
||||||
|
preview
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.chain(
|
||||||
|
preview
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| group.operations.iter())
|
||||||
|
.flat_map(|operation| operation.findings.iter()),
|
||||||
|
)
|
||||||
|
.any(|finding| finding.severity == ImportFindingSeverity::Error)
|
||||||
|
}
|
||||||
|
|
||||||
fn import_job_source(
|
fn import_job_source(
|
||||||
payload: &serde_json::Value,
|
payload: &serde_json::Value,
|
||||||
locale: OpenApiUploadLocale,
|
locale: OpenApiUploadLocale,
|
||||||
|
|||||||
@@ -60,12 +60,26 @@ async fn previews_openapi_and_creates_draft_operations() {
|
|||||||
preview.preview.groups[0].operations[0].suggested_name,
|
preview.preview.groups[0].operations[0].suggested_name,
|
||||||
"latest_rates"
|
"latest_rates"
|
||||||
);
|
);
|
||||||
|
let public_response = serde_json::to_string(&preview).unwrap();
|
||||||
|
assert!(!public_response.contains("normalizer_version"));
|
||||||
|
assert!(!public_response.contains("projection_version"));
|
||||||
|
assert!(!public_response.contains("ir_fingerprint"));
|
||||||
|
assert!(!public_response.contains("source_identity"));
|
||||||
let preview_job = registry
|
let preview_job = registry
|
||||||
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
.get_import_job(&workspace_id, &preview.job_id.as_str().into())
|
||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(preview_job.status, ImportJobStatus::Pending);
|
assert_eq!(preview_job.status, ImportJobStatus::Pending);
|
||||||
|
assert_eq!(
|
||||||
|
preview_job.preview_payload["normalization"]["normalizer_version"],
|
||||||
|
"normalized-ir-v2"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
preview_job.preview_payload["normalization"]["projection_version"],
|
||||||
|
"preview-v2"
|
||||||
|
);
|
||||||
|
assert!(preview_job.preview_payload["normalization"]["ir_fingerprint"].is_string());
|
||||||
|
|
||||||
let created = service
|
let created = service
|
||||||
.create_openapi_import(
|
.create_openapi_import(
|
||||||
@@ -321,6 +335,251 @@ async fn apply_fails_closed_when_the_preview_parser_contract_drifts() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn apply_fails_closed_for_each_normalization_contract_field_and_digest_shape() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let service = test_service(
|
||||||
|
registry.clone(),
|
||||||
|
test_storage_root("openapi_import_contract_fields"),
|
||||||
|
test_auth_settings(),
|
||||||
|
test_secret_crypto(),
|
||||||
|
);
|
||||||
|
let workspace_id = WorkspaceId::new("ws_default");
|
||||||
|
for case in [
|
||||||
|
"normalizer_version",
|
||||||
|
"projection_version",
|
||||||
|
"ir_fingerprint",
|
||||||
|
"missing_preview_digest",
|
||||||
|
"non_string_preview_digest",
|
||||||
|
] {
|
||||||
|
let preview = service
|
||||||
|
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||||
|
let query = match case {
|
||||||
|
"normalizer_version" => sqlx::query(
|
||||||
|
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,normalizer_version}', to_jsonb('future'::text)) where id = $1",
|
||||||
|
),
|
||||||
|
"projection_version" => sqlx::query(
|
||||||
|
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,projection_version}', to_jsonb('future'::text)) where id = $1",
|
||||||
|
),
|
||||||
|
"ir_fingerprint" => sqlx::query(
|
||||||
|
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{normalization,ir_fingerprint}', to_jsonb('bad'::text)) where id = $1",
|
||||||
|
),
|
||||||
|
"missing_preview_digest" => sqlx::query(
|
||||||
|
"update import_jobs set preview_payload = preview_payload - 'preview_digest' where id = $1",
|
||||||
|
),
|
||||||
|
"non_string_preview_digest" => sqlx::query(
|
||||||
|
"update import_jobs set preview_payload = jsonb_set(preview_payload, '{preview_digest}', '42'::jsonb) where id = $1",
|
||||||
|
),
|
||||||
|
_ => unreachable!(),
|
||||||
|
};
|
||||||
|
query
|
||||||
|
.bind(job_id.as_str())
|
||||||
|
.execute(registry.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.create_openapi_import(
|
||||||
|
&workspace_id,
|
||||||
|
&job_id,
|
||||||
|
OpenApiImportCreatePayload {
|
||||||
|
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||||
|
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||||
|
conflict_mode: "rename".to_owned(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.list_operations(&workspace_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn legacy_job_without_normalization_contract_replays_until_expiry() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let service = test_service(
|
||||||
|
registry.clone(),
|
||||||
|
test_storage_root("openapi_import_legacy_replay"),
|
||||||
|
test_auth_settings(),
|
||||||
|
test_secret_crypto(),
|
||||||
|
);
|
||||||
|
let workspace_id = WorkspaceId::new("ws_default");
|
||||||
|
let preview = service
|
||||||
|
.preview_openapi_import(&workspace_id, openapi_upload())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let job_id: crank_registry::ImportJobId = preview.job_id.as_str().into();
|
||||||
|
sqlx::query(
|
||||||
|
"update import_jobs set preview_payload = preview_payload - 'normalization' where id = $1",
|
||||||
|
)
|
||||||
|
.bind(job_id.as_str())
|
||||||
|
.execute(registry.pool())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = service
|
||||||
|
.create_openapi_import(
|
||||||
|
&workspace_id,
|
||||||
|
&job_id,
|
||||||
|
OpenApiImportCreatePayload {
|
||||||
|
selected_operation_keys: vec!["GET /v2/latest".to_owned()],
|
||||||
|
server_url: Some("https://api.frankfurter.dev".to_owned()),
|
||||||
|
conflict_mode: "skip".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.created.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn blocker_finding_keeps_valid_preview_but_prevents_draft_mutation() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let service = test_service(
|
||||||
|
registry,
|
||||||
|
test_storage_root("openapi_import_blocker"),
|
||||||
|
test_auth_settings(),
|
||||||
|
test_secret_crypto(),
|
||||||
|
);
|
||||||
|
let workspace_id = WorkspaceId::new("ws_default");
|
||||||
|
let upload = OpenApiUpload {
|
||||||
|
bytes: br#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Partial }
|
||||||
|
paths:
|
||||||
|
/valid:
|
||||||
|
get:
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
/broken: not-a-path-item
|
||||||
|
"#
|
||||||
|
.to_vec(),
|
||||||
|
mime_type: "application/yaml".to_owned(),
|
||||||
|
locale: OpenApiUploadLocale::En,
|
||||||
|
};
|
||||||
|
let preview = service
|
||||||
|
.preview_openapi_import(&workspace_id, upload)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(preview.preview.groups[0].operations.len(), 1);
|
||||||
|
assert!(
|
||||||
|
preview
|
||||||
|
.preview
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.severity == crank_import::rest::ImportFindingSeverity::Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.create_openapi_import(
|
||||||
|
&workspace_id,
|
||||||
|
&preview.job_id.as_str().into(),
|
||||||
|
OpenApiImportCreatePayload {
|
||||||
|
selected_operation_keys: vec!["GET /valid".to_owned()],
|
||||||
|
server_url: None,
|
||||||
|
conflict_mode: "skip".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.list_operations(&workspace_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn operation_level_reference_blocker_prevents_draft_mutation() {
|
||||||
|
let registry = test_registry().await;
|
||||||
|
let service = test_service(
|
||||||
|
registry,
|
||||||
|
test_storage_root("openapi_import_operation_blocker"),
|
||||||
|
test_auth_settings(),
|
||||||
|
test_secret_crypto(),
|
||||||
|
);
|
||||||
|
let workspace_id = WorkspaceId::new("ws_default");
|
||||||
|
let upload = OpenApiUpload {
|
||||||
|
bytes: br#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: References }
|
||||||
|
paths:
|
||||||
|
/referenced:
|
||||||
|
get:
|
||||||
|
operationId: referenced
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ok
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Result' }
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Result: { type: object, properties: { id: { type: string } } }
|
||||||
|
"#
|
||||||
|
.to_vec(),
|
||||||
|
mime_type: "application/yaml".to_owned(),
|
||||||
|
locale: OpenApiUploadLocale::En,
|
||||||
|
};
|
||||||
|
let preview = service
|
||||||
|
.preview_openapi_import(&workspace_id, upload)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
preview.preview.findings.iter().all(|finding| {
|
||||||
|
finding.severity != crank_import::rest::ImportFindingSeverity::Error
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
preview.preview.groups[0].operations[0]
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| {
|
||||||
|
finding.code == "unresolved_reference"
|
||||||
|
&& finding.severity == crank_import::rest::ImportFindingSeverity::Error
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.create_openapi_import(
|
||||||
|
&workspace_id,
|
||||||
|
&preview.job_id.as_str().into(),
|
||||||
|
OpenApiImportCreatePayload {
|
||||||
|
selected_operation_keys: vec!["GET /referenced".to_owned()],
|
||||||
|
server_url: None,
|
||||||
|
conflict_mode: "skip".to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.list_operations(&workspace_id)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn openapi_upload() -> OpenApiUpload {
|
fn openapi_upload() -> OpenApiUpload {
|
||||||
OpenApiUpload {
|
OpenApiUpload {
|
||||||
bytes: OPENAPI3.as_bytes().to_vec(),
|
bytes: OPENAPI3.as_bytes().to_vec(),
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ mod mapping;
|
|||||||
pub mod model;
|
pub mod model;
|
||||||
mod naming;
|
mod naming;
|
||||||
mod normalize;
|
mod normalize;
|
||||||
|
mod normalize_coverage;
|
||||||
|
mod normalize_limits;
|
||||||
|
mod normalize_schema;
|
||||||
mod openapi3;
|
mod openapi3;
|
||||||
mod payload;
|
mod payload;
|
||||||
mod recommendations;
|
mod recommendations;
|
||||||
@@ -10,8 +13,14 @@ mod swagger2;
|
|||||||
|
|
||||||
pub use model::{
|
pub use model::{
|
||||||
ImportFinding, ImportFindingSeverity, ImportGroupPreview, ImportOperationCandidate,
|
ImportFinding, ImportFindingSeverity, ImportGroupPreview, ImportOperationCandidate,
|
||||||
ImportPreview, ImportSourcePreview, RestImportCandidate, RestImportDocument,
|
ImportPreview, ImportSourcePreview, NORMALIZER_VERSION, NormalizationConfig, NormalizedFinding,
|
||||||
RestImportOperation, RestImportParameter, RestParameterLocation,
|
NormalizedIr, NormalizedOperation, NormalizedParameter, NormalizedReference, NormalizedSchema,
|
||||||
|
NormalizedSchemaKind, PROJECTION_VERSION, RestImportCandidate, RestImportDocument,
|
||||||
|
RestImportOperation, RestImportParameter, RestParameterLocation, SourceDigest, SourceIdentity,
|
||||||
|
SourceLocation, UnresolvedReference,
|
||||||
|
};
|
||||||
|
pub use normalize::{
|
||||||
|
ImportParseError, normalize_verified_document, preview_document, preview_document_legacy_v1,
|
||||||
|
preview_from_ir, validate_normalized_ir,
|
||||||
};
|
};
|
||||||
pub use normalize::preview_document;
|
|
||||||
pub use payload::operation_draft_from_candidate;
|
pub use payload::operation_draft_from_candidate;
|
||||||
|
|||||||
@@ -1,9 +1,294 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState};
|
use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState};
|
||||||
use crank_mapping::MappingSet;
|
use crank_mapping::MappingSet;
|
||||||
use crank_schema::Schema;
|
use crank_schema::Schema;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize, de};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// The immutable contract used to normalize an OpenAPI source. These names
|
||||||
|
/// deliberately travel with an import job: changing either contract must not
|
||||||
|
/// silently reinterpret a pending preview.
|
||||||
|
pub const NORMALIZER_VERSION: &str = "normalized-ir-v2";
|
||||||
|
pub const PROJECTION_VERSION: &str = "preview-v2";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct SourceDigest(String);
|
||||||
|
|
||||||
|
impl SourceDigest {
|
||||||
|
pub fn parse(value: impl Into<String>) -> Result<Self, &'static str> {
|
||||||
|
let value = value.into();
|
||||||
|
if value.len() == 64
|
||||||
|
&& value
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||||
|
{
|
||||||
|
Ok(Self(value))
|
||||||
|
} else {
|
||||||
|
Err("source digest must be a lowercase SHA-256 hex string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SourceDigest {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Self::parse(value).map_err(de::Error::custom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SourceIdentity {
|
||||||
|
pub digest: SourceDigest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SourceSyntax {
|
||||||
|
Json,
|
||||||
|
Yaml,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct CanonicalSourceNode {
|
||||||
|
pub construct_id: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
pub value: CanonicalSourceValue,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
|
||||||
|
pub enum CanonicalSourceValue {
|
||||||
|
Null,
|
||||||
|
Boolean(bool),
|
||||||
|
Number(String),
|
||||||
|
String(String),
|
||||||
|
Array(Vec<CanonicalSourceNode>),
|
||||||
|
Object(BTreeMap<String, CanonicalSourceNode>),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedApiMetadata {
|
||||||
|
pub title: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedPath {
|
||||||
|
pub construct_id: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
pub path: String,
|
||||||
|
pub operation_ids: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizationConfig {
|
||||||
|
pub normalizer_version: String,
|
||||||
|
pub projection_version: String,
|
||||||
|
pub max_bytes: usize,
|
||||||
|
pub max_depth: usize,
|
||||||
|
pub max_nodes: usize,
|
||||||
|
pub max_collection_items: usize,
|
||||||
|
pub max_aliases: usize,
|
||||||
|
pub max_scalar_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for NormalizationConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
normalizer_version: NORMALIZER_VERSION.to_owned(),
|
||||||
|
projection_version: PROJECTION_VERSION.to_owned(),
|
||||||
|
max_bytes: 256 * 1024,
|
||||||
|
max_depth: 64,
|
||||||
|
max_nodes: 50_000,
|
||||||
|
max_collection_items: 10_000,
|
||||||
|
max_aliases: 128,
|
||||||
|
max_scalar_bytes: 256 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NormalizationConfig {
|
||||||
|
pub fn validate_versions(&self) -> Result<(), &'static str> {
|
||||||
|
if self.normalizer_version == NORMALIZER_VERSION
|
||||||
|
&& self.projection_version == PROJECTION_VERSION
|
||||||
|
{
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("unsupported normalization contract version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct SourceLocation {
|
||||||
|
/// RFC 6901 JSON Pointer. It is intentionally the only source location
|
||||||
|
/// exposed by the normalizer: no excerpts or parser diagnostics leak.
|
||||||
|
pub pointer: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct UnresolvedReference {
|
||||||
|
pub uri: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unresolved `$ref` preserved from the decoded source. This is deliberately
|
||||||
|
/// broader than schema references: path items, reusable parameters and other
|
||||||
|
/// object-level references remain available to a later resolution phase.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedReference {
|
||||||
|
pub construct_id: String,
|
||||||
|
pub uri: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedFinding {
|
||||||
|
pub code: String,
|
||||||
|
pub severity: ImportFindingSeverity,
|
||||||
|
pub message: String,
|
||||||
|
pub construct_id: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub operation_key: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CoverageDisposition {
|
||||||
|
Mapped,
|
||||||
|
Finding,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct CoverageEntry {
|
||||||
|
pub construct_id: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
pub disposition: CoverageDisposition,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedOperation {
|
||||||
|
pub stable_id: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
pub key: String,
|
||||||
|
pub method: HttpMethod,
|
||||||
|
pub path: String,
|
||||||
|
pub operation_id: Option<String>,
|
||||||
|
pub summary: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub parameters: Vec<NormalizedParameter>,
|
||||||
|
pub request_body_schema: Option<NormalizedSchema>,
|
||||||
|
pub response_schema: Option<NormalizedSchema>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub servers: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub findings: Vec<NormalizedFinding>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedParameter {
|
||||||
|
pub name: String,
|
||||||
|
pub location: RestParameterLocation,
|
||||||
|
pub required: bool,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub schema: Option<NormalizedSchema>,
|
||||||
|
pub construct_id: String,
|
||||||
|
pub source_location: SourceLocation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedSchema {
|
||||||
|
pub construct_id: String,
|
||||||
|
pub location: SourceLocation,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub kind: NormalizedSchemaKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "type")]
|
||||||
|
pub enum NormalizedSchemaKind {
|
||||||
|
Unknown,
|
||||||
|
Scalar {
|
||||||
|
scalar_type: NormalizedScalarKind,
|
||||||
|
format: Option<String>,
|
||||||
|
nullable: bool,
|
||||||
|
default_value: Option<NormalizedLiteral>,
|
||||||
|
enum_values: Vec<NormalizedLiteral>,
|
||||||
|
},
|
||||||
|
Object {
|
||||||
|
properties: BTreeMap<String, NormalizedSchema>,
|
||||||
|
required: Vec<String>,
|
||||||
|
},
|
||||||
|
Array {
|
||||||
|
items: Option<Box<NormalizedSchema>>,
|
||||||
|
},
|
||||||
|
Reference {
|
||||||
|
reference: UnresolvedReference,
|
||||||
|
},
|
||||||
|
Composition {
|
||||||
|
operator: String,
|
||||||
|
variants: Vec<NormalizedSchema>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NormalizedScalarKind {
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
Number,
|
||||||
|
Boolean,
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case", tag = "kind", content = "value")]
|
||||||
|
pub enum NormalizedLiteral {
|
||||||
|
String(String),
|
||||||
|
Integer(i64),
|
||||||
|
Unsigned(u64),
|
||||||
|
Number(f64),
|
||||||
|
Boolean(bool),
|
||||||
|
Null,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure, canonical representation between parsing and preview projection.
|
||||||
|
/// `operations`, `findings` and `coverage` are sorted before construction.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct NormalizedIr {
|
||||||
|
pub normalizer_version: String,
|
||||||
|
pub projection_version: String,
|
||||||
|
pub source_identity: SourceIdentity,
|
||||||
|
pub source_syntax: SourceSyntax,
|
||||||
|
pub source_tree: CanonicalSourceNode,
|
||||||
|
pub metadata: NormalizedApiMetadata,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_path_candidates: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub paths: Vec<NormalizedPath>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub unresolved_references: Vec<NormalizedReference>,
|
||||||
|
pub source: ImportSourcePreview,
|
||||||
|
#[serde(default)]
|
||||||
|
pub operations: Vec<NormalizedOperation>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub findings: Vec<NormalizedFinding>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub coverage: Vec<CoverageEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ImportFindingSeverity {
|
pub enum ImportFindingSeverity {
|
||||||
@@ -80,7 +365,7 @@ pub struct RestImportCandidate {
|
|||||||
pub wizard_state: Option<WizardState>,
|
pub wizard_state: Option<WizardState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RestImportDocument {
|
pub struct RestImportDocument {
|
||||||
pub format: String,
|
pub format: String,
|
||||||
pub version: Option<String>,
|
pub version: Option<String>,
|
||||||
@@ -88,9 +373,11 @@ pub struct RestImportDocument {
|
|||||||
pub servers: Vec<String>,
|
pub servers: Vec<String>,
|
||||||
pub operations: Vec<RestImportOperation>,
|
pub operations: Vec<RestImportOperation>,
|
||||||
pub findings: Vec<ImportFinding>,
|
pub findings: Vec<ImportFinding>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub internal_finding_locations: Vec<Option<SourceLocation>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RestImportOperation {
|
pub struct RestImportOperation {
|
||||||
pub key: String,
|
pub key: String,
|
||||||
pub method: HttpMethod,
|
pub method: HttpMethod,
|
||||||
@@ -101,21 +388,36 @@ pub struct RestImportOperation {
|
|||||||
pub tags: Vec<String>,
|
pub tags: Vec<String>,
|
||||||
pub parameters: Vec<RestImportParameter>,
|
pub parameters: Vec<RestImportParameter>,
|
||||||
pub request_body_schema: Option<Value>,
|
pub request_body_schema: Option<Value>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub request_body_schema_location: Option<SourceLocation>,
|
||||||
pub response_schema: Option<Value>,
|
pub response_schema: Option<Value>,
|
||||||
|
#[serde(skip)]
|
||||||
|
pub response_schema_location: Option<SourceLocation>,
|
||||||
pub servers: Vec<String>,
|
pub servers: Vec<String>,
|
||||||
pub findings: Vec<ImportFinding>,
|
pub findings: Vec<ImportFinding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct RestImportParameter {
|
pub struct RestImportParameter {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub location: RestParameterLocation,
|
pub location: RestParameterLocation,
|
||||||
pub required: bool,
|
pub required: bool,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub schema: Option<Value>,
|
pub schema: Option<Value>,
|
||||||
|
#[serde(skip, default = "empty_source_location")]
|
||||||
|
pub source_location: SourceLocation,
|
||||||
|
#[serde(skip, default)]
|
||||||
|
pub schema_source_location: Option<SourceLocation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
fn empty_source_location() -> SourceLocation {
|
||||||
|
SourceLocation {
|
||||||
|
pointer: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RestParameterLocation {
|
pub enum RestParameterLocation {
|
||||||
Path,
|
Path,
|
||||||
Query,
|
Query,
|
||||||
|
|||||||
@@ -4,43 +4,156 @@ use serde_json::Value;
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
use crate::rest::{
|
use crate::rest::{
|
||||||
model::{ImportGroupPreview, ImportPreview, RestImportDocument},
|
model::{
|
||||||
|
CoverageDisposition, CoverageEntry, ImportFinding, ImportFindingSeverity,
|
||||||
|
ImportGroupPreview, ImportPreview, ImportSourcePreview, NORMALIZER_VERSION,
|
||||||
|
NormalizationConfig, NormalizedApiMetadata, NormalizedFinding, NormalizedIr,
|
||||||
|
NormalizedLiteral, NormalizedOperation, NormalizedParameter, NormalizedScalarKind,
|
||||||
|
NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION, RestImportDocument,
|
||||||
|
RestImportOperation, RestImportParameter, SourceDigest, SourceIdentity, SourceLocation,
|
||||||
|
SourceSyntax,
|
||||||
|
},
|
||||||
openapi3,
|
openapi3,
|
||||||
payload::candidate_from_operation,
|
payload::candidate_from_operation,
|
||||||
swagger2,
|
swagger2,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
use super::normalize_coverage;
|
||||||
|
use super::normalize_limits;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
pub enum ImportParseError {
|
pub enum ImportParseError {
|
||||||
#[error("document is not valid YAML or JSON: {0}")]
|
#[error("document is not valid YAML or JSON")]
|
||||||
InvalidDocument(String),
|
InvalidDocument,
|
||||||
#[error("unsupported OpenAPI document")]
|
#[error("unsupported OpenAPI document")]
|
||||||
UnsupportedDocument,
|
UnsupportedDocument,
|
||||||
|
#[error("OpenAPI document exceeds normalization limits")]
|
||||||
|
LimitExceeded,
|
||||||
|
#[error("OpenAPI document contains no supported operations")]
|
||||||
|
NoMethods,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn preview_document(document: &str) -> Result<ImportPreview, ImportParseError> {
|
pub fn preview_document(document: &str) -> Result<ImportPreview, ImportParseError> {
|
||||||
let yaml: serde_yaml::Value = serde_yaml::from_str(document)
|
let digest = SourceDigest::parse("0".repeat(64)).expect("fixed digest is valid");
|
||||||
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?;
|
let ir = normalize_verified_document(document, digest, &NormalizationConfig::default())?;
|
||||||
let root = serde_json::to_value(yaml)
|
Ok(preview_from_ir(&ir))
|
||||||
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?;
|
}
|
||||||
|
|
||||||
let normalized = if root.get("openapi").is_some() {
|
/// Compatibility projection for jobs created before `NormalizedIr`. It is
|
||||||
openapi3::parse_document(&root)?
|
/// deliberately isolated from the v2 pipeline and may be removed only after
|
||||||
} else if root.get("swagger").and_then(Value::as_str) == Some("2.0") {
|
/// the import-job TTL has elapsed.
|
||||||
swagger2::parse_document(&root)?
|
pub fn preview_document_legacy_v1(document: &str) -> Result<ImportPreview, ImportParseError> {
|
||||||
|
let root = decode_legacy_v1(document)?;
|
||||||
|
let mut parsed = match root.get("openapi") {
|
||||||
|
Some(_) => openapi3::parse_document_legacy_v1(&root)?,
|
||||||
|
None if root.get("swagger").and_then(Value::as_str) == Some("2.0") => {
|
||||||
|
swagger2::parse_document_legacy_v1(&root)?
|
||||||
|
}
|
||||||
|
None => return Err(ImportParseError::UnsupportedDocument),
|
||||||
|
};
|
||||||
|
for operation in &mut parsed.operations {
|
||||||
|
for parameter in &mut operation.parameters {
|
||||||
|
if let Some(schema) = parameter.schema.take() {
|
||||||
|
parameter.schema = Some(resolve_local_ref(&root, &schema, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(schema) = operation.request_body_schema.take() {
|
||||||
|
operation.request_body_schema = Some(resolve_local_ref(&root, &schema, 0));
|
||||||
|
}
|
||||||
|
if let Some(schema) = operation.response_schema.take() {
|
||||||
|
operation.response_schema = Some(resolve_local_ref(&root, &schema, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(preview_from_document(parsed))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_legacy_v1(document: &str) -> Result<Value, ImportParseError> {
|
||||||
|
let config = NormalizationConfig::default();
|
||||||
|
if document.len() > config.max_bytes
|
||||||
|
|| normalize_limits::alias_count(document) > config.max_aliases
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::LimitExceeded);
|
||||||
|
}
|
||||||
|
let root = decode(document)?;
|
||||||
|
validate_value_limits(&root, &config)?;
|
||||||
|
Ok(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn normalize_verified_document(
|
||||||
|
document: &str,
|
||||||
|
digest: SourceDigest,
|
||||||
|
config: &NormalizationConfig,
|
||||||
|
) -> Result<NormalizedIr, ImportParseError> {
|
||||||
|
config
|
||||||
|
.validate_versions()
|
||||||
|
.map_err(|_| ImportParseError::UnsupportedDocument)?;
|
||||||
|
if document.len() > config.max_bytes
|
||||||
|
|| normalize_limits::alias_count(document) > config.max_aliases
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::LimitExceeded);
|
||||||
|
}
|
||||||
|
let source_syntax = if serde_json::from_str::<Value>(document).is_ok() {
|
||||||
|
SourceSyntax::Json
|
||||||
} else {
|
} else {
|
||||||
return Err(ImportParseError::UnsupportedDocument);
|
SourceSyntax::Yaml
|
||||||
|
};
|
||||||
|
let root = decode(document)?;
|
||||||
|
validate_value_limits(&root, config)?;
|
||||||
|
|
||||||
|
let parsed = match root.get("openapi").and_then(Value::as_str) {
|
||||||
|
Some(version) if supported_oas_version(version) => openapi3::parse_document(&root)?,
|
||||||
|
Some(_) => return Err(ImportParseError::UnsupportedDocument),
|
||||||
|
None if root.get("swagger").and_then(Value::as_str) == Some("2.0") => {
|
||||||
|
swagger2::parse_document(&root)?
|
||||||
|
}
|
||||||
|
None => return Err(ImportParseError::UnsupportedDocument),
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(preview_from_document(normalized))
|
if parsed.operations.is_empty() {
|
||||||
|
return Err(ImportParseError::NoMethods);
|
||||||
|
}
|
||||||
|
canonicalize(parsed, digest, config, root, source_syntax)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preview_from_ir(ir: &NormalizedIr) -> ImportPreview {
|
||||||
|
let operations = ir
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.map(legacy_operation_from_normalized)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let findings = ir
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.map(|finding| ImportFinding {
|
||||||
|
code: finding.code.clone(),
|
||||||
|
severity: finding.severity.clone(),
|
||||||
|
message: finding.message.clone(),
|
||||||
|
operation_key: finding.operation_key.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
preview_from_operations(&ir.source, operations.iter(), findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preview_from_document(document: RestImportDocument) -> ImportPreview {
|
fn preview_from_document(document: RestImportDocument) -> ImportPreview {
|
||||||
|
let source = ImportSourcePreview {
|
||||||
|
format: document.format,
|
||||||
|
version: document.version,
|
||||||
|
title: document.title,
|
||||||
|
servers: document.servers,
|
||||||
|
};
|
||||||
|
preview_from_operations(&source, document.operations.iter(), document.findings)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn preview_from_operations<'a>(
|
||||||
|
source: &ImportSourcePreview,
|
||||||
|
operations: impl Iterator<Item = &'a crate::rest::model::RestImportOperation>,
|
||||||
|
mut findings: Vec<ImportFinding>,
|
||||||
|
) -> ImportPreview {
|
||||||
let mut groups: BTreeMap<String, ImportGroupPreview> = BTreeMap::new();
|
let mut groups: BTreeMap<String, ImportGroupPreview> = BTreeMap::new();
|
||||||
let mut used_names = BTreeSet::new();
|
let mut used_names = BTreeSet::new();
|
||||||
|
|
||||||
for operation in &document.operations {
|
for operation in operations {
|
||||||
let candidate = candidate_from_operation(operation, &document.servers, &mut used_names);
|
let candidate = candidate_from_operation(operation, &source.servers, &mut used_names);
|
||||||
let group_title = operation
|
let group_title = operation
|
||||||
.tags
|
.tags
|
||||||
.first()
|
.first()
|
||||||
@@ -58,36 +171,674 @@ fn preview_from_document(document: RestImportDocument) -> ImportPreview {
|
|||||||
.push(candidate);
|
.push(candidate);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sort_findings(&mut findings);
|
||||||
ImportPreview {
|
ImportPreview {
|
||||||
source: crate::rest::model::ImportSourcePreview {
|
source: source.clone(),
|
||||||
|
groups: groups.into_values().collect(),
|
||||||
|
findings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode(document: &str) -> Result<Value, ImportParseError> {
|
||||||
|
if let Ok(json) = serde_json::from_str(document) {
|
||||||
|
return Ok(json);
|
||||||
|
}
|
||||||
|
let yaml: serde_yaml::Value =
|
||||||
|
serde_yaml::from_str(document).map_err(|_| ImportParseError::InvalidDocument)?;
|
||||||
|
serde_json::to_value(yaml).map_err(|_| ImportParseError::InvalidDocument)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_oas_version(version: &str) -> bool {
|
||||||
|
["3.0.", "3.1."].into_iter().any(|prefix| {
|
||||||
|
version.strip_prefix(prefix).is_some_and(|patch| {
|
||||||
|
!patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonicalize(
|
||||||
|
document: RestImportDocument,
|
||||||
|
digest: SourceDigest,
|
||||||
|
config: &NormalizationConfig,
|
||||||
|
root: Value,
|
||||||
|
source_syntax: SourceSyntax,
|
||||||
|
) -> Result<NormalizedIr, ImportParseError> {
|
||||||
|
let source = ImportSourcePreview {
|
||||||
format: document.format,
|
format: document.format,
|
||||||
version: document.version,
|
version: document.version,
|
||||||
title: document.title,
|
title: document.title,
|
||||||
servers: document.servers,
|
servers: document.servers,
|
||||||
|
};
|
||||||
|
let version = source.version.as_deref().unwrap_or("unknown");
|
||||||
|
let mut coverage = Vec::new();
|
||||||
|
let source_tree = normalize_coverage::canonical_source_tree(&root, "", &mut coverage);
|
||||||
|
let unresolved_references = normalize_coverage::references_from_source_tree(&source_tree);
|
||||||
|
coverage.extend(unresolved_references.iter().map(|reference| CoverageEntry {
|
||||||
|
construct_id: reference.construct_id.clone(),
|
||||||
|
location: reference.location.clone(),
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
}));
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: "document".to_owned(),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: String::new(),
|
||||||
},
|
},
|
||||||
groups: groups.into_values().collect(),
|
disposition: CoverageDisposition::Mapped,
|
||||||
findings: document.findings,
|
});
|
||||||
|
coverage.extend(normalize_coverage::server_coverage(
|
||||||
|
&source,
|
||||||
|
&source_tree,
|
||||||
|
version,
|
||||||
|
)?);
|
||||||
|
let mut covered_paths = BTreeSet::new();
|
||||||
|
let mut covered_parameters = BTreeSet::new();
|
||||||
|
let internal_finding_locations = document.internal_finding_locations.clone();
|
||||||
|
let mut findings = document
|
||||||
|
.findings
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, finding)| {
|
||||||
|
let location = internal_finding_locations.get(index).cloned().flatten();
|
||||||
|
NormalizedFinding {
|
||||||
|
code: finding.code.clone(),
|
||||||
|
severity: finding.severity,
|
||||||
|
message: finding.message,
|
||||||
|
construct_id: location
|
||||||
|
.as_ref()
|
||||||
|
.map(|location| {
|
||||||
|
if location.pointer.is_empty() {
|
||||||
|
"document".to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{version}:{}", location.pointer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "document".to_owned()),
|
||||||
|
location: location.unwrap_or(SourceLocation {
|
||||||
|
pointer: String::new(),
|
||||||
|
}),
|
||||||
|
operation_key: finding.operation_key,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let mut operations = document
|
||||||
|
.operations
|
||||||
|
.into_iter()
|
||||||
|
.map(|operation| {
|
||||||
|
let method = method_name(operation.method).to_ascii_lowercase();
|
||||||
|
let pointer = format!(
|
||||||
|
"/paths/{}/{}",
|
||||||
|
normalize_coverage::escape_pointer(&operation.path),
|
||||||
|
method
|
||||||
|
);
|
||||||
|
let stable_id = format!("{version}:{method}:{pointer}");
|
||||||
|
let location = SourceLocation {
|
||||||
|
pointer: pointer.clone(),
|
||||||
|
};
|
||||||
|
let mut operation_findings = Vec::new();
|
||||||
|
if covered_paths.insert(operation.path.clone()) {
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: format!("{version}:path:{}", operation.path),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"/paths/{}",
|
||||||
|
normalize_coverage::escape_pointer(&operation.path)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if operation
|
||||||
|
.operation_id
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: format!("{stable_id}:operation_id"),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: format!("{pointer}/operationId"),
|
||||||
|
},
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
operation_findings.push(NormalizedFinding {
|
||||||
|
code: "missing_operation_id".to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Warning,
|
||||||
|
message: "У метода нет operationId.".to_owned(),
|
||||||
|
construct_id: format!("{stable_id}:operation_id"),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: format!("{pointer}/operationId"),
|
||||||
|
},
|
||||||
|
operation_key: Some(operation.key.clone()),
|
||||||
|
});
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: format!("{stable_id}:operation_id"),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: format!("{pointer}/operationId"),
|
||||||
|
},
|
||||||
|
disposition: CoverageDisposition::Finding,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (index, location) in
|
||||||
|
normalize_coverage::tag_locations(&root, &pointer, &operation.tags)?
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
{
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: format!("{stable_id}:tag:{index}"),
|
||||||
|
location,
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
operation_findings.extend(operation.findings.iter().map(|finding| NormalizedFinding {
|
||||||
|
code: finding.code.clone(),
|
||||||
|
severity: finding.severity.clone(),
|
||||||
|
message: finding.message.clone(),
|
||||||
|
construct_id: stable_id.clone(),
|
||||||
|
location: location.clone(),
|
||||||
|
operation_key: Some(operation.key.clone()),
|
||||||
|
}));
|
||||||
|
let parameters = operation
|
||||||
|
.parameters
|
||||||
|
.into_iter()
|
||||||
|
.map(|parameter| {
|
||||||
|
let parameter_pointer = parameter.source_location.pointer;
|
||||||
|
let parameter_id = format!(
|
||||||
|
"parameter:{}",
|
||||||
|
normalize_coverage::escape_pointer(¶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::<Vec<_>>();
|
||||||
|
let request_body_schema = operation.request_body_schema.as_ref().map(|schema| {
|
||||||
|
let schema_location = operation
|
||||||
|
.request_body_schema_location
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(SourceLocation {
|
||||||
|
pointer: format!("{pointer}/requestBody"),
|
||||||
|
});
|
||||||
|
normalize_coverage::typed_schema(
|
||||||
|
schema,
|
||||||
|
&schema_location.pointer,
|
||||||
|
&format!(
|
||||||
|
"{stable_id}:request:{}",
|
||||||
|
normalize_coverage::escape_pointer(&schema_location.pointer)
|
||||||
|
),
|
||||||
|
&mut coverage,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let response_schema = operation.response_schema.as_ref().map(|schema| {
|
||||||
|
let schema_location = operation
|
||||||
|
.response_schema_location
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or(SourceLocation {
|
||||||
|
pointer: format!("{pointer}/responses"),
|
||||||
|
});
|
||||||
|
normalize_coverage::typed_schema(
|
||||||
|
schema,
|
||||||
|
&schema_location.pointer,
|
||||||
|
&format!(
|
||||||
|
"{stable_id}:response:{}",
|
||||||
|
normalize_coverage::escape_pointer(&schema_location.pointer)
|
||||||
|
),
|
||||||
|
&mut coverage,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if normalize_coverage::has_reference_schema(request_body_schema.as_ref())
|
||||||
|
|| normalize_coverage::has_reference_schema(response_schema.as_ref())
|
||||||
|
|| parameters.iter().any(|parameter| {
|
||||||
|
normalize_coverage::has_reference_schema(parameter.schema.as_ref())
|
||||||
|
})
|
||||||
|
{
|
||||||
|
operation_findings.push(NormalizedFinding {
|
||||||
|
code: "unresolved_reference".to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Error,
|
||||||
|
message: "Ссылка сохранена в NormalizedIR и будет разрешена отдельным этапом."
|
||||||
|
.to_owned(),
|
||||||
|
construct_id: stable_id.clone(),
|
||||||
|
location: location.clone(),
|
||||||
|
operation_key: Some(operation.key.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: stable_id.clone(),
|
||||||
|
location: location.clone(),
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
});
|
||||||
|
Ok::<_, ImportParseError>(NormalizedOperation {
|
||||||
|
stable_id,
|
||||||
|
location,
|
||||||
|
key: operation.key,
|
||||||
|
method: operation.method,
|
||||||
|
path: operation.path,
|
||||||
|
operation_id: operation.operation_id,
|
||||||
|
summary: operation.summary,
|
||||||
|
description: operation.description,
|
||||||
|
tags: operation.tags,
|
||||||
|
parameters,
|
||||||
|
request_body_schema,
|
||||||
|
response_schema,
|
||||||
|
servers: operation.servers,
|
||||||
|
findings: operation_findings,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
operations.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
left.path.as_str(),
|
||||||
|
method_rank(left.method),
|
||||||
|
left.location.pointer.as_str(),
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
right.path.as_str(),
|
||||||
|
method_rank(right.method),
|
||||||
|
right.location.pointer.as_str(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
for operation in &mut operations {
|
||||||
|
sort_normalized_findings(&mut operation.findings);
|
||||||
|
}
|
||||||
|
let mut operation_id_counts = BTreeMap::new();
|
||||||
|
for operation in &operations {
|
||||||
|
if let Some(operation_id) = operation
|
||||||
|
.operation_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
*operation_id_counts
|
||||||
|
.entry(operation_id.to_owned())
|
||||||
|
.or_insert(0usize) += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for operation in &mut operations {
|
||||||
|
if let Some(operation_id) = operation
|
||||||
|
.operation_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
&& operation_id_counts[operation_id] > 1
|
||||||
|
{
|
||||||
|
operation.findings.push(NormalizedFinding {
|
||||||
|
code: "duplicate_operation_id".to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Warning,
|
||||||
|
message: "operationId повторяется в документе.".to_owned(),
|
||||||
|
construct_id: format!("{}:operation_id", operation.stable_id),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: format!("{}/operationId", operation.location.pointer),
|
||||||
|
},
|
||||||
|
operation_key: Some(operation.key.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
sort_normalized_findings(&mut operation.findings);
|
||||||
|
}
|
||||||
|
// Resolution is deliberately deferred. References already represented by
|
||||||
|
// an operation schema use that operation's blocker; all other references
|
||||||
|
// need a document-level blocker so omitted object-level semantics can
|
||||||
|
// never reach Apply silently.
|
||||||
|
findings.extend(
|
||||||
|
unresolved_references
|
||||||
|
.iter()
|
||||||
|
.filter(|reference| {
|
||||||
|
!operations.iter().any(|operation| {
|
||||||
|
operation
|
||||||
|
.parameters
|
||||||
|
.iter()
|
||||||
|
.filter_map(|parameter| parameter.schema.as_ref())
|
||||||
|
.chain(operation.request_body_schema.iter())
|
||||||
|
.chain(operation.response_schema.iter())
|
||||||
|
.any(|schema| schema_contains_reference(schema, &reference.location))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|reference| NormalizedFinding {
|
||||||
|
code: "unresolved_reference".to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Error,
|
||||||
|
message: "Ссылка требует разрешения перед применением импорта.".to_owned(),
|
||||||
|
construct_id: reference.construct_id.clone(),
|
||||||
|
location: reference.location.clone(),
|
||||||
|
operation_key: None,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
sort_normalized_findings(&mut findings);
|
||||||
|
for finding in &findings {
|
||||||
|
if !coverage.iter().any(|entry| {
|
||||||
|
entry.construct_id == finding.construct_id
|
||||||
|
&& entry.location == finding.location
|
||||||
|
&& entry.disposition == CoverageDisposition::Mapped
|
||||||
|
}) {
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: finding.construct_id.clone(),
|
||||||
|
location: finding.location.clone(),
|
||||||
|
disposition: CoverageDisposition::Finding,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
coverage.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
left.construct_id.as_str(),
|
||||||
|
left.location.pointer.as_str(),
|
||||||
|
coverage_disposition_rank(&left.disposition),
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
right.construct_id.as_str(),
|
||||||
|
right.location.pointer.as_str(),
|
||||||
|
coverage_disposition_rank(&right.disposition),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
coverage.dedup();
|
||||||
|
|
||||||
|
let ir = NormalizedIr {
|
||||||
|
normalizer_version: config.normalizer_version.clone(),
|
||||||
|
projection_version: config.projection_version.clone(),
|
||||||
|
source_identity: SourceIdentity { digest },
|
||||||
|
source_syntax,
|
||||||
|
source_tree,
|
||||||
|
metadata: NormalizedApiMetadata {
|
||||||
|
title: source.title.clone(),
|
||||||
|
version: root
|
||||||
|
.pointer("/info/version")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
description: root
|
||||||
|
.pointer("/info/description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
},
|
||||||
|
base_path_candidates: root
|
||||||
|
.get("basePath")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(|value| vec![value.to_owned()])
|
||||||
|
.unwrap_or_default(),
|
||||||
|
paths: normalize_coverage::paths_from_operations(&operations, version),
|
||||||
|
unresolved_references,
|
||||||
|
source,
|
||||||
|
operations,
|
||||||
|
findings,
|
||||||
|
coverage,
|
||||||
|
};
|
||||||
|
normalize_coverage::validate_coverage(&ir)?;
|
||||||
|
Ok(ir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_normalized_ir(ir: &NormalizedIr) -> Result<(), ImportParseError> {
|
||||||
|
if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
normalize_coverage::validate_coverage(ir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coverage_disposition_rank(disposition: &CoverageDisposition) -> u8 {
|
||||||
|
match disposition {
|
||||||
|
CoverageDisposition::Mapped => 0,
|
||||||
|
CoverageDisposition::Finding => 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legacy_operation_from_normalized(operation: &NormalizedOperation) -> RestImportOperation {
|
||||||
|
RestImportOperation {
|
||||||
|
key: operation.key.clone(),
|
||||||
|
method: operation.method,
|
||||||
|
path: operation.path.clone(),
|
||||||
|
operation_id: operation.operation_id.clone(),
|
||||||
|
summary: operation.summary.clone(),
|
||||||
|
description: operation.description.clone(),
|
||||||
|
tags: operation.tags.clone(),
|
||||||
|
parameters: operation
|
||||||
|
.parameters
|
||||||
|
.iter()
|
||||||
|
.map(|parameter| RestImportParameter {
|
||||||
|
name: parameter.name.clone(),
|
||||||
|
location: parameter.location,
|
||||||
|
required: parameter.required,
|
||||||
|
description: parameter.description.clone(),
|
||||||
|
schema: parameter.schema.as_ref().map(legacy_schema_value),
|
||||||
|
source_location: parameter.source_location.clone(),
|
||||||
|
schema_source_location: None,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
request_body_schema: operation
|
||||||
|
.request_body_schema
|
||||||
|
.as_ref()
|
||||||
|
.map(legacy_schema_value),
|
||||||
|
request_body_schema_location: None,
|
||||||
|
response_schema: operation.response_schema.as_ref().map(legacy_schema_value),
|
||||||
|
response_schema_location: None,
|
||||||
|
servers: operation.servers.clone(),
|
||||||
|
findings: operation
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.map(|finding| ImportFinding {
|
||||||
|
code: finding.code.clone(),
|
||||||
|
severity: finding.severity.clone(),
|
||||||
|
message: finding.message.clone(),
|
||||||
|
operation_key: finding.operation_key.clone(),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_schema_value(schema: &NormalizedSchema) -> Value {
|
||||||
|
let mut value = match &schema.kind {
|
||||||
|
NormalizedSchemaKind::Reference { reference } => serde_json::json!({"$ref": reference.uri}),
|
||||||
|
NormalizedSchemaKind::Composition { operator, variants } => {
|
||||||
|
serde_json::json!({operator: variants.iter().map(legacy_schema_value).collect::<Vec<_>>() })
|
||||||
|
}
|
||||||
|
NormalizedSchemaKind::Object {
|
||||||
|
properties,
|
||||||
|
required,
|
||||||
|
} => {
|
||||||
|
serde_json::json!({"type":"object", "properties": properties.iter().map(|(name, schema)| (name.clone(), legacy_schema_value(schema))).collect::<serde_json::Map<_, _>>(), "required": required})
|
||||||
|
}
|
||||||
|
NormalizedSchemaKind::Array { items } => {
|
||||||
|
let mut value = serde_json::json!({"type":"array"});
|
||||||
|
if let Some(items) = items {
|
||||||
|
value["items"] = legacy_schema_value(items);
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
NormalizedSchemaKind::Scalar {
|
||||||
|
scalar_type,
|
||||||
|
format,
|
||||||
|
nullable,
|
||||||
|
default_value,
|
||||||
|
enum_values,
|
||||||
|
} => {
|
||||||
|
let scalar_type = match scalar_type {
|
||||||
|
NormalizedScalarKind::String => "string",
|
||||||
|
NormalizedScalarKind::Integer => "integer",
|
||||||
|
NormalizedScalarKind::Number => "number",
|
||||||
|
NormalizedScalarKind::Boolean => "boolean",
|
||||||
|
NormalizedScalarKind::Null => "null",
|
||||||
|
};
|
||||||
|
serde_json::json!({"type":scalar_type, "format":format, "nullable":nullable, "default":default_value.as_ref().map(legacy_literal_value), "enum":enum_values.iter().map(legacy_literal_value).collect::<Vec<_>>()})
|
||||||
|
}
|
||||||
|
NormalizedSchemaKind::Unknown => serde_json::json!({}),
|
||||||
|
};
|
||||||
|
if let Some(description) = &schema.description {
|
||||||
|
value["description"] = Value::String(description.clone());
|
||||||
|
}
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_literal_value(value: &NormalizedLiteral) -> Value {
|
||||||
|
match value {
|
||||||
|
NormalizedLiteral::String(value) => Value::String(value.clone()),
|
||||||
|
NormalizedLiteral::Integer(value) => Value::Number((*value).into()),
|
||||||
|
NormalizedLiteral::Unsigned(value) => Value::Number((*value).into()),
|
||||||
|
NormalizedLiteral::Number(value) => serde_json::Number::from_f64(*value)
|
||||||
|
.map(Value::Number)
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
NormalizedLiteral::Boolean(value) => Value::Bool(*value),
|
||||||
|
NormalizedLiteral::Null => Value::Null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema_contains_reference(schema: &NormalizedSchema, location: &SourceLocation) -> bool {
|
||||||
|
match &schema.kind {
|
||||||
|
NormalizedSchemaKind::Reference { reference } => reference.location == *location,
|
||||||
|
NormalizedSchemaKind::Object { properties, .. } => properties
|
||||||
|
.values()
|
||||||
|
.any(|schema| schema_contains_reference(schema, location)),
|
||||||
|
NormalizedSchemaKind::Array { items } => items
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|schema| schema_contains_reference(schema, location)),
|
||||||
|
NormalizedSchemaKind::Composition { variants, .. } => variants
|
||||||
|
.iter()
|
||||||
|
.any(|schema| schema_contains_reference(schema, location)),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_value_limits(
|
||||||
|
value: &Value,
|
||||||
|
config: &NormalizationConfig,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
fn visit(
|
||||||
|
value: &Value,
|
||||||
|
depth: usize,
|
||||||
|
nodes: &mut usize,
|
||||||
|
config: &NormalizationConfig,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
*nodes += 1;
|
||||||
|
if depth > config.max_depth || *nodes > config.max_nodes {
|
||||||
|
return Err(ImportParseError::LimitExceeded);
|
||||||
|
}
|
||||||
|
match value {
|
||||||
|
Value::String(text) if text.len() > config.max_scalar_bytes => {
|
||||||
|
Err(ImportParseError::LimitExceeded)
|
||||||
|
}
|
||||||
|
Value::Array(items) => {
|
||||||
|
if items.len() > config.max_collection_items {
|
||||||
|
return Err(ImportParseError::LimitExceeded);
|
||||||
|
}
|
||||||
|
for item in items {
|
||||||
|
visit(item, depth + 1, nodes, config)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Value::Object(object) => {
|
||||||
|
if object.len() > config.max_collection_items {
|
||||||
|
return Err(ImportParseError::LimitExceeded);
|
||||||
|
}
|
||||||
|
for (key, item) in object {
|
||||||
|
if key.len() > config.max_scalar_bytes {
|
||||||
|
return Err(ImportParseError::LimitExceeded);
|
||||||
|
}
|
||||||
|
visit(item, depth + 1, nodes, config)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visit(value, 0, &mut 0, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_name(method: crank_core::HttpMethod) -> &'static str {
|
||||||
|
match method {
|
||||||
|
crank_core::HttpMethod::Get => "GET",
|
||||||
|
crank_core::HttpMethod::Post => "POST",
|
||||||
|
crank_core::HttpMethod::Put => "PUT",
|
||||||
|
crank_core::HttpMethod::Patch => "PATCH",
|
||||||
|
crank_core::HttpMethod::Delete => "DELETE",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_rank(method: crank_core::HttpMethod) -> u8 {
|
||||||
|
match method {
|
||||||
|
crank_core::HttpMethod::Get => 0,
|
||||||
|
crank_core::HttpMethod::Post => 1,
|
||||||
|
crank_core::HttpMethod::Put => 2,
|
||||||
|
crank_core::HttpMethod::Patch => 3,
|
||||||
|
crank_core::HttpMethod::Delete => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn severity_rank(severity: &ImportFindingSeverity) -> u8 {
|
||||||
|
match severity {
|
||||||
|
ImportFindingSeverity::Error => 0,
|
||||||
|
ImportFindingSeverity::Warning => 1,
|
||||||
|
ImportFindingSeverity::Info => 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_findings(findings: &mut [ImportFinding]) {
|
||||||
|
findings.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
severity_rank(&left.severity),
|
||||||
|
left.operation_key.as_deref().unwrap_or(""),
|
||||||
|
left.code.as_str(),
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
severity_rank(&right.severity),
|
||||||
|
right.operation_key.as_deref().unwrap_or(""),
|
||||||
|
right.code.as_str(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sort_normalized_findings(findings: &mut [NormalizedFinding]) {
|
||||||
|
findings.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
severity_rank(&left.severity),
|
||||||
|
left.construct_id.as_str(),
|
||||||
|
left.code.as_str(),
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
severity_rank(&right.severity),
|
||||||
|
right.construct_id.as_str(),
|
||||||
|
right.code.as_str(),
|
||||||
|
))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kept private for pending pre-v2 job replays. New normalization never calls
|
||||||
|
// it: refs are represented above and are resolved only in Story 2.3.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub(crate) fn resolve_local_ref(root: &Value, value: &Value, depth: usize) -> Value {
|
pub(crate) fn resolve_local_ref(root: &Value, value: &Value, depth: usize) -> Value {
|
||||||
if depth > 12 {
|
if depth > 12 {
|
||||||
return value.clone();
|
return value.clone();
|
||||||
}
|
}
|
||||||
if let Some(reference) = value.get("$ref").and_then(Value::as_str) {
|
if let Some(reference) = value.get("$ref").and_then(Value::as_str)
|
||||||
if let Some(resolved) = pointer(root, reference) {
|
&& let Some(resolved) = pointer(root, reference)
|
||||||
|
{
|
||||||
return resolve_local_ref(root, resolved, depth + 1);
|
return resolve_local_ref(root, resolved, depth + 1);
|
||||||
}
|
}
|
||||||
return value.clone();
|
|
||||||
}
|
|
||||||
match value {
|
match value {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => Value::Object(
|
||||||
let mut out = serde_json::Map::new();
|
map.iter()
|
||||||
for (key, item) in map {
|
.map(|(key, item)| (key.clone(), resolve_local_ref(root, item, depth + 1)))
|
||||||
out.insert(key.clone(), resolve_local_ref(root, item, depth + 1));
|
.collect(),
|
||||||
}
|
),
|
||||||
Value::Object(out)
|
|
||||||
}
|
|
||||||
Value::Array(items) => Value::Array(
|
Value::Array(items) => Value::Array(
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
@@ -104,8 +855,7 @@ fn pointer<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
|
|||||||
}
|
}
|
||||||
let mut current = root;
|
let mut current = root;
|
||||||
for part in reference.trim_start_matches("#/").split('/') {
|
for part in reference.trim_start_matches("#/").split('/') {
|
||||||
let part = part.replace("~1", "/").replace("~0", "~");
|
current = current.get(part.replace("~1", "/").replace("~0", "~"))?;
|
||||||
current = current.get(&part)?;
|
|
||||||
}
|
}
|
||||||
Some(current)
|
Some(current)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,887 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::rest::model::{
|
||||||
|
CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition, CoverageEntry,
|
||||||
|
ImportSourcePreview, NORMALIZER_VERSION, NormalizedIr, NormalizedOperation, NormalizedPath,
|
||||||
|
NormalizedReference, NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION,
|
||||||
|
SourceLocation,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::ImportParseError;
|
||||||
|
|
||||||
|
pub(super) use super::normalize_schema::{has_reference_schema, typed_schema};
|
||||||
|
|
||||||
|
pub(super) fn validate_coverage(ir: &NormalizedIr) -> Result<(), ImportParseError> {
|
||||||
|
if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
let mut source_nodes = BTreeMap::new();
|
||||||
|
index_source_tree(&ir.source_tree, "", &mut source_nodes)?;
|
||||||
|
let version = validate_source_contract(ir, &source_nodes)?;
|
||||||
|
|
||||||
|
let mut expected = BTreeMap::<String, ExpectedCoverage>::new();
|
||||||
|
for node in source_nodes.values() {
|
||||||
|
insert_expected(
|
||||||
|
&mut expected,
|
||||||
|
node.construct_id.clone(),
|
||||||
|
node.location.clone(),
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
insert_real_expected(
|
||||||
|
&mut expected,
|
||||||
|
&source_nodes,
|
||||||
|
"document".to_owned(),
|
||||||
|
SourceLocation {
|
||||||
|
pointer: String::new(),
|
||||||
|
},
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
for (index, location) in server_locations(ir, &source_nodes)?.into_iter().enumerate() {
|
||||||
|
insert_real_expected(
|
||||||
|
&mut expected,
|
||||||
|
&source_nodes,
|
||||||
|
format!("{version}:server:{index}"),
|
||||||
|
location,
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_references = references_from_source_tree(&ir.source_tree);
|
||||||
|
if ir.unresolved_references != expected_references {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
for reference in &ir.unresolved_references {
|
||||||
|
insert_real_expected(
|
||||||
|
&mut expected,
|
||||||
|
&source_nodes,
|
||||||
|
reference.construct_id.clone(),
|
||||||
|
reference.location.clone(),
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let expected_paths = paths_from_operations(&ir.operations, &version);
|
||||||
|
if ir.paths != expected_paths {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
for path in &ir.paths {
|
||||||
|
insert_real_expected(
|
||||||
|
&mut expected,
|
||||||
|
&source_nodes,
|
||||||
|
path.construct_id.clone(),
|
||||||
|
path.location.clone(),
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_operation_order(&ir.operations)?;
|
||||||
|
for operation in &ir.operations {
|
||||||
|
collect_operation_expectations(operation, &version, &source_nodes, &mut expected)?;
|
||||||
|
}
|
||||||
|
collect_finding_only_expectations(&version, ir, &source_nodes, &mut expected)?;
|
||||||
|
|
||||||
|
let findings = ir
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.chain(
|
||||||
|
ir.operations
|
||||||
|
.iter()
|
||||||
|
.flat_map(|operation| &operation.findings),
|
||||||
|
)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for finding in &findings {
|
||||||
|
let Some(target) = expected.get(&finding.construct_id) else {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
};
|
||||||
|
if target.location != finding.location {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if expected.iter().any(|(construct_id, target)| {
|
||||||
|
target.disposition == CoverageDisposition::Finding
|
||||||
|
&& !findings.iter().any(|finding| {
|
||||||
|
finding.construct_id == *construct_id && finding.location == target.location
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut actual = BTreeMap::new();
|
||||||
|
for entry in &ir.coverage {
|
||||||
|
if actual
|
||||||
|
.insert(
|
||||||
|
entry.construct_id.clone(),
|
||||||
|
ExpectedCoverage {
|
||||||
|
location: entry.location.clone(),
|
||||||
|
disposition: entry.disposition.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if actual != expected {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
struct ExpectedCoverage {
|
||||||
|
location: SourceLocation,
|
||||||
|
disposition: CoverageDisposition,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_expected(
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
construct_id: String,
|
||||||
|
location: SourceLocation,
|
||||||
|
disposition: CoverageDisposition,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
let value = ExpectedCoverage {
|
||||||
|
location,
|
||||||
|
disposition,
|
||||||
|
};
|
||||||
|
if expected
|
||||||
|
.get(&construct_id)
|
||||||
|
.is_some_and(|existing| existing != &value)
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
expected.entry(construct_id).or_insert(value);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_real_expected(
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
construct_id: String,
|
||||||
|
location: SourceLocation,
|
||||||
|
disposition: CoverageDisposition,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
if !source_nodes.contains_key(&location.pointer) {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
insert_expected(expected, construct_id, location, disposition)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index_source_tree<'a>(
|
||||||
|
node: &'a CanonicalSourceNode,
|
||||||
|
expected_pointer: &str,
|
||||||
|
nodes: &mut BTreeMap<String, &'a CanonicalSourceNode>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
if node.location.pointer != expected_pointer
|
||||||
|
|| node.construct_id != format!("source:{expected_pointer}")
|
||||||
|
|| nodes.insert(expected_pointer.to_owned(), node).is_some()
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
match &node.value {
|
||||||
|
CanonicalSourceValue::Array(items) => {
|
||||||
|
for (index, child) in items.iter().enumerate() {
|
||||||
|
index_source_tree(child, &format!("{expected_pointer}/{index}"), nodes)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CanonicalSourceValue::Object(items) => {
|
||||||
|
for (key, child) in items {
|
||||||
|
index_source_tree(
|
||||||
|
child,
|
||||||
|
&format!("{expected_pointer}/{}", escape_pointer(key)),
|
||||||
|
nodes,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_source_contract(
|
||||||
|
ir: &NormalizedIr,
|
||||||
|
nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
) -> Result<String, ImportParseError> {
|
||||||
|
let version = ir
|
||||||
|
.source
|
||||||
|
.version
|
||||||
|
.as_deref()
|
||||||
|
.ok_or(ImportParseError::InvalidDocument)?;
|
||||||
|
let valid = match ir.source.format.as_str() {
|
||||||
|
"openapi" => string_at(nodes, "/openapi") == Some(version) && supported_oas(version),
|
||||||
|
"swagger" => version == "2.0" && string_at(nodes, "/swagger") == Some("2.0"),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !valid
|
||||||
|
|| ir.metadata.title != ir.source.title
|
||||||
|
|| ir.source.title != string_at(nodes, "/info/title").unwrap_or("Imported API")
|
||||||
|
|| ir.metadata.version != string_at(nodes, "/info/version").map(ToOwned::to_owned)
|
||||||
|
|| ir.metadata.description != string_at(nodes, "/info/description").map(ToOwned::to_owned)
|
||||||
|
|| ir.base_path_candidates
|
||||||
|
!= string_at(nodes, "/basePath")
|
||||||
|
.map(|value| vec![value.to_owned()])
|
||||||
|
.unwrap_or_default()
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
Ok(version.to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_oas(version: &str) -> bool {
|
||||||
|
["3.0.", "3.1."].into_iter().any(|prefix| {
|
||||||
|
version.strip_prefix(prefix).is_some_and(|patch| {
|
||||||
|
!patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string_at<'a>(
|
||||||
|
nodes: &'a BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
pointer: &str,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
match &nodes.get(pointer)?.value {
|
||||||
|
CanonicalSourceValue::String(value) => Some(value),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_locations(
|
||||||
|
ir: &NormalizedIr,
|
||||||
|
nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
) -> Result<Vec<SourceLocation>, ImportParseError> {
|
||||||
|
server_locations_for_source(&ir.source, nodes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_locations_for_source(
|
||||||
|
source: &ImportSourcePreview,
|
||||||
|
nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
) -> Result<Vec<SourceLocation>, ImportParseError> {
|
||||||
|
let (urls, locations) = if source.format == "openapi" {
|
||||||
|
openapi_servers(nodes)
|
||||||
|
} else {
|
||||||
|
swagger_servers(nodes)
|
||||||
|
};
|
||||||
|
if urls != source.servers {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
Ok(locations)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn server_coverage(
|
||||||
|
source: &ImportSourcePreview,
|
||||||
|
source_tree: &CanonicalSourceNode,
|
||||||
|
version: &str,
|
||||||
|
) -> Result<Vec<CoverageEntry>, ImportParseError> {
|
||||||
|
let mut nodes = BTreeMap::new();
|
||||||
|
index_source_tree(source_tree, "", &mut nodes)?;
|
||||||
|
Ok(server_locations_for_source(source, &nodes)?
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, location)| CoverageEntry {
|
||||||
|
construct_id: format!("{version}:server:{index}"),
|
||||||
|
location,
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn tag_locations(
|
||||||
|
root: &Value,
|
||||||
|
operation_pointer: &str,
|
||||||
|
tags: &[String],
|
||||||
|
) -> Result<Vec<SourceLocation>, ImportParseError> {
|
||||||
|
let source_tags = root
|
||||||
|
.pointer(&format!("{operation_pointer}/tags"))
|
||||||
|
.and_then(Value::as_array);
|
||||||
|
let locations = source_tags
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, value)| {
|
||||||
|
value.as_str().map(|value| {
|
||||||
|
(
|
||||||
|
value,
|
||||||
|
SourceLocation {
|
||||||
|
pointer: format!("{operation_pointer}/tags/{index}"),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if locations
|
||||||
|
.iter()
|
||||||
|
.map(|(value, _)| *value)
|
||||||
|
.ne(tags.iter().map(String::as_str))
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
Ok(locations
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, location)| location)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openapi_servers(
|
||||||
|
nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
) -> (Vec<String>, Vec<SourceLocation>) {
|
||||||
|
let Some(CanonicalSourceNode {
|
||||||
|
value: CanonicalSourceValue::Array(items),
|
||||||
|
..
|
||||||
|
}) = nodes.get("/servers").copied()
|
||||||
|
else {
|
||||||
|
return (Vec::new(), Vec::new());
|
||||||
|
};
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| {
|
||||||
|
let CanonicalSourceValue::Object(fields) = &item.value else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let CanonicalSourceValue::String(url) = &fields.get("url")?.value else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some((url.trim_end_matches('/').to_owned(), item.location.clone()))
|
||||||
|
})
|
||||||
|
.unzip()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn swagger_servers(
|
||||||
|
nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
) -> (Vec<String>, Vec<SourceLocation>) {
|
||||||
|
let Some(host) = string_at(nodes, "/host") else {
|
||||||
|
return (Vec::new(), Vec::new());
|
||||||
|
};
|
||||||
|
let base_path = string_at(nodes, "/basePath").unwrap_or("");
|
||||||
|
let schemes = nodes
|
||||||
|
.get("/schemes")
|
||||||
|
.and_then(|node| match &node.value {
|
||||||
|
CanonicalSourceValue::Array(items) => Some(
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| match &item.value {
|
||||||
|
CanonicalSourceValue::String(value) => Some((value.as_str(), item)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.filter(|items| !items.is_empty());
|
||||||
|
let schemes = schemes.unwrap_or_else(|| {
|
||||||
|
vec![(
|
||||||
|
"https",
|
||||||
|
*nodes.get("/host").expect("host was checked above"),
|
||||||
|
)]
|
||||||
|
});
|
||||||
|
schemes
|
||||||
|
.into_iter()
|
||||||
|
.map(|(scheme, node)| {
|
||||||
|
(
|
||||||
|
format!("{scheme}://{host}{base_path}")
|
||||||
|
.trim_end_matches('/')
|
||||||
|
.to_owned(),
|
||||||
|
node.location.clone(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unzip()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_operation_order(operations: &[NormalizedOperation]) -> Result<(), ImportParseError> {
|
||||||
|
if operations
|
||||||
|
.windows(2)
|
||||||
|
.any(|pair| operation_sort_key(&pair[0]) >= operation_sort_key(&pair[1]))
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn operation_sort_key(operation: &NormalizedOperation) -> (&str, u8, &str) {
|
||||||
|
(
|
||||||
|
&operation.path,
|
||||||
|
method_rank(operation.method),
|
||||||
|
&operation.location.pointer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_rank(method: crank_core::HttpMethod) -> u8 {
|
||||||
|
match method {
|
||||||
|
crank_core::HttpMethod::Get => 0,
|
||||||
|
crank_core::HttpMethod::Post => 1,
|
||||||
|
crank_core::HttpMethod::Put => 2,
|
||||||
|
crank_core::HttpMethod::Patch => 3,
|
||||||
|
crank_core::HttpMethod::Delete => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn method_name(method: crank_core::HttpMethod) -> &'static str {
|
||||||
|
match method {
|
||||||
|
crank_core::HttpMethod::Get => "get",
|
||||||
|
crank_core::HttpMethod::Post => "post",
|
||||||
|
crank_core::HttpMethod::Put => "put",
|
||||||
|
crank_core::HttpMethod::Patch => "patch",
|
||||||
|
crank_core::HttpMethod::Delete => "delete",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_operation_expectations(
|
||||||
|
operation: &NormalizedOperation,
|
||||||
|
version: &str,
|
||||||
|
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
let method = method_name(operation.method);
|
||||||
|
let pointer = format!("/paths/{}/{method}", escape_pointer(&operation.path));
|
||||||
|
let stable_id = format!("{version}:{method}:{pointer}");
|
||||||
|
if operation.location.pointer != pointer
|
||||||
|
|| operation.stable_id != stable_id
|
||||||
|
|| operation.key != format!("{} {}", method.to_ascii_uppercase(), operation.path)
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
insert_real_expected(
|
||||||
|
expected,
|
||||||
|
source_nodes,
|
||||||
|
stable_id.clone(),
|
||||||
|
operation.location.clone(),
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let operation_id_location = SourceLocation {
|
||||||
|
pointer: format!("{pointer}/operationId"),
|
||||||
|
};
|
||||||
|
let operation_id_disposition = match operation
|
||||||
|
.operation_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
{
|
||||||
|
Some(value) if string_at(source_nodes, &operation_id_location.pointer) == Some(value) => {
|
||||||
|
CoverageDisposition::Mapped
|
||||||
|
}
|
||||||
|
Some(_) => return Err(ImportParseError::InvalidDocument),
|
||||||
|
None => CoverageDisposition::Finding,
|
||||||
|
};
|
||||||
|
if operation_id_disposition == CoverageDisposition::Mapped
|
||||||
|
|| source_nodes.contains_key(&operation_id_location.pointer)
|
||||||
|
{
|
||||||
|
insert_real_expected(
|
||||||
|
expected,
|
||||||
|
source_nodes,
|
||||||
|
format!("{stable_id}:operation_id"),
|
||||||
|
operation_id_location,
|
||||||
|
operation_id_disposition,
|
||||||
|
)?;
|
||||||
|
} else {
|
||||||
|
insert_expected(
|
||||||
|
expected,
|
||||||
|
format!("{stable_id}:operation_id"),
|
||||||
|
operation_id_location,
|
||||||
|
CoverageDisposition::Finding,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tag_locations = normalized_tag_locations(source_nodes, &pointer, &operation.tags)?;
|
||||||
|
for (index, location) in tag_locations.into_iter().enumerate() {
|
||||||
|
insert_real_expected(
|
||||||
|
expected,
|
||||||
|
source_nodes,
|
||||||
|
format!("{stable_id}:tag:{index}"),
|
||||||
|
location,
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for parameter in &operation.parameters {
|
||||||
|
if parameter.construct_id
|
||||||
|
!= format!(
|
||||||
|
"parameter:{}",
|
||||||
|
escape_pointer(¶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<String, &CanonicalSourceNode>,
|
||||||
|
operation_pointer: &str,
|
||||||
|
tags: &[String],
|
||||||
|
) -> Result<Vec<SourceLocation>, ImportParseError> {
|
||||||
|
let tags_pointer = format!("{operation_pointer}/tags");
|
||||||
|
let source_tags = nodes.get(&tags_pointer).and_then(|node| match &node.value {
|
||||||
|
CanonicalSourceValue::Array(items) => Some(items),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
let locations = source_tags
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|node| match &node.value {
|
||||||
|
CanonicalSourceValue::String(value) => Some((value, node.location.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if locations.iter().map(|(value, _)| *value).ne(tags.iter()) {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
Ok(locations
|
||||||
|
.into_iter()
|
||||||
|
.map(|(_, location)| location)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_schema_expectations(
|
||||||
|
schema: &NormalizedSchema,
|
||||||
|
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
if schema.construct_id != format!("schema:{}", escape_pointer(&schema.location.pointer)) {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
insert_real_expected(
|
||||||
|
expected,
|
||||||
|
source_nodes,
|
||||||
|
schema.construct_id.clone(),
|
||||||
|
schema.location.clone(),
|
||||||
|
CoverageDisposition::Mapped,
|
||||||
|
)?;
|
||||||
|
if schema.description
|
||||||
|
!= string_at(
|
||||||
|
source_nodes,
|
||||||
|
&format!("{}/description", schema.location.pointer),
|
||||||
|
)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
{
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
}
|
||||||
|
match &schema.kind {
|
||||||
|
NormalizedSchemaKind::Object { properties, .. } => {
|
||||||
|
for child in properties.values() {
|
||||||
|
collect_schema_expectations(child, source_nodes, expected)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NormalizedSchemaKind::Array { items: Some(child) } => {
|
||||||
|
collect_schema_expectations(child, source_nodes, expected)?;
|
||||||
|
}
|
||||||
|
NormalizedSchemaKind::Composition { variants, .. } => {
|
||||||
|
for child in variants {
|
||||||
|
collect_schema_expectations(child, source_nodes, expected)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_finding_only_expectations(
|
||||||
|
version: &str,
|
||||||
|
ir: &NormalizedIr,
|
||||||
|
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
if ir.source.format == "openapi" && ir.source.servers.len() > 1 {
|
||||||
|
let location = SourceLocation {
|
||||||
|
pointer: "/servers".to_owned(),
|
||||||
|
};
|
||||||
|
insert_real_expected(
|
||||||
|
expected,
|
||||||
|
source_nodes,
|
||||||
|
format!("{version}:/servers"),
|
||||||
|
location,
|
||||||
|
CoverageDisposition::Finding,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if ir.source.format == "openapi"
|
||||||
|
&& let Some(servers) = source_nodes.get("/servers").copied()
|
||||||
|
{
|
||||||
|
collect_invalid_openapi_server_findings(version, servers, expected)?;
|
||||||
|
}
|
||||||
|
let Some(CanonicalSourceNode {
|
||||||
|
value: CanonicalSourceValue::Object(paths),
|
||||||
|
..
|
||||||
|
}) = source_nodes.get("/paths").copied()
|
||||||
|
else {
|
||||||
|
return Err(ImportParseError::InvalidDocument);
|
||||||
|
};
|
||||||
|
for path_item in paths.values() {
|
||||||
|
let CanonicalSourceValue::Object(methods) = &path_item.value else {
|
||||||
|
insert_finding_pointer(version, path_item, expected)?;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if ir.source.format == "openapi"
|
||||||
|
&& let Some(servers) = methods.get("servers")
|
||||||
|
{
|
||||||
|
collect_invalid_openapi_server_findings(version, servers, expected)?;
|
||||||
|
}
|
||||||
|
if let Some(parameters) = methods.get("parameters") {
|
||||||
|
collect_dropped_parameter_findings(
|
||||||
|
version,
|
||||||
|
parameters,
|
||||||
|
ir.source.format.as_str(),
|
||||||
|
expected,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
for method in ["head", "options", "trace", "connect"] {
|
||||||
|
if let Some(node) = methods.get(method) {
|
||||||
|
insert_finding_pointer(version, node, expected)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for method in ["get", "post", "put", "patch", "delete"] {
|
||||||
|
if let Some(node) = methods.get(method) {
|
||||||
|
match &node.value {
|
||||||
|
CanonicalSourceValue::Object(operation) => {
|
||||||
|
if ir.source.format == "openapi"
|
||||||
|
&& let Some(servers) = operation.get("servers")
|
||||||
|
{
|
||||||
|
collect_invalid_openapi_server_findings(version, servers, expected)?;
|
||||||
|
}
|
||||||
|
if let Some(tags) = operation.get("tags") {
|
||||||
|
collect_invalid_tag_findings(version, tags, expected)?;
|
||||||
|
}
|
||||||
|
if let Some(parameters) = operation.get("parameters") {
|
||||||
|
collect_dropped_parameter_findings(
|
||||||
|
version,
|
||||||
|
parameters,
|
||||||
|
ir.source.format.as_str(),
|
||||||
|
expected,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => insert_finding_pointer(version, node, expected)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_invalid_openapi_server_findings(
|
||||||
|
version: &str,
|
||||||
|
servers: &CanonicalSourceNode,
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
let CanonicalSourceValue::Array(items) = &servers.value else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
for server in items {
|
||||||
|
let valid = matches!(
|
||||||
|
&server.value,
|
||||||
|
CanonicalSourceValue::Object(fields)
|
||||||
|
if matches!(fields.get("url").map(|node| &node.value), Some(CanonicalSourceValue::String(_)))
|
||||||
|
);
|
||||||
|
if !valid {
|
||||||
|
insert_finding_pointer(version, server, expected)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_invalid_tag_findings(
|
||||||
|
version: &str,
|
||||||
|
tags: &CanonicalSourceNode,
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
let CanonicalSourceValue::Array(items) = &tags.value else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
for tag in items {
|
||||||
|
if !matches!(tag.value, CanonicalSourceValue::String(_)) {
|
||||||
|
insert_finding_pointer(version, tag, expected)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_dropped_parameter_findings(
|
||||||
|
version: &str,
|
||||||
|
parameters: &CanonicalSourceNode,
|
||||||
|
format: &str,
|
||||||
|
expected: &mut BTreeMap<String, ExpectedCoverage>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
let CanonicalSourceValue::Array(items) = ¶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<String, ExpectedCoverage>,
|
||||||
|
) -> Result<(), ImportParseError> {
|
||||||
|
insert_expected(
|
||||||
|
expected,
|
||||||
|
format!("{version}:{}", node.location.pointer),
|
||||||
|
node.location.clone(),
|
||||||
|
CoverageDisposition::Finding,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn references_from_source_tree(
|
||||||
|
source_tree: &CanonicalSourceNode,
|
||||||
|
) -> Vec<NormalizedReference> {
|
||||||
|
let mut references = Vec::new();
|
||||||
|
collect_references(source_tree, &mut references);
|
||||||
|
references.sort_by(|left, right| left.construct_id.cmp(&right.construct_id));
|
||||||
|
references
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_references(node: &CanonicalSourceNode, references: &mut Vec<NormalizedReference>) {
|
||||||
|
match &node.value {
|
||||||
|
CanonicalSourceValue::Array(items) => {
|
||||||
|
for item in items {
|
||||||
|
collect_references(item, references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CanonicalSourceValue::Object(items) => {
|
||||||
|
if let Some(CanonicalSourceNode {
|
||||||
|
value: CanonicalSourceValue::String(uri),
|
||||||
|
location,
|
||||||
|
..
|
||||||
|
}) = items.get("$ref")
|
||||||
|
{
|
||||||
|
references.push(NormalizedReference {
|
||||||
|
construct_id: format!("reference:{}", escape_pointer(&location.pointer)),
|
||||||
|
uri: uri.clone(),
|
||||||
|
location: location.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for item in items.values() {
|
||||||
|
collect_references(item, references);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn canonical_source_tree(
|
||||||
|
value: &Value,
|
||||||
|
pointer: &str,
|
||||||
|
coverage: &mut Vec<CoverageEntry>,
|
||||||
|
) -> CanonicalSourceNode {
|
||||||
|
let id = format!("source:{pointer}");
|
||||||
|
let location = SourceLocation {
|
||||||
|
pointer: pointer.to_owned(),
|
||||||
|
};
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: id.clone(),
|
||||||
|
location: location.clone(),
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
});
|
||||||
|
let value = match value {
|
||||||
|
Value::Null => CanonicalSourceValue::Null,
|
||||||
|
Value::Bool(value) => CanonicalSourceValue::Boolean(*value),
|
||||||
|
Value::Number(value) => CanonicalSourceValue::Number(value.to_string()),
|
||||||
|
Value::String(value) => CanonicalSourceValue::String(value.clone()),
|
||||||
|
Value::Array(items) => CanonicalSourceValue::Array(
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, value)| {
|
||||||
|
canonical_source_tree(value, &format!("{pointer}/{index}"), coverage)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
Value::Object(items) => CanonicalSourceValue::Object(
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|(key, value)| {
|
||||||
|
(
|
||||||
|
key.clone(),
|
||||||
|
canonical_source_tree(
|
||||||
|
value,
|
||||||
|
&format!("{pointer}/{}", escape_pointer(key)),
|
||||||
|
coverage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
CanonicalSourceNode {
|
||||||
|
construct_id: id,
|
||||||
|
location,
|
||||||
|
value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn paths_from_operations(
|
||||||
|
operations: &[NormalizedOperation],
|
||||||
|
version: &str,
|
||||||
|
) -> Vec<NormalizedPath> {
|
||||||
|
let mut paths = BTreeMap::<String, Vec<String>>::new();
|
||||||
|
for operation in operations {
|
||||||
|
paths
|
||||||
|
.entry(operation.path.clone())
|
||||||
|
.or_default()
|
||||||
|
.push(operation.stable_id.clone());
|
||||||
|
}
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|(path, operation_ids)| NormalizedPath {
|
||||||
|
construct_id: format!("{version}:path:{path}"),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: format!("/paths/{}", escape_pointer(&path)),
|
||||||
|
},
|
||||||
|
path,
|
||||||
|
operation_ids,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn escape_pointer(value: &str) -> String {
|
||||||
|
value.replace('~', "~0").replace('/', "~1")
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum AliasQuote {
|
||||||
|
Single,
|
||||||
|
Double,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
struct BlockScalar {
|
||||||
|
parent_indent: usize,
|
||||||
|
content_indent: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn alias_count(document: &str) -> usize {
|
||||||
|
// YAML lexical preflight: aliases are counted before serde_yaml can expand
|
||||||
|
// them. This is deliberately a small lexer rather than a second YAML
|
||||||
|
// parser: it understands quote escapes, comments, and flow aliases while
|
||||||
|
// keeping the scan bounded by the source size.
|
||||||
|
//
|
||||||
|
// Block scalars are the one intentionally conservative exception. Their
|
||||||
|
// contents are opaque YAML text, and reproducing YAML's indentation rules
|
||||||
|
// here would create a second parser with its own correctness risks. If a
|
||||||
|
// content line contains `*`, fail closed by returning usize::MAX; this
|
||||||
|
// prevents an alias-looking token from being hidden in a literal block at
|
||||||
|
// the cost of rejecting that block before decode.
|
||||||
|
let mut count = 0;
|
||||||
|
let mut quote = None;
|
||||||
|
let mut block_scalar: Option<BlockScalar> = None;
|
||||||
|
|
||||||
|
for line in document.split('\n') {
|
||||||
|
if let Some(block) = block_scalar {
|
||||||
|
if is_blank_line(line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let indent = leading_spaces(line);
|
||||||
|
let is_content = match block.content_indent {
|
||||||
|
Some(content_indent) => indent >= content_indent,
|
||||||
|
None => indent > block.parent_indent,
|
||||||
|
};
|
||||||
|
if is_content {
|
||||||
|
if line.as_bytes().contains(&b'*') {
|
||||||
|
return usize::MAX;
|
||||||
|
}
|
||||||
|
if block.content_indent.is_none() {
|
||||||
|
block_scalar = Some(BlockScalar {
|
||||||
|
content_indent: Some(indent),
|
||||||
|
..block
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
block_scalar = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = line.as_bytes();
|
||||||
|
let line_indent = leading_spaces(line);
|
||||||
|
let mut comment = false;
|
||||||
|
let mut index = 0;
|
||||||
|
while index < bytes.len() {
|
||||||
|
let byte = bytes[index];
|
||||||
|
if comment {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(current) = quote {
|
||||||
|
match current {
|
||||||
|
AliasQuote::Single if byte == b'\'' => {
|
||||||
|
// YAML escapes a single quote by doubling it. Consume
|
||||||
|
// both bytes so the second quote cannot reopen a
|
||||||
|
// scalar and desynchronise the scan.
|
||||||
|
if bytes.get(index + 1) == Some(&b'\'') {
|
||||||
|
index += 2;
|
||||||
|
} else {
|
||||||
|
quote = None;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AliasQuote::Double if byte == b'\\' => {
|
||||||
|
// A backslash escapes the following byte, including a
|
||||||
|
// quote. At end of line it only folds the YAML line;
|
||||||
|
// the quote remains open for the next line.
|
||||||
|
index += if index + 1 < bytes.len() { 2 } else { 1 };
|
||||||
|
}
|
||||||
|
AliasQuote::Double if byte == b'"' => {
|
||||||
|
quote = None;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
_ => index += 1,
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match byte {
|
||||||
|
b'\'' => {
|
||||||
|
quote = Some(AliasQuote::Single);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
b'"' => {
|
||||||
|
quote = Some(AliasQuote::Double);
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
b'#' if index == 0
|
||||||
|
|| bytes
|
||||||
|
.get(index - 1)
|
||||||
|
.is_some_and(|previous| previous.is_ascii_whitespace()) =>
|
||||||
|
{
|
||||||
|
comment = true;
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
b'*' => {
|
||||||
|
let previous = index.checked_sub(1).and_then(|i| bytes.get(i)).copied();
|
||||||
|
let next = bytes.get(index + 1).copied();
|
||||||
|
if previous
|
||||||
|
.is_none_or(|byte| byte.is_ascii_whitespace() || b"[:,[{".contains(&byte))
|
||||||
|
&& next.is_some_and(is_alias_name_byte)
|
||||||
|
{
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
b'|' | b'>' if is_block_scalar_indicator(bytes, index) => {
|
||||||
|
block_scalar = Some(BlockScalar {
|
||||||
|
parent_indent: line_indent,
|
||||||
|
content_indent: block_scalar_indent(bytes, index, line_indent),
|
||||||
|
});
|
||||||
|
// The rest of a block-scalar header cannot contain YAML
|
||||||
|
// aliases; its content starts on the next line.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => index += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_alias_name_byte(byte: u8) -> bool {
|
||||||
|
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leading_spaces(line: &str) -> usize {
|
||||||
|
line.bytes().take_while(|byte| *byte == b' ').count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_blank_line(line: &str) -> bool {
|
||||||
|
line.bytes()
|
||||||
|
.all(|byte| byte == b' ' || byte == b'\t' || byte == b'\r')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_block_scalar_indicator(bytes: &[u8], index: usize) -> bool {
|
||||||
|
let mut previous_position = index;
|
||||||
|
while previous_position > 0
|
||||||
|
&& bytes
|
||||||
|
.get(previous_position - 1)
|
||||||
|
.is_some_and(|byte| byte.is_ascii_whitespace())
|
||||||
|
{
|
||||||
|
previous_position -= 1;
|
||||||
|
}
|
||||||
|
let previous_is_value_boundary = previous_position == 0
|
||||||
|
|| bytes
|
||||||
|
.get(previous_position - 1)
|
||||||
|
.is_some_and(|byte| *byte == b':' || *byte == b'-');
|
||||||
|
let next = bytes.get(index + 1).copied();
|
||||||
|
let next_is_header_suffix = next.is_none_or(|byte| {
|
||||||
|
byte.is_ascii_whitespace()
|
||||||
|
|| byte == b'#'
|
||||||
|
|| byte == b'+'
|
||||||
|
|| byte == b'-'
|
||||||
|
|| byte.is_ascii_digit()
|
||||||
|
});
|
||||||
|
previous_is_value_boundary && next_is_header_suffix
|
||||||
|
}
|
||||||
|
|
||||||
|
fn block_scalar_indent(bytes: &[u8], index: usize, parent_indent: usize) -> Option<usize> {
|
||||||
|
let mut position = index + 1;
|
||||||
|
while let Some(byte) = bytes.get(position).copied() {
|
||||||
|
if byte == b'+' || byte == b'-' {
|
||||||
|
position += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if byte.is_ascii_digit() && byte != b'0' {
|
||||||
|
return Some(parent_indent + usize::from(byte - b'0'));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::rest::model::{
|
||||||
|
CoverageDisposition, CoverageEntry, NormalizedLiteral, NormalizedScalarKind, NormalizedSchema,
|
||||||
|
NormalizedSchemaKind, SourceLocation, UnresolvedReference,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::normalize_coverage::escape_pointer;
|
||||||
|
|
||||||
|
pub(super) fn typed_schema(
|
||||||
|
value: &Value,
|
||||||
|
pointer: &str,
|
||||||
|
_id: &str,
|
||||||
|
coverage: &mut Vec<CoverageEntry>,
|
||||||
|
) -> NormalizedSchema {
|
||||||
|
// IDs are derived from RFC 6901 locations rather than user-controlled
|
||||||
|
// property names, so escaped keys cannot collide with one another.
|
||||||
|
let id = format!("schema:{}", escape_pointer(pointer));
|
||||||
|
let location = SourceLocation {
|
||||||
|
pointer: pointer.to_owned(),
|
||||||
|
};
|
||||||
|
coverage.push(CoverageEntry {
|
||||||
|
construct_id: id.clone(),
|
||||||
|
location: location.clone(),
|
||||||
|
disposition: CoverageDisposition::Mapped,
|
||||||
|
});
|
||||||
|
let kind = if let Some(reference) = value.get("$ref").and_then(Value::as_str) {
|
||||||
|
NormalizedSchemaKind::Reference {
|
||||||
|
reference: UnresolvedReference {
|
||||||
|
uri: reference.to_owned(),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: format!("{pointer}/$ref"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} else if let Some((operator, variants)) =
|
||||||
|
["allOf", "oneOf", "anyOf"]
|
||||||
|
.into_iter()
|
||||||
|
.find_map(|operator| {
|
||||||
|
value
|
||||||
|
.get(operator)
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| (operator, items))
|
||||||
|
})
|
||||||
|
{
|
||||||
|
NormalizedSchemaKind::Composition {
|
||||||
|
operator: operator.to_owned(),
|
||||||
|
variants: variants
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, item)| {
|
||||||
|
typed_schema(
|
||||||
|
item,
|
||||||
|
&format!("{pointer}/{operator}/{index}"),
|
||||||
|
&format!(
|
||||||
|
"schema:{}",
|
||||||
|
escape_pointer(&format!("{pointer}/{operator}/{index}"))
|
||||||
|
),
|
||||||
|
coverage,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
}
|
||||||
|
} else if value.get("properties").is_some()
|
||||||
|
|| value.get("type").and_then(Value::as_str) == Some("object")
|
||||||
|
{
|
||||||
|
let required = value
|
||||||
|
.get("required")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let properties = value
|
||||||
|
.get("properties")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.map(|properties| {
|
||||||
|
properties
|
||||||
|
.iter()
|
||||||
|
.map(|(name, schema)| {
|
||||||
|
(
|
||||||
|
name.clone(),
|
||||||
|
typed_schema(
|
||||||
|
schema,
|
||||||
|
&format!("{pointer}/properties/{}", escape_pointer(name)),
|
||||||
|
&format!(
|
||||||
|
"schema:{}",
|
||||||
|
escape_pointer(&format!(
|
||||||
|
"{pointer}/properties/{}",
|
||||||
|
escape_pointer(name)
|
||||||
|
))
|
||||||
|
),
|
||||||
|
coverage,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
NormalizedSchemaKind::Object {
|
||||||
|
properties,
|
||||||
|
required,
|
||||||
|
}
|
||||||
|
} else if value.get("type").and_then(Value::as_str) == Some("array") {
|
||||||
|
NormalizedSchemaKind::Array {
|
||||||
|
items: value.get("items").map(|items| {
|
||||||
|
Box::new(typed_schema(
|
||||||
|
items,
|
||||||
|
&format!("{pointer}/items"),
|
||||||
|
&format!("schema:{}", escape_pointer(&format!("{pointer}/items"))),
|
||||||
|
coverage,
|
||||||
|
))
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
} else if let Some(raw_type) = value.get("type").and_then(Value::as_str) {
|
||||||
|
NormalizedSchemaKind::Scalar {
|
||||||
|
scalar_type: match raw_type {
|
||||||
|
"integer" => NormalizedScalarKind::Integer,
|
||||||
|
"number" => NormalizedScalarKind::Number,
|
||||||
|
"boolean" => NormalizedScalarKind::Boolean,
|
||||||
|
"null" => NormalizedScalarKind::Null,
|
||||||
|
_ => NormalizedScalarKind::String,
|
||||||
|
},
|
||||||
|
format: value
|
||||||
|
.get("format")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
nullable: value
|
||||||
|
.get("nullable")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
default_value: value.get("default").and_then(normalized_literal),
|
||||||
|
enum_values: value
|
||||||
|
.get("enum")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| items.iter().filter_map(normalized_literal).collect())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NormalizedSchemaKind::Unknown
|
||||||
|
};
|
||||||
|
NormalizedSchema {
|
||||||
|
construct_id: id,
|
||||||
|
location,
|
||||||
|
description: value
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
kind,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn has_reference_schema(schema: Option<&NormalizedSchema>) -> bool {
|
||||||
|
match schema.map(|schema| &schema.kind) {
|
||||||
|
Some(NormalizedSchemaKind::Reference { .. }) => true,
|
||||||
|
Some(NormalizedSchemaKind::Object { properties, .. }) => properties
|
||||||
|
.values()
|
||||||
|
.any(|schema| has_reference_schema(Some(schema))),
|
||||||
|
Some(NormalizedSchemaKind::Array { items }) => has_reference_schema(items.as_deref()),
|
||||||
|
Some(NormalizedSchemaKind::Composition { variants, .. }) => variants
|
||||||
|
.iter()
|
||||||
|
.any(|schema| has_reference_schema(Some(schema))),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_literal(value: &Value) -> Option<NormalizedLiteral> {
|
||||||
|
match value {
|
||||||
|
Value::String(value) => Some(NormalizedLiteral::String(value.clone())),
|
||||||
|
Value::Bool(value) => Some(NormalizedLiteral::Boolean(*value)),
|
||||||
|
Value::Null => Some(NormalizedLiteral::Null),
|
||||||
|
Value::Number(value) => value
|
||||||
|
.as_i64()
|
||||||
|
.map(NormalizedLiteral::Integer)
|
||||||
|
.or_else(|| value.as_u64().map(NormalizedLiteral::Unsigned))
|
||||||
|
.or_else(|| value.as_f64().map(NormalizedLiteral::Number)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,14 +3,18 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::rest::{
|
use crate::rest::{
|
||||||
model::{
|
model::{
|
||||||
ImportFinding, RestImportDocument, RestImportOperation, RestImportParameter,
|
ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation,
|
||||||
RestParameterLocation,
|
RestImportParameter, RestParameterLocation,
|
||||||
},
|
},
|
||||||
normalize::{ImportParseError, resolve_local_ref},
|
normalize::ImportParseError,
|
||||||
recommendations::document_finding,
|
recommendations::{document_blocker, document_finding},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||||
|
parse_document_v2(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_document_v2(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||||
let version = root
|
let version = root
|
||||||
.get("openapi")
|
.get("openapi")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -20,17 +24,196 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or("Imported API")
|
.unwrap_or("Imported API")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
let servers = root
|
let mut findings = Vec::new();
|
||||||
.get("servers")
|
let mut internal_finding_locations = Vec::new();
|
||||||
.and_then(Value::as_array)
|
let servers = parse_servers(root.get("servers"), "/servers");
|
||||||
.map(|items| {
|
append_findings_with_locations(
|
||||||
items
|
&mut findings,
|
||||||
.iter()
|
&mut internal_finding_locations,
|
||||||
.filter_map(|item| item.get("url").and_then(Value::as_str))
|
servers.findings,
|
||||||
.map(|url| url.trim_end_matches('/').to_owned())
|
);
|
||||||
.collect::<Vec<_>>()
|
let servers = servers.servers;
|
||||||
|
if servers.is_empty() {
|
||||||
|
findings.push(document_finding(
|
||||||
|
"missing_servers",
|
||||||
|
"В документе не указаны servers, base URL нужно будет выбрать вручную.",
|
||||||
|
));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: String::new(),
|
||||||
|
}));
|
||||||
|
} else if servers.len() > 1 {
|
||||||
|
findings.push(document_finding(
|
||||||
|
"multiple_servers",
|
||||||
|
"В документе несколько servers, при импорте нужно выбрать нужный base URL.",
|
||||||
|
));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: "/servers".to_owned(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut operations = Vec::new();
|
||||||
|
let paths = root
|
||||||
|
.get("paths")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.ok_or(ImportParseError::UnsupportedDocument)?;
|
||||||
|
|
||||||
|
for (path, path_item) in paths {
|
||||||
|
if !path_item.is_object() {
|
||||||
|
findings.push(path_item_blocker(path));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("/paths/{}", path.replace('~', "~0").replace('/', "~1")),
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for method_name in ["head", "options", "trace", "connect"] {
|
||||||
|
if path_item.get(method_name).is_some() {
|
||||||
|
findings.push(document_blocker(
|
||||||
|
"unsupported_http_method",
|
||||||
|
format!("Метод {method_name} для пути {path} пока не поддерживается."),
|
||||||
|
));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"/paths/{}/{}",
|
||||||
|
path.replace('~', "~0").replace('/', "~1"),
|
||||||
|
method_name
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
|
||||||
|
let path_parameters = parameters(
|
||||||
|
path_item.get("parameters"),
|
||||||
|
&format!("{path_pointer}/parameters"),
|
||||||
|
);
|
||||||
|
append_findings_with_locations(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
path_parameters.findings,
|
||||||
|
);
|
||||||
|
let path_parameters = path_parameters.parameters;
|
||||||
|
let path_servers =
|
||||||
|
parse_servers(path_item.get("servers"), &format!("{path_pointer}/servers"));
|
||||||
|
append_findings_with_locations(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
path_servers.findings,
|
||||||
|
);
|
||||||
|
let path_servers = path_servers.servers;
|
||||||
|
for method_name in ["get", "post", "put", "patch", "delete"] {
|
||||||
|
let Some(operation_value) = path_item.get(method_name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !operation_value.is_object() {
|
||||||
|
findings.push(document_blocker(
|
||||||
|
"invalid_operation",
|
||||||
|
"Операция имеет неверную структуру и не была интерпретирована.",
|
||||||
|
));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"/paths/{}/{}",
|
||||||
|
path.replace('~', "~0").replace('/', "~1"),
|
||||||
|
method_name
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(method) = method_from_lower(method_name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let operation_pointer = format!("{path_pointer}/{method_name}");
|
||||||
|
|
||||||
|
let mut operation_parameters = path_parameters.clone();
|
||||||
|
let parsed_parameters = parameters(
|
||||||
|
operation_value.get("parameters"),
|
||||||
|
&format!("{path_pointer}/{method_name}/parameters"),
|
||||||
|
);
|
||||||
|
append_findings_with_locations(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
parsed_parameters.findings,
|
||||||
|
);
|
||||||
|
operation_parameters.extend(parsed_parameters.parameters);
|
||||||
|
let operation_servers = parse_servers(
|
||||||
|
operation_value.get("servers"),
|
||||||
|
&format!("{operation_pointer}/servers"),
|
||||||
|
);
|
||||||
|
append_findings_with_locations(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
operation_servers.findings,
|
||||||
|
);
|
||||||
|
let operation_servers = operation_servers.servers;
|
||||||
|
let tags = tags(
|
||||||
|
operation_value.get("tags"),
|
||||||
|
&format!("{operation_pointer}/tags"),
|
||||||
|
);
|
||||||
|
append_findings_with_locations(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
tags.findings,
|
||||||
|
);
|
||||||
|
|
||||||
|
operations.push(RestImportOperation {
|
||||||
|
key: format!("{} {}", method_name.to_uppercase(), path),
|
||||||
|
method,
|
||||||
|
path: path.clone(),
|
||||||
|
operation_id: operation_value
|
||||||
|
.get("operationId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
summary: operation_value
|
||||||
|
.get("summary")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
description: operation_value
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
tags: tags.tags,
|
||||||
|
parameters: operation_parameters,
|
||||||
|
request_body_schema: request_body_schema(operation_value, &operation_pointer)
|
||||||
|
.map(|(schema, _)| schema),
|
||||||
|
request_body_schema_location: request_body_schema(
|
||||||
|
operation_value,
|
||||||
|
&operation_pointer,
|
||||||
|
)
|
||||||
|
.map(|(_, location)| location),
|
||||||
|
response_schema: response_schema(operation_value, &operation_pointer)
|
||||||
|
.map(|(schema, _)| schema),
|
||||||
|
response_schema_location: response_schema(operation_value, &operation_pointer)
|
||||||
|
.map(|(_, location)| location),
|
||||||
|
servers: if operation_servers.is_empty() {
|
||||||
|
path_servers.clone()
|
||||||
|
} else {
|
||||||
|
operation_servers
|
||||||
|
},
|
||||||
|
findings: operation_findings(operation_value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RestImportDocument {
|
||||||
|
format: "openapi".to_owned(),
|
||||||
|
version,
|
||||||
|
title,
|
||||||
|
servers,
|
||||||
|
operations,
|
||||||
|
findings,
|
||||||
|
internal_finding_locations,
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
}
|
||||||
|
|
||||||
|
pub fn parse_document_legacy_v1(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||||
|
let version = root
|
||||||
|
.get("openapi")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned);
|
||||||
|
let title = root
|
||||||
|
.pointer("/info/title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Imported API")
|
||||||
|
.to_owned();
|
||||||
|
let servers = legacy_servers(root.get("servers"));
|
||||||
let mut findings = Vec::new();
|
let mut findings = Vec::new();
|
||||||
if servers.is_empty() {
|
if servers.is_empty() {
|
||||||
findings.push(document_finding(
|
findings.push(document_finding(
|
||||||
@@ -49,49 +232,80 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
|
|||||||
.get("paths")
|
.get("paths")
|
||||||
.and_then(Value::as_object)
|
.and_then(Value::as_object)
|
||||||
.ok_or(ImportParseError::UnsupportedDocument)?;
|
.ok_or(ImportParseError::UnsupportedDocument)?;
|
||||||
|
|
||||||
for (path, path_item) in paths {
|
for (path, path_item) in paths {
|
||||||
let path_parameters = parameters(root, path_item.get("parameters"));
|
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
|
||||||
|
let path_parameters = legacy_parameters(
|
||||||
|
root,
|
||||||
|
path_item.get("parameters"),
|
||||||
|
&format!("{path_pointer}/parameters"),
|
||||||
|
);
|
||||||
for method_name in ["get", "post", "put", "patch", "delete"] {
|
for method_name in ["get", "post", "put", "patch", "delete"] {
|
||||||
let Some(operation_value) = path_item.get(method_name) else {
|
let Some(operation) = path_item.get(method_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let Some(method) = method_from_lower(method_name) else {
|
let Some(method) = method_from_lower(method_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
let operation_pointer = format!("{path_pointer}/{method_name}");
|
||||||
|
let mut parameters = path_parameters.clone();
|
||||||
|
parameters.extend(legacy_parameters(
|
||||||
|
root,
|
||||||
|
operation.get("parameters"),
|
||||||
|
&format!("{operation_pointer}/parameters"),
|
||||||
|
));
|
||||||
|
operations.push(RestImportOperation {
|
||||||
|
key: format!("{} {}", method_name.to_uppercase(), path),
|
||||||
|
method,
|
||||||
|
path: path.clone(),
|
||||||
|
operation_id: operation
|
||||||
|
.get("operationId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
summary: operation
|
||||||
|
.get("summary")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
description: operation
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
tags: legacy_tags(operation.get("tags")),
|
||||||
|
parameters,
|
||||||
|
request_body_schema: legacy_request_body_schema(root, operation),
|
||||||
|
request_body_schema_location: None,
|
||||||
|
response_schema: legacy_response_schema(root, operation),
|
||||||
|
response_schema_location: None,
|
||||||
|
servers: legacy_servers(operation.get("servers")),
|
||||||
|
findings: legacy_operation_findings(operation),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RestImportDocument {
|
||||||
|
format: "openapi".to_owned(),
|
||||||
|
version,
|
||||||
|
title,
|
||||||
|
servers,
|
||||||
|
operations,
|
||||||
|
findings,
|
||||||
|
internal_finding_locations: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let mut operation_parameters = path_parameters.clone();
|
fn legacy_servers(value: Option<&Value>) -> Vec<String> {
|
||||||
operation_parameters.extend(parameters(root, operation_value.get("parameters")));
|
value
|
||||||
let operation_servers = operation_value
|
|
||||||
.get("servers")
|
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.map(|items| {
|
.map(|items| {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.get("url").and_then(Value::as_str))
|
.filter_map(|item| item.get("url").and_then(Value::as_str))
|
||||||
.map(|url| url.trim_end_matches('/').to_owned())
|
.map(|url| url.trim_end_matches('/').to_owned())
|
||||||
.collect::<Vec<_>>()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
operations.push(RestImportOperation {
|
fn legacy_tags(value: Option<&Value>) -> Vec<String> {
|
||||||
key: format!("{} {}", method_name.to_uppercase(), path),
|
value
|
||||||
method,
|
|
||||||
path: path.clone(),
|
|
||||||
operation_id: operation_value
|
|
||||||
.get("operationId")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
summary: operation_value
|
|
||||||
.get("summary")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
description: operation_value
|
|
||||||
.get("description")
|
|
||||||
.and_then(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned),
|
|
||||||
tags: operation_value
|
|
||||||
.get("tags")
|
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.map(|items| {
|
.map(|items| {
|
||||||
items
|
items
|
||||||
@@ -100,34 +314,22 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default()
|
||||||
parameters: operation_parameters,
|
|
||||||
request_body_schema: request_body_schema(root, operation_value),
|
|
||||||
response_schema: response_schema(root, operation_value),
|
|
||||||
servers: operation_servers,
|
|
||||||
findings: operation_findings(operation_value),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(RestImportDocument {
|
fn legacy_parameters(
|
||||||
format: "openapi".to_owned(),
|
root: &Value,
|
||||||
version,
|
value: Option<&Value>,
|
||||||
title,
|
base_pointer: &str,
|
||||||
servers,
|
) -> Vec<RestImportParameter> {
|
||||||
operations,
|
|
||||||
findings,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
|
||||||
value
|
value
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.map(|items| {
|
.map(|items| {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| {
|
.enumerate()
|
||||||
let item = resolve_local_ref(root, item, 0);
|
.filter_map(|(index, item)| {
|
||||||
|
let item = crate::rest::normalize::resolve_local_ref(root, item, 0);
|
||||||
let location = match item.get("in").and_then(Value::as_str)? {
|
let location = match item.get("in").and_then(Value::as_str)? {
|
||||||
"path" => RestParameterLocation::Path,
|
"path" => RestParameterLocation::Path,
|
||||||
"query" => RestParameterLocation::Query,
|
"query" => RestParameterLocation::Query,
|
||||||
@@ -146,9 +348,13 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
|||||||
.get("description")
|
.get("description")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
schema: item
|
schema: item.get("schema").map(|schema| {
|
||||||
.get("schema")
|
crate::rest::normalize::resolve_local_ref(root, schema, 0)
|
||||||
.map(|schema| resolve_local_ref(root, schema, 0)),
|
}),
|
||||||
|
source_location: crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{base_pointer}/{index}"),
|
||||||
|
},
|
||||||
|
schema_source_location: None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -156,31 +362,31 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_body_schema(root: &Value, operation: &Value) -> Option<Value> {
|
fn legacy_request_body_schema(root: &Value, operation: &Value) -> Option<Value> {
|
||||||
let body = resolve_local_ref(root, operation.get("requestBody")?, 0);
|
let body = crate::rest::normalize::resolve_local_ref(root, operation.get("requestBody")?, 0);
|
||||||
let content = body.get("content")?.as_object()?;
|
let content = body.get("content")?.as_object()?;
|
||||||
for content_type in ["application/json", "application/*+json"] {
|
for content_type in ["application/json", "application/*+json"] {
|
||||||
if let Some(schema) = content
|
if let Some(schema) = content
|
||||||
.get(content_type)
|
.get(content_type)
|
||||||
.and_then(|media| media.get("schema"))
|
.and_then(|media| media.get("schema"))
|
||||||
{
|
{
|
||||||
return Some(resolve_local_ref(root, schema, 0));
|
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content
|
content
|
||||||
.iter()
|
.iter()
|
||||||
.find(|(content_type, _)| content_type.contains("json"))
|
.find(|(content_type, _)| content_type.contains("json"))
|
||||||
.and_then(|(_, media)| media.get("schema"))
|
.and_then(|(_, media)| media.get("schema"))
|
||||||
.map(|schema| resolve_local_ref(root, schema, 0))
|
.map(|schema| crate::rest::normalize::resolve_local_ref(root, schema, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
fn legacy_response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
||||||
let responses = operation.get("responses")?.as_object()?;
|
let responses = operation.get("responses")?.as_object()?;
|
||||||
for code in ["200", "201", "202", "default"] {
|
for code in ["200", "201", "202", "default"] {
|
||||||
let Some(response) = responses.get(code) else {
|
let Some(response) = responses.get(code) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let response = resolve_local_ref(root, response, 0);
|
let response = crate::rest::normalize::resolve_local_ref(root, response, 0);
|
||||||
let Some(content) = response.get("content").and_then(Value::as_object) else {
|
let Some(content) = response.get("content").and_then(Value::as_object) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -189,7 +395,7 @@ fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
|||||||
.get(content_type)
|
.get(content_type)
|
||||||
.and_then(|media| media.get("schema"))
|
.and_then(|media| media.get("schema"))
|
||||||
{
|
{
|
||||||
return Some(resolve_local_ref(root, schema, 0));
|
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(schema) = content
|
if let Some(schema) = content
|
||||||
@@ -197,17 +403,350 @@ fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
|||||||
.find(|(content_type, _)| content_type.contains("json"))
|
.find(|(content_type, _)| content_type.contains("json"))
|
||||||
.and_then(|(_, media)| media.get("schema"))
|
.and_then(|(_, media)| media.get("schema"))
|
||||||
{
|
{
|
||||||
return Some(resolve_local_ref(root, schema, 0));
|
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn legacy_operation_findings(operation: &Value) -> Vec<ImportFinding> {
|
||||||
|
if operation.get("requestBody").is_some()
|
||||||
|
&& legacy_request_body_schema(&Value::Null, operation).is_none()
|
||||||
|
{
|
||||||
|
vec![document_finding(
|
||||||
|
"unsupported_request_body",
|
||||||
|
"У метода есть requestBody, но JSON schema не найдена.",
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParsedParameters {
|
||||||
|
parameters: Vec<RestImportParameter>,
|
||||||
|
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParsedServers {
|
||||||
|
servers: Vec<String>,
|
||||||
|
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_servers(value: Option<&Value>, base_pointer: &str) -> ParsedServers {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let servers = value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, item)| {
|
||||||
|
let pointer = format!("{base_pointer}/{index}");
|
||||||
|
let Some(object) = item.as_object() else {
|
||||||
|
findings.push(server_blocker(&pointer));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let Some(url) = object.get("url").and_then(Value::as_str) else {
|
||||||
|
findings.push(server_blocker(&pointer));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
Some(url.trim_end_matches('/').to_owned())
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
ParsedServers { servers, findings }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParsedTags {
|
||||||
|
tags: Vec<String>,
|
||||||
|
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags(value: Option<&Value>, base_pointer: &str) -> ParsedTags {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let tags = value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, item)| match item.as_str() {
|
||||||
|
Some(tag) => Some(tag.to_owned()),
|
||||||
|
None => {
|
||||||
|
findings.push((
|
||||||
|
document_finding(
|
||||||
|
"invalid_tag",
|
||||||
|
"Тег должен быть строкой и не был импортирован.",
|
||||||
|
),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{base_pointer}/{index}"),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
ParsedTags { tags, findings }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn server_blocker(pointer: &str) -> (ImportFinding, crate::rest::model::SourceLocation) {
|
||||||
|
(
|
||||||
|
document_blocker(
|
||||||
|
"invalid_server",
|
||||||
|
"Server должен быть объектом со строковым url и не был импортирован.",
|
||||||
|
),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: pointer.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameters(value: Option<&Value>, base_pointer: &str) -> ParsedParameters {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let parameters = value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, item)| {
|
||||||
|
let pointer = format!("{base_pointer}/{index}");
|
||||||
|
let Some(object) = item.as_object() else {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"invalid_parameter",
|
||||||
|
"Параметр должен быть объектом и не был интерпретирован.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if object.contains_key("$ref") {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
"Параметр по $ref требует разрешения перед импортом.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !object.get("name").is_some_and(Value::is_string) {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"missing_parameter_name",
|
||||||
|
"У параметра отсутствует строковое поле name.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !object.get("in").is_some_and(Value::is_string) {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"missing_parameter_location",
|
||||||
|
"У параметра отсутствует строковое поле in.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let item = item.clone();
|
||||||
|
let location = match item.get("in").and_then(Value::as_str)? {
|
||||||
|
"path" => RestParameterLocation::Path,
|
||||||
|
"query" => RestParameterLocation::Query,
|
||||||
|
"header" => RestParameterLocation::Header,
|
||||||
|
"cookie" => {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"Cookie parameter пока не поддерживается и не был импортирован.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"Параметр использует неподдерживаемое значение in и не был импортирован.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(RestImportParameter {
|
||||||
|
name: item.get("name").and_then(Value::as_str)?.to_owned(),
|
||||||
|
location,
|
||||||
|
required: item
|
||||||
|
.get("required")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| location == RestParameterLocation::Path,
|
||||||
|
description: item
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
schema: item.get("schema").cloned(),
|
||||||
|
schema_source_location: item.get("schema").map(|_| {
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{base_pointer}/{index}/schema"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
source_location: crate::rest::model::SourceLocation {
|
||||||
|
pointer,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
ParsedParameters {
|
||||||
|
parameters,
|
||||||
|
findings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameter_blocker(
|
||||||
|
code: &str,
|
||||||
|
message: &str,
|
||||||
|
pointer: &str,
|
||||||
|
) -> (ImportFinding, crate::rest::model::SourceLocation) {
|
||||||
|
(
|
||||||
|
document_blocker(code, message),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: pointer.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_findings_with_locations(
|
||||||
|
findings: &mut Vec<ImportFinding>,
|
||||||
|
locations: &mut Vec<Option<crate::rest::model::SourceLocation>>,
|
||||||
|
parameter_findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
|
||||||
|
) {
|
||||||
|
for (finding, location) in parameter_findings {
|
||||||
|
findings.push(finding);
|
||||||
|
locations.push(Some(location));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_body_schema(
|
||||||
|
operation: &Value,
|
||||||
|
operation_pointer: &str,
|
||||||
|
) -> Option<(Value, crate::rest::model::SourceLocation)> {
|
||||||
|
let body = operation.get("requestBody")?;
|
||||||
|
let content = body.get("content")?.as_object()?;
|
||||||
|
for content_type in ["application/json", "application/*+json"] {
|
||||||
|
if let Some(schema) = content
|
||||||
|
.get(content_type)
|
||||||
|
.and_then(|media| media.get("schema"))
|
||||||
|
{
|
||||||
|
return Some((
|
||||||
|
schema.clone(),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"{operation_pointer}/requestBody/content/{}/schema",
|
||||||
|
content_type.replace('~', "~0").replace('/', "~1")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content
|
||||||
|
.iter()
|
||||||
|
.find(|(content_type, _)| content_type.contains("json"))
|
||||||
|
.and_then(|(content_type, media)| {
|
||||||
|
media.get("schema").cloned().map(|schema| {
|
||||||
|
(
|
||||||
|
schema,
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"{operation_pointer}/requestBody/content/{}/schema",
|
||||||
|
content_type.replace('~', "~0").replace('/', "~1")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_schema(
|
||||||
|
operation: &Value,
|
||||||
|
operation_pointer: &str,
|
||||||
|
) -> Option<(Value, crate::rest::model::SourceLocation)> {
|
||||||
|
let responses = operation.get("responses")?.as_object()?;
|
||||||
|
let mut numeric = responses
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(code, response)| {
|
||||||
|
(code.len() == 3)
|
||||||
|
.then(|| code.parse::<u16>().ok())
|
||||||
|
.flatten()
|
||||||
|
.filter(|status| (200..300).contains(status))
|
||||||
|
.map(|status| (status, code.as_str(), response))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
numeric.sort_by(|left, right| (left.0, left.1).cmp(&(right.0, right.1)));
|
||||||
|
for (_, code, response) in numeric {
|
||||||
|
if let Some(schema) = response_json_schema(response, operation_pointer, code) {
|
||||||
|
return Some(schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wildcard = responses
|
||||||
|
.iter()
|
||||||
|
.filter(|(code, _)| code.eq_ignore_ascii_case("2xx"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
wildcard.sort_by(|left, right| left.0.cmp(right.0));
|
||||||
|
for (code, response) in wildcard {
|
||||||
|
if let Some(schema) = response_json_schema(response, operation_pointer, code) {
|
||||||
|
return Some(schema);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
responses
|
||||||
|
.get("default")
|
||||||
|
.and_then(|response| response_json_schema(response, operation_pointer, "default"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_json_schema(
|
||||||
|
response: &Value,
|
||||||
|
operation_pointer: &str,
|
||||||
|
code: &str,
|
||||||
|
) -> Option<(Value, crate::rest::model::SourceLocation)> {
|
||||||
|
let content = response.get("content")?.as_object()?;
|
||||||
|
for content_type in ["application/json", "application/*+json"] {
|
||||||
|
if let Some(schema) = content
|
||||||
|
.get(content_type)
|
||||||
|
.and_then(|media| media.get("schema"))
|
||||||
|
{
|
||||||
|
return Some((
|
||||||
|
schema.clone(),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"{operation_pointer}/responses/{}/content/{}/schema",
|
||||||
|
code.replace('~', "~0").replace('/', "~1"),
|
||||||
|
content_type.replace('~', "~0").replace('/', "~1")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content
|
||||||
|
.iter()
|
||||||
|
.find(|(content_type, _)| content_type.contains("json"))
|
||||||
|
.and_then(|(content_type, media)| {
|
||||||
|
media.get("schema").map(|schema| {
|
||||||
|
(
|
||||||
|
schema.clone(),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"{operation_pointer}/responses/{}/content/{}/schema",
|
||||||
|
code.replace('~', "~0").replace('/', "~1"),
|
||||||
|
content_type.replace('~', "~0").replace('/', "~1")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn operation_findings(operation: &Value) -> Vec<ImportFinding> {
|
fn operation_findings(operation: &Value) -> Vec<ImportFinding> {
|
||||||
let mut findings = Vec::new();
|
let mut findings = Vec::new();
|
||||||
if operation.get("requestBody").is_some()
|
if operation.get("requestBody").is_some() && request_body_schema(operation, "").is_none() {
|
||||||
&& request_body_schema(&Value::Null, operation).is_none()
|
|
||||||
{
|
|
||||||
findings.push(document_finding(
|
findings.push(document_finding(
|
||||||
"unsupported_request_body",
|
"unsupported_request_body",
|
||||||
"У метода есть requestBody, но JSON schema не найдена.",
|
"У метода есть requestBody, но JSON schema не найдена.",
|
||||||
@@ -226,3 +765,12 @@ fn method_from_lower(value: &str) -> Option<HttpMethod> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn path_item_blocker(_path: &str) -> ImportFinding {
|
||||||
|
ImportFinding {
|
||||||
|
code: "invalid_path_item".to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Error,
|
||||||
|
message: "Path Item имеет неверную структуру и не был интерпретирован.".to_owned(),
|
||||||
|
operation_key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,15 @@ pub fn document_finding(code: &str, message: impl Into<String>) -> ImportFinding
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn document_blocker(code: &str, message: impl Into<String>) -> ImportFinding {
|
||||||
|
ImportFinding {
|
||||||
|
code: code.to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Error,
|
||||||
|
message: message.into(),
|
||||||
|
operation_key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn operation_finding(
|
pub fn operation_finding(
|
||||||
operation_key: &str,
|
operation_key: &str,
|
||||||
code: &str,
|
code: &str,
|
||||||
@@ -45,6 +54,9 @@ pub fn operation_recommendations(operation: &RestImportOperation) -> Vec<ImportF
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.trim()
|
.trim()
|
||||||
.is_empty()
|
.is_empty()
|
||||||
|
&& !findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.code == "missing_operation_id")
|
||||||
{
|
{
|
||||||
findings.push(operation_finding(
|
findings.push(operation_finding(
|
||||||
&operation.key,
|
&operation.key,
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ pub fn schema_from_openapi(
|
|||||||
let Some(value) = value else {
|
let Some(value) = value else {
|
||||||
return primitive(SchemaKind::String, required, description);
|
return primitive(SchemaKind::String, required, description);
|
||||||
};
|
};
|
||||||
|
// NormalizedIR keeps composition typed; the legacy preview adapter retains
|
||||||
|
// its historical first-branch projection for pending v1 jobs.
|
||||||
let resolved = collapse_composition(value);
|
let resolved = collapse_composition(value);
|
||||||
|
|
||||||
if let Some(values) = resolved.get("enum").and_then(Value::as_array) {
|
if let Some(values) = resolved.get("enum").and_then(Value::as_array) {
|
||||||
@@ -100,6 +102,17 @@ pub fn schema_from_openapi(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn collapse_composition(value: &Value) -> Value {
|
||||||
|
for key in ["allOf", "oneOf", "anyOf"] {
|
||||||
|
if let Some(items) = value.get(key).and_then(Value::as_array)
|
||||||
|
&& let Some(first) = items.first()
|
||||||
|
{
|
||||||
|
return first.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn object_with_fields(description: Option<String>, fields: BTreeMap<String, Schema>) -> Schema {
|
pub fn object_with_fields(description: Option<String>, fields: BTreeMap<String, Schema>) -> Schema {
|
||||||
Schema {
|
Schema {
|
||||||
kind: SchemaKind::Object,
|
kind: SchemaKind::Object,
|
||||||
@@ -190,14 +203,3 @@ fn text(value: &Value, key: &str) -> Option<String> {
|
|||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn collapse_composition(value: &Value) -> Value {
|
|
||||||
for key in ["allOf", "oneOf", "anyOf"] {
|
|
||||||
if let Some(items) = value.get(key).and_then(Value::as_array)
|
|
||||||
&& let Some(first) = items.first()
|
|
||||||
{
|
|
||||||
return first.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
value.clone()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ use crank_core::HttpMethod;
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::rest::{
|
use crate::rest::{
|
||||||
model::{RestImportDocument, RestImportOperation, RestImportParameter, RestParameterLocation},
|
model::{
|
||||||
normalize::{ImportParseError, resolve_local_ref},
|
ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation,
|
||||||
recommendations::{document_finding, operation_finding},
|
RestImportParameter, RestParameterLocation,
|
||||||
|
},
|
||||||
|
normalize::ImportParseError,
|
||||||
|
recommendations::{document_blocker, document_finding, operation_finding},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||||
|
parse_document_v2(root)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_document_v2(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||||
let title = root
|
let title = root
|
||||||
.pointer("/info/title")
|
.pointer("/info/title")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
@@ -15,11 +22,15 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
let servers = swagger_servers(root);
|
let servers = swagger_servers(root);
|
||||||
let mut findings = Vec::new();
|
let mut findings = Vec::new();
|
||||||
|
let mut internal_finding_locations = Vec::new();
|
||||||
if servers.is_empty() {
|
if servers.is_empty() {
|
||||||
findings.push(document_finding(
|
findings.push(document_finding(
|
||||||
"missing_servers",
|
"missing_servers",
|
||||||
"В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.",
|
"В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.",
|
||||||
));
|
));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: String::new(),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut operations = Vec::new();
|
let mut operations = Vec::new();
|
||||||
@@ -29,20 +40,95 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
|
|||||||
.ok_or(ImportParseError::UnsupportedDocument)?;
|
.ok_or(ImportParseError::UnsupportedDocument)?;
|
||||||
|
|
||||||
for (path, path_item) in paths {
|
for (path, path_item) in paths {
|
||||||
let path_parameters = parameters(root, path_item.get("parameters"));
|
if !path_item.is_object() {
|
||||||
|
findings.push(path_item_blocker(path));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("/paths/{}", path.replace('~', "~0").replace('/', "~1")),
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for method_name in ["head", "options", "trace", "connect"] {
|
||||||
|
if path_item.get(method_name).is_some() {
|
||||||
|
findings.push(document_blocker(
|
||||||
|
"unsupported_http_method",
|
||||||
|
format!("Метод {method_name} для пути {path} пока не поддерживается."),
|
||||||
|
));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"/paths/{}/{}",
|
||||||
|
path.replace('~', "~0").replace('/', "~1"),
|
||||||
|
method_name
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
|
||||||
|
let path_parameters = parameters(
|
||||||
|
root,
|
||||||
|
path_item.get("parameters"),
|
||||||
|
&format!("{path_pointer}/parameters"),
|
||||||
|
);
|
||||||
|
append_parameter_findings(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
path_parameters.findings,
|
||||||
|
);
|
||||||
|
let path_parameters = path_parameters.parameters;
|
||||||
for method_name in ["get", "post", "put", "patch", "delete"] {
|
for method_name in ["get", "post", "put", "patch", "delete"] {
|
||||||
let Some(operation_value) = path_item.get(method_name) else {
|
let Some(operation_value) = path_item.get(method_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
if !operation_value.is_object() {
|
||||||
|
findings.push(document_blocker(
|
||||||
|
"invalid_operation",
|
||||||
|
"Операция имеет неверную структуру и не была интерпретирована.",
|
||||||
|
));
|
||||||
|
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"/paths/{}/{}",
|
||||||
|
path.replace('~', "~0").replace('/', "~1"),
|
||||||
|
method_name
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let Some(method) = method_from_lower(method_name) else {
|
let Some(method) = method_from_lower(method_name) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut operation_parameters = path_parameters.clone();
|
let mut operation_parameters = path_parameters.clone();
|
||||||
operation_parameters.extend(parameters(root, operation_value.get("parameters")));
|
let parsed_parameters = parameters(
|
||||||
|
root,
|
||||||
|
operation_value.get("parameters"),
|
||||||
|
&format!("{path_pointer}/{method_name}/parameters"),
|
||||||
|
);
|
||||||
|
append_parameter_findings(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
parsed_parameters.findings,
|
||||||
|
);
|
||||||
|
operation_parameters.extend(parsed_parameters.parameters);
|
||||||
|
let tags = tags(
|
||||||
|
operation_value.get("tags"),
|
||||||
|
&format!("{path_pointer}/{method_name}/tags"),
|
||||||
|
);
|
||||||
|
append_parameter_findings(
|
||||||
|
&mut findings,
|
||||||
|
&mut internal_finding_locations,
|
||||||
|
tags.findings,
|
||||||
|
);
|
||||||
let request_body_schema = operation_parameters
|
let request_body_schema = operation_parameters
|
||||||
.iter()
|
.iter()
|
||||||
.find(|parameter| parameter.name == "body")
|
.find(|parameter| parameter.name == "body")
|
||||||
.and_then(|parameter| parameter.schema.clone());
|
.and_then(|parameter| {
|
||||||
|
parameter.schema.clone().map(|schema| {
|
||||||
|
(
|
||||||
|
schema,
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{}/schema", parameter.source_location.pointer),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
let operation_parameters = operation_parameters
|
let operation_parameters = operation_parameters
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|parameter| parameter.name != "body")
|
.filter(|parameter| parameter.name != "body")
|
||||||
@@ -64,20 +150,22 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
|
|||||||
.get("description")
|
.get("description")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
tags: operation_value
|
tags: tags.tags,
|
||||||
.get("tags")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.map(|items| {
|
|
||||||
items
|
|
||||||
.iter()
|
|
||||||
.filter_map(Value::as_str)
|
|
||||||
.map(ToOwned::to_owned)
|
|
||||||
.collect()
|
|
||||||
})
|
|
||||||
.unwrap_or_default(),
|
|
||||||
parameters: operation_parameters,
|
parameters: operation_parameters,
|
||||||
request_body_schema,
|
request_body_schema: request_body_schema
|
||||||
response_schema: response_schema(root, operation_value),
|
.as_ref()
|
||||||
|
.map(|(schema, _)| schema.clone()),
|
||||||
|
request_body_schema_location: request_body_schema.map(|(_, location)| location),
|
||||||
|
response_schema: response_schema(
|
||||||
|
operation_value,
|
||||||
|
&format!("{path_pointer}/{method_name}"),
|
||||||
|
)
|
||||||
|
.map(|(schema, _)| schema),
|
||||||
|
response_schema_location: response_schema(
|
||||||
|
operation_value,
|
||||||
|
&format!("{path_pointer}/{method_name}"),
|
||||||
|
)
|
||||||
|
.map(|(_, location)| location),
|
||||||
servers: Vec::new(),
|
servers: Vec::new(),
|
||||||
findings: swagger_operation_findings(path, operation_value),
|
findings: swagger_operation_findings(path, operation_value),
|
||||||
});
|
});
|
||||||
@@ -91,9 +179,201 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
|
|||||||
servers,
|
servers,
|
||||||
operations,
|
operations,
|
||||||
findings,
|
findings,
|
||||||
|
internal_finding_locations,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn parse_document_legacy_v1(root: &Value) -> Result<RestImportDocument, ImportParseError> {
|
||||||
|
let title = root
|
||||||
|
.pointer("/info/title")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Imported API")
|
||||||
|
.to_owned();
|
||||||
|
let servers = swagger_servers(root);
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
if servers.is_empty() {
|
||||||
|
findings.push(document_finding(
|
||||||
|
"missing_servers",
|
||||||
|
"В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut operations = Vec::new();
|
||||||
|
let paths = root
|
||||||
|
.get("paths")
|
||||||
|
.and_then(Value::as_object)
|
||||||
|
.ok_or(ImportParseError::UnsupportedDocument)?;
|
||||||
|
for (path, path_item) in paths {
|
||||||
|
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
|
||||||
|
let path_parameters = legacy_parameters(
|
||||||
|
root,
|
||||||
|
path_item.get("parameters"),
|
||||||
|
&format!("{path_pointer}/parameters"),
|
||||||
|
);
|
||||||
|
for method_name in ["get", "post", "put", "patch", "delete"] {
|
||||||
|
let Some(operation) = path_item.get(method_name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(method) = method_from_lower(method_name) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let operation_pointer = format!("{path_pointer}/{method_name}");
|
||||||
|
let mut parameters = path_parameters.clone();
|
||||||
|
parameters.extend(legacy_parameters(
|
||||||
|
root,
|
||||||
|
operation.get("parameters"),
|
||||||
|
&format!("{operation_pointer}/parameters"),
|
||||||
|
));
|
||||||
|
let request_body_schema = parameters
|
||||||
|
.iter()
|
||||||
|
.find(|parameter| parameter.name == "body")
|
||||||
|
.and_then(|parameter| parameter.schema.clone());
|
||||||
|
let parameters = parameters
|
||||||
|
.into_iter()
|
||||||
|
.filter(|parameter| parameter.name != "body")
|
||||||
|
.collect();
|
||||||
|
operations.push(RestImportOperation {
|
||||||
|
key: format!("{} {}", method_name.to_uppercase(), path),
|
||||||
|
method,
|
||||||
|
path: path.clone(),
|
||||||
|
operation_id: operation
|
||||||
|
.get("operationId")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
summary: operation
|
||||||
|
.get("summary")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
description: operation
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
tags: legacy_tags(operation.get("tags")),
|
||||||
|
parameters,
|
||||||
|
request_body_schema,
|
||||||
|
request_body_schema_location: None,
|
||||||
|
response_schema: legacy_response_schema(root, operation),
|
||||||
|
response_schema_location: None,
|
||||||
|
servers: Vec::new(),
|
||||||
|
findings: swagger_operation_findings(path, operation),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(RestImportDocument {
|
||||||
|
format: "swagger".to_owned(),
|
||||||
|
version: Some("2.0".to_owned()),
|
||||||
|
title,
|
||||||
|
servers,
|
||||||
|
operations,
|
||||||
|
findings,
|
||||||
|
internal_finding_locations: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_tags(value: Option<&Value>) -> Vec<String> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_parameters(
|
||||||
|
root: &Value,
|
||||||
|
value: Option<&Value>,
|
||||||
|
base_pointer: &str,
|
||||||
|
) -> Vec<RestImportParameter> {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, item)| {
|
||||||
|
let item = crate::rest::normalize::resolve_local_ref(root, item, 0);
|
||||||
|
let raw_location = item.get("in").and_then(Value::as_str)?;
|
||||||
|
let pointer = format!("{base_pointer}/{index}");
|
||||||
|
if raw_location == "body" {
|
||||||
|
return Some(RestImportParameter {
|
||||||
|
name: "body".to_owned(),
|
||||||
|
location: RestParameterLocation::Query,
|
||||||
|
required: item
|
||||||
|
.get("required")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false),
|
||||||
|
description: item
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
schema: item.get("schema").map(|schema| {
|
||||||
|
crate::rest::normalize::resolve_local_ref(root, schema, 0)
|
||||||
|
}),
|
||||||
|
source_location: crate::rest::model::SourceLocation { pointer },
|
||||||
|
schema_source_location: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let location = match raw_location {
|
||||||
|
"path" => RestParameterLocation::Path,
|
||||||
|
"query" => RestParameterLocation::Query,
|
||||||
|
"header" => RestParameterLocation::Header,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(RestImportParameter {
|
||||||
|
name: item.get("name").and_then(Value::as_str)?.to_owned(),
|
||||||
|
location,
|
||||||
|
required: item
|
||||||
|
.get("required")
|
||||||
|
.and_then(Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
|| location == RestParameterLocation::Path,
|
||||||
|
description: item
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(ToOwned::to_owned),
|
||||||
|
schema: legacy_parameter_schema(root, &item),
|
||||||
|
source_location: crate::rest::model::SourceLocation { pointer },
|
||||||
|
schema_source_location: None,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_parameter_schema(root: &Value, parameter: &Value) -> Option<Value> {
|
||||||
|
if let Some(schema) = parameter.get("schema") {
|
||||||
|
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
|
||||||
|
}
|
||||||
|
let mut schema = serde_json::Map::new();
|
||||||
|
for key in ["type", "format", "items", "enum", "default", "description"] {
|
||||||
|
if let Some(value) = parameter.get(key) {
|
||||||
|
schema.insert(
|
||||||
|
key.to_owned(),
|
||||||
|
crate::rest::normalize::resolve_local_ref(root, value, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(!schema.is_empty()).then_some(Value::Object(schema))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn legacy_response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
||||||
|
let responses = operation.get("responses")?.as_object()?;
|
||||||
|
for code in ["200", "201", "202", "default"] {
|
||||||
|
let Some(response) = responses.get(code) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let response = crate::rest::normalize::resolve_local_ref(root, response, 0);
|
||||||
|
if let Some(schema) = response.get("schema") {
|
||||||
|
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
fn swagger_servers(root: &Value) -> Vec<String> {
|
fn swagger_servers(root: &Value) -> Vec<String> {
|
||||||
let Some(host) = root.get("host").and_then(Value::as_str) else {
|
let Some(host) = root.get("host").and_then(Value::as_str) else {
|
||||||
return Vec::new();
|
return Vec::new();
|
||||||
@@ -116,14 +396,51 @@ fn swagger_servers(root: &Value) -> Vec<String> {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
fn parameters(_root: &Value, value: Option<&Value>, base_pointer: &str) -> ParsedParameters {
|
||||||
value
|
let mut findings = Vec::new();
|
||||||
|
let parameters = value
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.map(|items| {
|
.map(|items| {
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| {
|
.enumerate()
|
||||||
let item = resolve_local_ref(root, item, 0);
|
.filter_map(|(index, item)| {
|
||||||
|
let pointer = format!("{base_pointer}/{index}");
|
||||||
|
{
|
||||||
|
let Some(object) = item.as_object() else {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"invalid_parameter",
|
||||||
|
"Параметр должен быть объектом и не был интерпретирован.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
if object.contains_key("$ref") {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
"Параметр по $ref требует разрешения перед импортом.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !object.get("name").is_some_and(Value::is_string) {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"missing_parameter_name",
|
||||||
|
"У параметра отсутствует строковое поле name.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !object.get("in").is_some_and(Value::is_string) {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"missing_parameter_location",
|
||||||
|
"У параметра отсутствует строковое поле in.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let item = item.clone();
|
||||||
let raw_location = item.get("in").and_then(Value::as_str)?;
|
let raw_location = item.get("in").and_then(Value::as_str)?;
|
||||||
if raw_location == "body" {
|
if raw_location == "body" {
|
||||||
return Some(RestImportParameter {
|
return Some(RestImportParameter {
|
||||||
@@ -137,16 +454,37 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
|||||||
.get("description")
|
.get("description")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
schema: item
|
schema: item.get("schema").cloned(),
|
||||||
.get("schema")
|
schema_source_location: item.get("schema").map(|_| {
|
||||||
.map(|schema| resolve_local_ref(root, schema, 0)),
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{base_pointer}/{index}/schema"),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
source_location: crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{base_pointer}/{index}"),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let location = match raw_location {
|
let location = match raw_location {
|
||||||
"path" => RestParameterLocation::Path,
|
"path" => RestParameterLocation::Path,
|
||||||
"query" => RestParameterLocation::Query,
|
"query" => RestParameterLocation::Query,
|
||||||
"header" => RestParameterLocation::Header,
|
"header" => RestParameterLocation::Header,
|
||||||
_ => return None,
|
"cookie" => {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"Cookie parameter пока не поддерживается и не был импортирован.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
findings.push(parameter_blocker(
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"Параметр использует неподдерживаемое значение in и не был импортирован.",
|
||||||
|
&pointer,
|
||||||
|
));
|
||||||
|
return None;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Some(RestImportParameter {
|
Some(RestImportParameter {
|
||||||
name: item.get("name").and_then(Value::as_str)?.to_owned(),
|
name: item.get("name").and_then(Value::as_str)?.to_owned(),
|
||||||
@@ -160,22 +498,95 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
|
|||||||
.get("description")
|
.get("description")
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.map(ToOwned::to_owned),
|
.map(ToOwned::to_owned),
|
||||||
schema: swagger_parameter_schema(root, &item),
|
schema: swagger_parameter_schema(_root, &item),
|
||||||
|
schema_source_location: Some(crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{base_pointer}/{index}"),
|
||||||
|
}),
|
||||||
|
source_location: crate::rest::model::SourceLocation {
|
||||||
|
pointer,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
ParsedParameters {
|
||||||
|
parameters,
|
||||||
|
findings,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option<Value> {
|
struct ParsedParameters {
|
||||||
|
parameters: Vec<RestImportParameter>,
|
||||||
|
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ParsedTags {
|
||||||
|
tags: Vec<String>,
|
||||||
|
findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags(value: Option<&Value>, base_pointer: &str) -> ParsedTags {
|
||||||
|
let mut findings = Vec::new();
|
||||||
|
let tags = value
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(index, item)| match item.as_str() {
|
||||||
|
Some(tag) => Some(tag.to_owned()),
|
||||||
|
None => {
|
||||||
|
findings.push((
|
||||||
|
document_finding(
|
||||||
|
"invalid_tag",
|
||||||
|
"Тег должен быть строкой и не был импортирован.",
|
||||||
|
),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!("{base_pointer}/{index}"),
|
||||||
|
},
|
||||||
|
));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
ParsedTags { tags, findings }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parameter_blocker(
|
||||||
|
code: &str,
|
||||||
|
message: &str,
|
||||||
|
pointer: &str,
|
||||||
|
) -> (ImportFinding, crate::rest::model::SourceLocation) {
|
||||||
|
(
|
||||||
|
document_blocker(code, message),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: pointer.to_owned(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_parameter_findings(
|
||||||
|
findings: &mut Vec<ImportFinding>,
|
||||||
|
locations: &mut Vec<Option<crate::rest::model::SourceLocation>>,
|
||||||
|
parameter_findings: Vec<(ImportFinding, crate::rest::model::SourceLocation)>,
|
||||||
|
) {
|
||||||
|
for (finding, location) in parameter_findings {
|
||||||
|
findings.push(finding);
|
||||||
|
locations.push(Some(location));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn swagger_parameter_schema(_root: &Value, parameter: &Value) -> Option<Value> {
|
||||||
if let Some(schema) = parameter.get("schema") {
|
if let Some(schema) = parameter.get("schema") {
|
||||||
return Some(resolve_local_ref(root, schema, 0));
|
return Some(schema.clone());
|
||||||
}
|
}
|
||||||
let mut schema = serde_json::Map::new();
|
let mut schema = serde_json::Map::new();
|
||||||
for key in ["type", "format", "items", "enum", "default", "description"] {
|
for key in ["type", "format", "items", "enum", "default", "description"] {
|
||||||
if let Some(value) = parameter.get(key) {
|
if let Some(value) = parameter.get(key) {
|
||||||
schema.insert(key.to_owned(), resolve_local_ref(root, value, 0));
|
schema.insert(key.to_owned(), value.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if schema.is_empty() {
|
if schema.is_empty() {
|
||||||
@@ -185,18 +596,48 @@ fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option<Value> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
|
fn response_schema(
|
||||||
|
operation: &Value,
|
||||||
|
operation_pointer: &str,
|
||||||
|
) -> Option<(Value, crate::rest::model::SourceLocation)> {
|
||||||
let responses = operation.get("responses")?.as_object()?;
|
let responses = operation.get("responses")?.as_object()?;
|
||||||
for code in ["200", "201", "202", "default"] {
|
let mut numeric = responses
|
||||||
let Some(response) = responses.get(code) else {
|
.iter()
|
||||||
continue;
|
.filter_map(|(code, response)| {
|
||||||
};
|
(code.len() == 3)
|
||||||
let response = resolve_local_ref(root, response, 0);
|
.then(|| code.parse::<u16>().ok())
|
||||||
if let Some(schema) = response.get("schema") {
|
.flatten()
|
||||||
return Some(resolve_local_ref(root, schema, 0));
|
.filter(|status| (200..300).contains(status))
|
||||||
|
.map(|status| (status, code.as_str(), response))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
numeric.sort_by(|left, right| (left.0, left.1).cmp(&(right.0, right.1)));
|
||||||
|
for (_, code, response) in numeric {
|
||||||
|
if let Some(schema) = response_schema_at(response, operation_pointer, code) {
|
||||||
|
return Some(schema);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
responses
|
||||||
|
.get("default")
|
||||||
|
.and_then(|response| response_schema_at(response, operation_pointer, "default"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_schema_at(
|
||||||
|
response: &Value,
|
||||||
|
operation_pointer: &str,
|
||||||
|
code: &str,
|
||||||
|
) -> Option<(Value, crate::rest::model::SourceLocation)> {
|
||||||
|
response.get("schema").map(|schema| {
|
||||||
|
(
|
||||||
|
schema.clone(),
|
||||||
|
crate::rest::model::SourceLocation {
|
||||||
|
pointer: format!(
|
||||||
|
"{operation_pointer}/responses/{}/schema",
|
||||||
|
code.replace('~', "~0").replace('/', "~1")
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn swagger_operation_findings(
|
fn swagger_operation_findings(
|
||||||
@@ -232,3 +673,12 @@ fn method_from_lower(value: &str) -> Option<HttpMethod> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn path_item_blocker(_path: &str) -> ImportFinding {
|
||||||
|
ImportFinding {
|
||||||
|
code: "invalid_path_item".to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Error,
|
||||||
|
message: "Path Item имеет неверную структуру и не был интерпретирован.".to_owned(),
|
||||||
|
operation_key: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
use crank_import::rest::model::{CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition};
|
||||||
|
use crank_import::rest::{
|
||||||
|
ImportFindingSeverity, ImportParseError, NormalizationConfig, NormalizedFinding, NormalizedIr,
|
||||||
|
SourceDigest, SourceLocation, normalize_verified_document, validate_normalized_ir,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn normalize(document: &str) -> NormalizedIr {
|
||||||
|
normalize_verified_document(
|
||||||
|
document,
|
||||||
|
SourceDigest::parse("c".repeat(64)).unwrap(),
|
||||||
|
&NormalizationConfig::default(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_source() -> &'static str {
|
||||||
|
r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Coverage, version: v1 }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
operationId: listItems
|
||||||
|
tags: [items]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: ok
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { type: object, properties: { id: { type: string } } }
|
||||||
|
"#
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_invalid(ir: &NormalizedIr) {
|
||||||
|
assert_eq!(
|
||||||
|
validate_normalized_ir(ir),
|
||||||
|
Err(ImportParseError::InvalidDocument)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_digest_deserialization_preserves_the_validated_invariant() {
|
||||||
|
assert!(serde_json::from_str::<SourceDigest>(&format!("\"{}\"", "a".repeat(64))).is_ok());
|
||||||
|
assert!(serde_json::from_str::<SourceDigest>(&format!("\"{}\"", "A".repeat(64))).is_err());
|
||||||
|
assert!(serde_json::from_str::<SourceDigest>("\"not-a-digest\"").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_tampered_coverage_location_disposition_and_orphan_finding() {
|
||||||
|
let ir = normalize(valid_source());
|
||||||
|
let operation_id = ir.operations[0].stable_id.clone();
|
||||||
|
|
||||||
|
let mut wrong_location = ir.clone();
|
||||||
|
wrong_location
|
||||||
|
.coverage
|
||||||
|
.iter_mut()
|
||||||
|
.find(|entry| entry.construct_id == operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.location
|
||||||
|
.pointer = "/paths/~1other/get".to_owned();
|
||||||
|
assert_invalid(&wrong_location);
|
||||||
|
|
||||||
|
let mut wrong_disposition = ir.clone();
|
||||||
|
wrong_disposition
|
||||||
|
.coverage
|
||||||
|
.iter_mut()
|
||||||
|
.find(|entry| entry.construct_id == operation_id)
|
||||||
|
.unwrap()
|
||||||
|
.disposition = CoverageDisposition::Finding;
|
||||||
|
assert_invalid(&wrong_disposition);
|
||||||
|
|
||||||
|
let mut duplicate = ir.clone();
|
||||||
|
duplicate.coverage.push(duplicate.coverage[0].clone());
|
||||||
|
assert_invalid(&duplicate);
|
||||||
|
|
||||||
|
let mut orphan = ir;
|
||||||
|
orphan.findings.push(NormalizedFinding {
|
||||||
|
code: "forged".to_owned(),
|
||||||
|
severity: ImportFindingSeverity::Warning,
|
||||||
|
message: "forged".to_owned(),
|
||||||
|
construct_id: "orphan".to_owned(),
|
||||||
|
location: SourceLocation {
|
||||||
|
pointer: String::new(),
|
||||||
|
},
|
||||||
|
operation_key: None,
|
||||||
|
});
|
||||||
|
assert_invalid(&orphan);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_tampered_contract_operation_identity_and_source_tree() {
|
||||||
|
let ir = normalize(valid_source());
|
||||||
|
|
||||||
|
let mut normalizer = ir.clone();
|
||||||
|
normalizer.normalizer_version = "future-normalizer".to_owned();
|
||||||
|
assert_invalid(&normalizer);
|
||||||
|
|
||||||
|
let mut projection = ir.clone();
|
||||||
|
projection.projection_version = "future-projection".to_owned();
|
||||||
|
assert_invalid(&projection);
|
||||||
|
|
||||||
|
let mut source_version = ir.clone();
|
||||||
|
source_version.source.version = Some("3.0.3".to_owned());
|
||||||
|
assert_invalid(&source_version);
|
||||||
|
|
||||||
|
let mut stable_id = ir.clone();
|
||||||
|
stable_id.operations[0].stable_id.push_str(":forged");
|
||||||
|
assert_invalid(&stable_id);
|
||||||
|
|
||||||
|
let mut operation_location = ir.clone();
|
||||||
|
operation_location.operations[0].location.pointer = "/paths/~1items/post".to_owned();
|
||||||
|
assert_invalid(&operation_location);
|
||||||
|
|
||||||
|
let mut operation_key = ir.clone();
|
||||||
|
operation_key.operations[0].key = "POST /items".to_owned();
|
||||||
|
assert_invalid(&operation_key);
|
||||||
|
|
||||||
|
let mut schema_description = ir.clone();
|
||||||
|
schema_description.operations[0]
|
||||||
|
.response_schema
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.description = Some("forged description".to_owned());
|
||||||
|
assert_invalid(&schema_description);
|
||||||
|
|
||||||
|
let mut source_tree = ir;
|
||||||
|
source_tree.source_tree.location.pointer = "/forged".to_owned();
|
||||||
|
assert_invalid(&source_tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validates_every_recursive_source_tree_pointer_and_construct_id() {
|
||||||
|
let mut ir = normalize(valid_source());
|
||||||
|
let CanonicalSourceValue::Object(root) = &mut ir.source_tree.value else {
|
||||||
|
panic!("source root must be an object");
|
||||||
|
};
|
||||||
|
let CanonicalSourceValue::Object(info) = &mut root.get_mut("info").unwrap().value else {
|
||||||
|
panic!("info must be an object");
|
||||||
|
};
|
||||||
|
let title = info.get_mut("title").unwrap();
|
||||||
|
title.construct_id = "source:/info/wrong".to_owned();
|
||||||
|
assert_invalid(&ir);
|
||||||
|
|
||||||
|
let mut ir = normalize(valid_source());
|
||||||
|
let title = source_node_mut(&mut ir.source_tree, "/info/title").unwrap();
|
||||||
|
title.location.pointer = "/info/wrong".to_owned();
|
||||||
|
assert_invalid(&ir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_virtual_missing_operation_id_with_exact_finding_coverage() {
|
||||||
|
let ir = normalize(
|
||||||
|
"openapi: 3.1.0\ninfo: { title: Missing ID }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
|
||||||
|
);
|
||||||
|
validate_normalized_ir(&ir).unwrap();
|
||||||
|
let operation = &ir.operations[0];
|
||||||
|
let id = format!("{}:operation_id", operation.stable_id);
|
||||||
|
let expected_pointer = format!("{}/operationId", operation.location.pointer);
|
||||||
|
assert!(source_node(&ir.source_tree, &expected_pointer).is_none());
|
||||||
|
assert!(operation.findings.iter().any(|finding| {
|
||||||
|
finding.code == "missing_operation_id"
|
||||||
|
&& finding.construct_id == id
|
||||||
|
&& finding.location.pointer == expected_pointer
|
||||||
|
}));
|
||||||
|
assert!(ir.coverage.iter().any(|entry| {
|
||||||
|
entry.construct_id == id
|
||||||
|
&& entry.location.pointer == expected_pointer
|
||||||
|
&& entry.disposition == CoverageDisposition::Finding
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mut missing_finding = ir;
|
||||||
|
missing_finding.operations[0]
|
||||||
|
.findings
|
||||||
|
.retain(|finding| finding.code != "missing_operation_id");
|
||||||
|
assert_invalid(&missing_finding);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deduplicates_shared_path_parameter_and_schema_coverage_exactly() {
|
||||||
|
let ir = normalize(
|
||||||
|
r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Shared }
|
||||||
|
paths:
|
||||||
|
/items/{id}:
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
get:
|
||||||
|
operationId: getItem
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
post:
|
||||||
|
operationId: updateItem
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#,
|
||||||
|
);
|
||||||
|
validate_normalized_ir(&ir).unwrap();
|
||||||
|
assert_eq!(ir.operations.len(), 2);
|
||||||
|
assert_eq!(ir.operations[0].parameters, ir.operations[1].parameters);
|
||||||
|
let parameter_id = &ir.operations[0].parameters[0].construct_id;
|
||||||
|
let schema_id = &ir.operations[0].parameters[0]
|
||||||
|
.schema
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.construct_id;
|
||||||
|
assert_eq!(
|
||||||
|
ir.coverage
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| &entry.construct_id == parameter_id)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ir.coverage
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| &entry.construct_id == schema_id)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_method_has_exact_finding_pointer_and_coverage() {
|
||||||
|
let ir = normalize(
|
||||||
|
"openapi: 3.1.0\ninfo: { title: Unsupported }\npaths: { /ok: { get: { responses: { '200': { description: ok } } }, head: { responses: {} } } }",
|
||||||
|
);
|
||||||
|
validate_normalized_ir(&ir).unwrap();
|
||||||
|
let pointer = "/paths/~1ok/head";
|
||||||
|
let construct_id = format!("3.1.0:{pointer}");
|
||||||
|
assert!(ir.findings.iter().any(|finding| {
|
||||||
|
finding.code == "unsupported_http_method"
|
||||||
|
&& finding.construct_id == construct_id
|
||||||
|
&& finding.location.pointer == pointer
|
||||||
|
}));
|
||||||
|
assert!(ir.coverage.iter().any(|entry| {
|
||||||
|
entry.construct_id == construct_id
|
||||||
|
&& entry.location.pointer == pointer
|
||||||
|
&& entry.disposition == CoverageDisposition::Finding
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_node<'a>(
|
||||||
|
node: &'a CanonicalSourceNode,
|
||||||
|
pointer: &str,
|
||||||
|
) -> Option<&'a CanonicalSourceNode> {
|
||||||
|
if node.location.pointer == pointer {
|
||||||
|
return Some(node);
|
||||||
|
}
|
||||||
|
match &node.value {
|
||||||
|
CanonicalSourceValue::Array(items) => {
|
||||||
|
items.iter().find_map(|child| source_node(child, pointer))
|
||||||
|
}
|
||||||
|
CanonicalSourceValue::Object(items) => {
|
||||||
|
items.values().find_map(|child| source_node(child, pointer))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source_node_mut<'a>(
|
||||||
|
node: &'a mut CanonicalSourceNode,
|
||||||
|
pointer: &str,
|
||||||
|
) -> Option<&'a mut CanonicalSourceNode> {
|
||||||
|
if node.location.pointer == pointer {
|
||||||
|
return Some(node);
|
||||||
|
}
|
||||||
|
match &mut node.value {
|
||||||
|
CanonicalSourceValue::Array(items) => items
|
||||||
|
.iter_mut()
|
||||||
|
.find_map(|child| source_node_mut(child, pointer)),
|
||||||
|
CanonicalSourceValue::Object(items) => items
|
||||||
|
.values_mut()
|
||||||
|
.find_map(|child| source_node_mut(child, pointer)),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
["not", "an", "openapi", "root"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
openapi: 3.2.0
|
||||||
|
info: { title: unsupported }
|
||||||
|
paths: {}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
openapi: [wrong-root-field]
|
||||||
|
info: { title: Invalid }
|
||||||
|
paths: {}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Fixture OpenAPI 3.0 }
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
responses: { '200': { description: OK } }
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": { "title": "Fixture OpenAPI 3.1" },
|
||||||
|
"paths": {
|
||||||
|
"/health": {
|
||||||
|
"get": { "responses": { "200": { "description": "OK" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Fixture Swagger 2.0 }
|
||||||
|
paths:
|
||||||
|
/health:
|
||||||
|
get:
|
||||||
|
responses: { '200': { description: OK } }
|
||||||
@@ -0,0 +1,767 @@
|
|||||||
|
mod normalization_details {
|
||||||
|
use crank_import::rest::{
|
||||||
|
ImportFindingSeverity, ImportParseError, NormalizationConfig, normalize_verified_document,
|
||||||
|
preview_document, preview_document_legacy_v1,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn normalize_document(
|
||||||
|
document: &str,
|
||||||
|
config: &NormalizationConfig,
|
||||||
|
) -> Result<crank_import::rest::NormalizedIr, ImportParseError> {
|
||||||
|
normalize_verified_document(
|
||||||
|
document,
|
||||||
|
crank_import::rest::SourceDigest::parse("b".repeat(64)).unwrap(),
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn operation_identity_does_not_depend_on_operation_id() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Identity }
|
||||||
|
paths:
|
||||||
|
/same:
|
||||||
|
get:
|
||||||
|
operationId: duplicate
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
post:
|
||||||
|
operationId: duplicate
|
||||||
|
responses: { '201': { description: ok } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_ne!(ir.operations[0].stable_id, ir.operations[1].stable_id);
|
||||||
|
assert_eq!(ir.operations[0].key, "GET /same");
|
||||||
|
assert_eq!(ir.operations[1].key, "POST /same");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blank_and_duplicate_operation_ids_have_exact_findings_and_path_servers_win() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: IDs }
|
||||||
|
servers: [{ url: https://document.test }]
|
||||||
|
paths:
|
||||||
|
/one:
|
||||||
|
servers: [{ url: https://path.test }]
|
||||||
|
get:
|
||||||
|
operationId: duplicate
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
/two:
|
||||||
|
get:
|
||||||
|
operationId: duplicate
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
/blank:
|
||||||
|
get:
|
||||||
|
operationId: ' '
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
let duplicate = ir
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.filter(|operation| {
|
||||||
|
operation
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.code == "duplicate_operation_id")
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(duplicate.len(), 2);
|
||||||
|
assert!(duplicate.iter().all(|operation| {
|
||||||
|
operation
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.location.pointer.ends_with("/operationId"))
|
||||||
|
}));
|
||||||
|
let blank = ir
|
||||||
|
.operations
|
||||||
|
.iter()
|
||||||
|
.find(|operation| operation.path == "/blank")
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
blank
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.code == "missing_operation_id")
|
||||||
|
);
|
||||||
|
let preview = preview_document(document).unwrap();
|
||||||
|
let blank_preview = preview
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| &group.operations)
|
||||||
|
.find(|operation| operation.path == "/blank")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
blank_preview
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.code == "missing_operation_id")
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
preview
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| &group.operations)
|
||||||
|
.find(|operation| operation.path == "/one")
|
||||||
|
.unwrap()
|
||||||
|
.server_urls,
|
||||||
|
vec!["https://path.test"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parameters_keep_exact_path_and_operation_source_pointers() {
|
||||||
|
let openapi = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Parameter locations }
|
||||||
|
paths:
|
||||||
|
/items/{id}:
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
get:
|
||||||
|
operationId: getItem
|
||||||
|
parameters:
|
||||||
|
- { name: filter, in: query, schema: { type: string } }
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap();
|
||||||
|
let parameters = &ir.operations[0].parameters;
|
||||||
|
assert_eq!(
|
||||||
|
parameters[0].source_location.pointer,
|
||||||
|
"/paths/~1items~1{id}/parameters/0"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parameters[1].source_location.pointer,
|
||||||
|
"/paths/~1items~1{id}/get/parameters/0"
|
||||||
|
);
|
||||||
|
assert_ne!(parameters[0].construct_id, parameters[1].construct_id);
|
||||||
|
|
||||||
|
let swagger = r#"
|
||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Swagger parameter locations }
|
||||||
|
paths:
|
||||||
|
/items/{id}:
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, type: string }
|
||||||
|
post:
|
||||||
|
operationId: updateItem
|
||||||
|
parameters:
|
||||||
|
- { name: body, in: body, schema: { type: object } }
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap();
|
||||||
|
let parameter = &ir.operations[0].parameters[0];
|
||||||
|
assert_eq!(
|
||||||
|
parameter.source_location.pointer,
|
||||||
|
"/paths/~1items~1{id}/parameters/0"
|
||||||
|
);
|
||||||
|
assert!(ir.operations[0].request_body_schema.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openapi_parameter_omissions_are_errors_at_the_dropped_item_pointer() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Parameter findings }
|
||||||
|
servers: [{ url: https://example.test }]
|
||||||
|
paths:
|
||||||
|
/items/{id}:
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: path_cookie, in: cookie }
|
||||||
|
- not-an-object
|
||||||
|
- { $ref: '#/components/parameters/Id' }
|
||||||
|
get:
|
||||||
|
operationId: getItem
|
||||||
|
parameters:
|
||||||
|
- { in: query }
|
||||||
|
- { name: missing_location }
|
||||||
|
- { name: form_value, in: formData }
|
||||||
|
- { name: operation_cookie, in: cookie }
|
||||||
|
- { name: query, in: query, schema: { type: string } }
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
Id: { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ir.operations[0]
|
||||||
|
.parameters
|
||||||
|
.iter()
|
||||||
|
.map(|parameter| parameter.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["id", "query"]
|
||||||
|
);
|
||||||
|
let errors = ir
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| {
|
||||||
|
[
|
||||||
|
"invalid_parameter",
|
||||||
|
"missing_parameter_name",
|
||||||
|
"missing_parameter_location",
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
]
|
||||||
|
.contains(&finding.code.as_str())
|
||||||
|
})
|
||||||
|
.map(|finding| (finding.code.as_str(), finding.location.pointer.as_str()))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
errors,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
(
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"/paths/~1items~1{id}/parameters/1",
|
||||||
|
),
|
||||||
|
("invalid_parameter", "/paths/~1items~1{id}/parameters/2",),
|
||||||
|
(
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
"/paths/~1items~1{id}/parameters/3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"missing_parameter_name",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"missing_parameter_location",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/2",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/3",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ir.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| {
|
||||||
|
[
|
||||||
|
"invalid_parameter",
|
||||||
|
"missing_parameter_name",
|
||||||
|
"missing_parameter_location",
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
]
|
||||||
|
.contains(&finding.code.as_str())
|
||||||
|
})
|
||||||
|
.all(|finding| finding.severity == ImportFindingSeverity::Error)
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = preview_document_legacy_v1(document).unwrap();
|
||||||
|
assert!(!legacy.findings.iter().any(|finding| {
|
||||||
|
[
|
||||||
|
"invalid_parameter",
|
||||||
|
"missing_parameter_name",
|
||||||
|
"missing_parameter_location",
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
]
|
||||||
|
.contains(&finding.code.as_str())
|
||||||
|
}));
|
||||||
|
assert!(
|
||||||
|
!legacy.groups[0].operations[0]
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.code == "unsupported_cookie_parameter")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swagger_parameter_omissions_are_errors_at_the_dropped_item_pointer() {
|
||||||
|
let document = r#"
|
||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Swagger parameter findings }
|
||||||
|
host: example.test
|
||||||
|
paths:
|
||||||
|
/items/{id}:
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, type: string }
|
||||||
|
- { name: path_cookie, in: cookie }
|
||||||
|
- not-an-object
|
||||||
|
- { $ref: '#/parameters/Id' }
|
||||||
|
get:
|
||||||
|
operationId: getItem
|
||||||
|
parameters:
|
||||||
|
- { in: query }
|
||||||
|
- { name: missing_location }
|
||||||
|
- { name: form_value, in: formData }
|
||||||
|
- { name: operation_cookie, in: cookie }
|
||||||
|
- { name: query, in: query, type: string }
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
parameters:
|
||||||
|
Id: { name: id, in: path, required: true, type: string }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ir.operations[0]
|
||||||
|
.parameters
|
||||||
|
.iter()
|
||||||
|
.map(|parameter| parameter.name.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["id", "query"]
|
||||||
|
);
|
||||||
|
let errors = ir
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| {
|
||||||
|
[
|
||||||
|
"invalid_parameter",
|
||||||
|
"missing_parameter_name",
|
||||||
|
"missing_parameter_location",
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
]
|
||||||
|
.contains(&finding.code.as_str())
|
||||||
|
})
|
||||||
|
.map(|finding| (finding.code.as_str(), finding.location.pointer.as_str()))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
errors,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
(
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"/paths/~1items~1{id}/parameters/1",
|
||||||
|
),
|
||||||
|
("invalid_parameter", "/paths/~1items~1{id}/parameters/2",),
|
||||||
|
(
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
"/paths/~1items~1{id}/parameters/3",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"missing_parameter_name",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/0",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"missing_parameter_location",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/1",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/2",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"/paths/~1items~1{id}/get/parameters/3",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ir.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| {
|
||||||
|
[
|
||||||
|
"invalid_parameter",
|
||||||
|
"missing_parameter_name",
|
||||||
|
"missing_parameter_location",
|
||||||
|
"unsupported_parameter_location",
|
||||||
|
"unsupported_cookie_parameter",
|
||||||
|
"unresolved_parameter_reference",
|
||||||
|
]
|
||||||
|
.contains(&finding.code.as_str())
|
||||||
|
})
|
||||||
|
.all(|finding| finding.severity == ImportFindingSeverity::Error)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openapi_invalid_servers_and_tags_keep_valid_siblings_with_exact_findings() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Server and tag findings }
|
||||||
|
servers:
|
||||||
|
- { url: https://document.test/ }
|
||||||
|
- not-an-object
|
||||||
|
- {}
|
||||||
|
- { url: 42 }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
servers:
|
||||||
|
- { url: https://path.test/ }
|
||||||
|
- false
|
||||||
|
- {}
|
||||||
|
get:
|
||||||
|
operationId: getItems
|
||||||
|
servers:
|
||||||
|
- { url: https://operation.test/ }
|
||||||
|
- { url: false }
|
||||||
|
tags: [items, 42, {}, null]
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_eq!(ir.source.servers, vec!["https://document.test"]);
|
||||||
|
assert_eq!(ir.operations[0].servers, vec!["https://operation.test"]);
|
||||||
|
assert_eq!(ir.operations[0].tags, vec!["items"]);
|
||||||
|
|
||||||
|
let server_errors = ir
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.code == "invalid_server")
|
||||||
|
.map(|finding| finding.location.pointer.as_str())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
server_errors,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"/servers/1",
|
||||||
|
"/servers/2",
|
||||||
|
"/servers/3",
|
||||||
|
"/paths/~1items/servers/1",
|
||||||
|
"/paths/~1items/servers/2",
|
||||||
|
"/paths/~1items/get/servers/1",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ir.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.code == "invalid_server")
|
||||||
|
.all(|finding| finding.severity == ImportFindingSeverity::Error)
|
||||||
|
);
|
||||||
|
let tag_warnings = ir
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.code == "invalid_tag")
|
||||||
|
.map(|finding| finding.location.pointer.as_str())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
tag_warnings,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"/paths/~1items/get/tags/1",
|
||||||
|
"/paths/~1items/get/tags/2",
|
||||||
|
"/paths/~1items/get/tags/3",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ir.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.code == "invalid_tag")
|
||||||
|
.all(|finding| finding.severity == ImportFindingSeverity::Warning)
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = preview_document_legacy_v1(document).unwrap();
|
||||||
|
assert_eq!(legacy.source.servers, vec!["https://document.test"]);
|
||||||
|
assert_eq!(
|
||||||
|
legacy.groups[0].operations[0].server_urls,
|
||||||
|
vec!["https://operation.test"]
|
||||||
|
);
|
||||||
|
assert_eq!(legacy.groups[0].operations[0].category, "items");
|
||||||
|
assert!(
|
||||||
|
!legacy.findings.iter().any(|finding| {
|
||||||
|
["invalid_server", "invalid_tag"].contains(&finding.code.as_str())
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn swagger_invalid_tags_keep_valid_siblings_with_exact_warning_pointers() {
|
||||||
|
let document = r#"
|
||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Swagger tag findings }
|
||||||
|
host: example.test
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
operationId: getItems
|
||||||
|
tags: [items, 42, {}, null]
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_eq!(ir.operations[0].tags, vec!["items"]);
|
||||||
|
let warnings = ir
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.code == "invalid_tag")
|
||||||
|
.map(|finding| finding.location.pointer.as_str())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
warnings,
|
||||||
|
std::collections::BTreeSet::from([
|
||||||
|
"/paths/~1items/get/tags/1",
|
||||||
|
"/paths/~1items/get/tags/2",
|
||||||
|
"/paths/~1items/get/tags/3",
|
||||||
|
])
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
ir.findings
|
||||||
|
.iter()
|
||||||
|
.filter(|finding| finding.code == "invalid_tag")
|
||||||
|
.all(|finding| finding.severity == ImportFindingSeverity::Warning)
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = preview_document_legacy_v1(document).unwrap();
|
||||||
|
assert_eq!(legacy.groups[0].operations[0].category, "items");
|
||||||
|
assert!(
|
||||||
|
!legacy
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.any(|finding| finding.code == "invalid_tag")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v2_response_selection_uses_all_success_codes_then_openapi_wildcard_and_default() {
|
||||||
|
let openapi = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Response priority }
|
||||||
|
paths:
|
||||||
|
/numeric:
|
||||||
|
get:
|
||||||
|
operationId: numeric
|
||||||
|
responses:
|
||||||
|
'200': { description: no schema }
|
||||||
|
'203': { description: accepted, content: { application/json: { schema: { type: string } } } }
|
||||||
|
'206': { description: partial, content: { application/json: { schema: { type: integer } } } }
|
||||||
|
/wildcard:
|
||||||
|
get:
|
||||||
|
operationId: wildcard
|
||||||
|
responses:
|
||||||
|
'200': { description: unsupported, content: { text/plain: { schema: { type: string } } } }
|
||||||
|
'2xX': { description: wildcard, content: { application/json: { schema: { type: boolean } } } }
|
||||||
|
default: { description: fallback, content: { application/json: { schema: { type: string } } } }
|
||||||
|
/default:
|
||||||
|
get:
|
||||||
|
operationId: fallback
|
||||||
|
responses:
|
||||||
|
'203': { description: no schema, content: { application/json: {} } }
|
||||||
|
default: { description: fallback, content: { application/json: { schema: { type: number } } } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap();
|
||||||
|
let response_pointer = |path: &str| {
|
||||||
|
ir.operations
|
||||||
|
.iter()
|
||||||
|
.find(|operation| operation.path == path)
|
||||||
|
.unwrap()
|
||||||
|
.response_schema
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.location
|
||||||
|
.pointer
|
||||||
|
.as_str()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
response_pointer("/numeric"),
|
||||||
|
"/paths/~1numeric/get/responses/203/content/application~1json/schema"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response_pointer("/wildcard"),
|
||||||
|
"/paths/~1wildcard/get/responses/2xX/content/application~1json/schema"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response_pointer("/default"),
|
||||||
|
"/paths/~1default/get/responses/default/content/application~1json/schema"
|
||||||
|
);
|
||||||
|
|
||||||
|
let swagger = r#"
|
||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Swagger response priority }
|
||||||
|
paths:
|
||||||
|
/numeric:
|
||||||
|
get:
|
||||||
|
operationId: numeric
|
||||||
|
responses:
|
||||||
|
'200': { description: no schema }
|
||||||
|
'206': { description: partial, schema: { type: integer } }
|
||||||
|
default: { description: fallback, schema: { type: string } }
|
||||||
|
/no-wildcard:
|
||||||
|
get:
|
||||||
|
operationId: noWildcard
|
||||||
|
responses:
|
||||||
|
'2XX': { description: ignored wildcard, schema: { type: string } }
|
||||||
|
default: { description: fallback, schema: { type: boolean } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap();
|
||||||
|
let response_pointer = |path: &str| {
|
||||||
|
ir.operations
|
||||||
|
.iter()
|
||||||
|
.find(|operation| operation.path == path)
|
||||||
|
.unwrap()
|
||||||
|
.response_schema
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.location
|
||||||
|
.pointer
|
||||||
|
.as_str()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
response_pointer("/numeric"),
|
||||||
|
"/paths/~1numeric/get/responses/206/schema"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response_pointer("/no-wildcard"),
|
||||||
|
"/paths/~1no-wildcard/get/responses/default/schema"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn schemas_keep_selected_source_pointers_and_pointer_derived_ids() {
|
||||||
|
let openapi = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Schema locations }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
post:
|
||||||
|
operationId: updateItems
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/vnd.example+json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
a/b: { type: string }
|
||||||
|
a~1b: { type: string }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: created
|
||||||
|
content:
|
||||||
|
application/vnd.example+json:
|
||||||
|
schema: { type: integer }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(openapi, &NormalizationConfig::default()).unwrap();
|
||||||
|
let operation = &ir.operations[0];
|
||||||
|
assert_eq!(
|
||||||
|
operation
|
||||||
|
.request_body_schema
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.location
|
||||||
|
.pointer,
|
||||||
|
"/paths/~1items/post/requestBody/content/application~1vnd.example+json/schema"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
operation.response_schema.as_ref().unwrap().location.pointer,
|
||||||
|
"/paths/~1items/post/responses/201/content/application~1vnd.example+json/schema"
|
||||||
|
);
|
||||||
|
let crank_import::rest::NormalizedSchemaKind::Object { properties, .. } =
|
||||||
|
&operation.request_body_schema.as_ref().unwrap().kind
|
||||||
|
else {
|
||||||
|
panic!("request schema must be object");
|
||||||
|
};
|
||||||
|
assert_ne!(
|
||||||
|
properties["a/b"].construct_id,
|
||||||
|
properties["a~1b"].construct_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let swagger = r#"
|
||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Swagger schema locations }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
post:
|
||||||
|
operationId: updateItems
|
||||||
|
parameters:
|
||||||
|
- in: body
|
||||||
|
name: body
|
||||||
|
schema: { type: object }
|
||||||
|
responses:
|
||||||
|
'201': { description: created, schema: { type: integer } }
|
||||||
|
"#;
|
||||||
|
let ir = normalize_document(swagger, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
ir.operations[0]
|
||||||
|
.request_body_schema
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.location
|
||||||
|
.pointer,
|
||||||
|
"/paths/~1items/post/parameters/0/schema"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ir.operations[0]
|
||||||
|
.response_schema
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.location
|
||||||
|
.pointer,
|
||||||
|
"/paths/~1items/post/responses/201/schema"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserves_every_unresolved_reference_from_the_full_source_tree() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: References }
|
||||||
|
paths:
|
||||||
|
/ok:
|
||||||
|
parameters:
|
||||||
|
- { $ref: '#/components/parameters/Id' }
|
||||||
|
get:
|
||||||
|
operationId: getOk
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Body' }
|
||||||
|
responses:
|
||||||
|
'200': { $ref: '#/components/responses/Ok' }
|
||||||
|
/reused:
|
||||||
|
$ref: '#/components/pathItems/Reusable'
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
Loop: { $ref: '#/components/schemas/Loop' }
|
||||||
|
Remote: { $ref: 'https://example.test/schema.json#/Remote' }
|
||||||
|
parameters:
|
||||||
|
Id: { $ref: '#/components/parameters/Id' }
|
||||||
|
requestBodies:
|
||||||
|
Body: { $ref: '#/components/requestBodies/Body' }
|
||||||
|
responses:
|
||||||
|
Ok: { $ref: '#/components/responses/Ok' }
|
||||||
|
pathItems:
|
||||||
|
Reusable: { $ref: '#/components/pathItems/Reusable' }
|
||||||
|
"#;
|
||||||
|
let first = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
let second = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_vec(&first).unwrap(),
|
||||||
|
serde_json::to_vec(&second).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(first.unresolved_references.len(), 10);
|
||||||
|
let references = first
|
||||||
|
.unresolved_references
|
||||||
|
.iter()
|
||||||
|
.map(|reference| (reference.uri.as_str(), reference.location.pointer.as_str()))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert!(references.contains(&(
|
||||||
|
"#/components/parameters/Id",
|
||||||
|
"/paths/~1ok/parameters/0/$ref"
|
||||||
|
)));
|
||||||
|
assert!(references.contains(&(
|
||||||
|
"#/components/requestBodies/Body",
|
||||||
|
"/paths/~1ok/get/requestBody/$ref"
|
||||||
|
)));
|
||||||
|
assert!(references.contains(&(
|
||||||
|
"#/components/responses/Ok",
|
||||||
|
"/paths/~1ok/get/responses/200/$ref"
|
||||||
|
)));
|
||||||
|
assert!(references.contains(&("#/components/pathItems/Reusable", "/paths/~1reused/$ref")));
|
||||||
|
assert!(
|
||||||
|
references.contains(&("#/components/schemas/Loop", "/components/schemas/Loop/$ref"))
|
||||||
|
);
|
||||||
|
assert!(references.contains(&(
|
||||||
|
"https://example.test/schema.json#/Remote",
|
||||||
|
"/components/schemas/Remote/$ref"
|
||||||
|
)));
|
||||||
|
assert!(first.unresolved_references.iter().all(|reference| {
|
||||||
|
reference.construct_id
|
||||||
|
== format!(
|
||||||
|
"reference:{}",
|
||||||
|
reference
|
||||||
|
.location
|
||||||
|
.pointer
|
||||||
|
.replace('~', "~0")
|
||||||
|
.replace('/', "~1")
|
||||||
|
)
|
||||||
|
&& first.coverage.iter().any(|entry| {
|
||||||
|
entry.construct_id == reference.construct_id
|
||||||
|
&& entry.location == reference.location
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
mod unit {
|
||||||
|
use crank_core::HttpMethod;
|
||||||
|
use crank_import::rest::{ImportParseError, preview_document, preview_document_legacy_v1};
|
||||||
|
use crank_schema::SchemaKind;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
const OPENAPI3: &str = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Frankfurter API
|
||||||
|
servers:
|
||||||
|
- url: https://api.frankfurter.dev
|
||||||
|
paths:
|
||||||
|
/v2/latest:
|
||||||
|
get:
|
||||||
|
operationId: getLatestRates
|
||||||
|
summary: Получить последние курсы
|
||||||
|
description: Возвращает последние курсы валют для базовой валюты.
|
||||||
|
tags: [currency]
|
||||||
|
parameters:
|
||||||
|
- name: base
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- name: symbols
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [amount, base]
|
||||||
|
properties:
|
||||||
|
amount:
|
||||||
|
type: number
|
||||||
|
base:
|
||||||
|
type: string
|
||||||
|
"#;
|
||||||
|
|
||||||
|
const SWAGGER2: &str = r#"
|
||||||
|
swagger: "2.0"
|
||||||
|
info:
|
||||||
|
title: Pet API
|
||||||
|
host: petstore.example.com
|
||||||
|
basePath: /api
|
||||||
|
schemes: [https]
|
||||||
|
paths:
|
||||||
|
/pets/{id}:
|
||||||
|
get:
|
||||||
|
operationId: getPet
|
||||||
|
summary: Получить питомца
|
||||||
|
tags: [pets]
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Pet'
|
||||||
|
definitions:
|
||||||
|
Pet:
|
||||||
|
type: object
|
||||||
|
required: [id, name]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previews_openapi3_rest_operations_grouped_by_tag() {
|
||||||
|
let preview = preview_document(OPENAPI3).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(preview.source.format, "openapi");
|
||||||
|
assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]);
|
||||||
|
assert_eq!(preview.groups.len(), 1);
|
||||||
|
assert_eq!(preview.groups[0].key, "currency");
|
||||||
|
let operation = &preview.groups[0].operations[0];
|
||||||
|
assert_eq!(operation.method, HttpMethod::Get);
|
||||||
|
assert_eq!(operation.suggested_name, "get_latest_rates");
|
||||||
|
assert_eq!(operation.input_fields, 2);
|
||||||
|
assert_eq!(operation.output_fields, 2);
|
||||||
|
assert_eq!(operation.draft.target.path_template, "/v2/latest");
|
||||||
|
assert_eq!(operation.draft.input_mapping.rules.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn previews_swagger2_and_preserves_unresolved_definitions() {
|
||||||
|
let preview = preview_document(SWAGGER2).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(preview.source.format, "swagger");
|
||||||
|
assert_eq!(
|
||||||
|
preview.source.servers,
|
||||||
|
vec!["https://petstore.example.com/api"]
|
||||||
|
);
|
||||||
|
let operation = &preview.groups[0].operations[0];
|
||||||
|
assert_eq!(operation.suggested_name, "get_pet");
|
||||||
|
assert_eq!(operation.input_fields, 1);
|
||||||
|
assert_eq!(operation.output_fields, 0);
|
||||||
|
assert_eq!(operation.draft.target.path_template, "/pets/{id}");
|
||||||
|
assert_eq!(
|
||||||
|
operation.draft.input_mapping.rules[0].target,
|
||||||
|
"$.request.path.id"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalized_schema_descriptions_round_trip_to_draft_fields_and_arrays_default_to_string_items()
|
||||||
|
{
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Schema descriptions }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
post:
|
||||||
|
operationId: createItem
|
||||||
|
parameters:
|
||||||
|
- name: filter
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: Filter input
|
||||||
|
properties:
|
||||||
|
nested:
|
||||||
|
type: object
|
||||||
|
description: Nested filter
|
||||||
|
properties:
|
||||||
|
labels:
|
||||||
|
type: array
|
||||||
|
description: Labels without item schema
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: Request root
|
||||||
|
properties:
|
||||||
|
payload:
|
||||||
|
type: object
|
||||||
|
description: Request payload
|
||||||
|
properties:
|
||||||
|
enabled: { type: boolean, description: Enable the item }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
description: Created item response
|
||||||
|
properties:
|
||||||
|
id: { type: string, description: New item identifier }
|
||||||
|
"#;
|
||||||
|
let preview = preview_document(document).unwrap();
|
||||||
|
let operation = &preview.groups[0].operations[0];
|
||||||
|
let filter = operation.draft.input_schema.field("filter").unwrap();
|
||||||
|
assert_eq!(filter.description.as_deref(), Some("Filter input"));
|
||||||
|
let nested = filter.field("nested").unwrap();
|
||||||
|
assert_eq!(nested.description.as_deref(), Some("Nested filter"));
|
||||||
|
let labels = nested.field("labels").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
labels.description.as_deref(),
|
||||||
|
Some("Labels without item schema")
|
||||||
|
);
|
||||||
|
assert_eq!(labels.kind, SchemaKind::Array);
|
||||||
|
assert_eq!(labels.items.as_ref().unwrap().kind, SchemaKind::String);
|
||||||
|
|
||||||
|
let payload = operation.draft.input_schema.field("payload").unwrap();
|
||||||
|
assert_eq!(payload.description.as_deref(), Some("Request payload"));
|
||||||
|
assert_eq!(
|
||||||
|
payload.field("enabled").unwrap().description.as_deref(),
|
||||||
|
Some("Enable the item")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
operation.draft.output_schema.description.as_deref(),
|
||||||
|
Some("Ответ API")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
operation
|
||||||
|
.draft
|
||||||
|
.output_schema
|
||||||
|
.field("id")
|
||||||
|
.unwrap()
|
||||||
|
.description
|
||||||
|
.as_deref(),
|
||||||
|
Some("New item identifier")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_v1_keeps_frozen_response_priority_when_v2_selects_later_success_code() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Response replay priority }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
operationId: listItems
|
||||||
|
responses:
|
||||||
|
'200': { description: no schema }
|
||||||
|
'203': { description: selected by v2, content: { application/json: { schema: { type: string } } } }
|
||||||
|
'206': { description: later success, content: { application/json: { schema: { type: integer } } } }
|
||||||
|
default: { description: legacy fallback, content: { application/json: { schema: { type: boolean } } } }
|
||||||
|
"#;
|
||||||
|
let v2 = preview_document(document).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
v2.groups[0].operations[0].draft.output_schema.kind,
|
||||||
|
SchemaKind::String
|
||||||
|
);
|
||||||
|
|
||||||
|
let legacy = preview_document_legacy_v1(document).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
legacy.groups[0].operations[0].draft.output_schema.kind,
|
||||||
|
SchemaKind::Boolean
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_v1_projection_matches_baseline_local_ref_and_composition_behavior() {
|
||||||
|
let document = r#"
|
||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Legacy }
|
||||||
|
paths:
|
||||||
|
/pets:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
'200': { description: OK, schema: { $ref: '#/definitions/Pet' } }
|
||||||
|
definitions:
|
||||||
|
Pet:
|
||||||
|
allOf:
|
||||||
|
- type: object
|
||||||
|
properties: { id: { type: string }, name: { type: string } }
|
||||||
|
"#;
|
||||||
|
let preview = preview_document_legacy_v1(document).unwrap();
|
||||||
|
assert_eq!(preview.groups[0].operations[0].output_fields, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_v1_resolves_object_level_local_refs_before_projection() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Legacy object refs }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
operationId: getItem
|
||||||
|
parameters: [{ $ref: '#/components/parameters/Id' }]
|
||||||
|
requestBody: { $ref: '#/components/requestBodies/Body' }
|
||||||
|
responses:
|
||||||
|
'200': { $ref: '#/components/responses/Ok' }
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
Id:
|
||||||
|
name: id
|
||||||
|
in: query
|
||||||
|
schema: { type: string }
|
||||||
|
requestBodies:
|
||||||
|
Body:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Input' }
|
||||||
|
responses:
|
||||||
|
Ok:
|
||||||
|
description: ok
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Output' }
|
||||||
|
schemas:
|
||||||
|
Input:
|
||||||
|
type: object
|
||||||
|
properties: { name: { type: string } }
|
||||||
|
Output:
|
||||||
|
type: object
|
||||||
|
properties: { result: { type: string } }
|
||||||
|
"#;
|
||||||
|
let preview = preview_document_legacy_v1(document).unwrap();
|
||||||
|
let operation = &preview.groups[0].operations[0];
|
||||||
|
assert_eq!(operation.input_fields, 2);
|
||||||
|
assert_eq!(operation.output_fields, 1);
|
||||||
|
assert!(operation.draft.input_schema.fields.contains_key("id"));
|
||||||
|
assert!(operation.draft.input_schema.fields.contains_key("name"));
|
||||||
|
assert!(operation.draft.output_schema.fields.contains_key("result"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_v1_rejects_hostile_alias_input_before_decode() {
|
||||||
|
let document = format!(
|
||||||
|
"openapi: 3.0.3\ninfo: {{ title: aliases }}\npaths: {{}}\nitems: [{}]",
|
||||||
|
std::iter::repeat_n("*bomb", 129)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
preview_document_legacy_v1(&document),
|
||||||
|
Err(ImportParseError::LimitExceeded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_v1_keeps_baseline_version_dispatch_and_does_not_expand_path_item_refs() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 9.9.0
|
||||||
|
info: { title: Legacy dispatch }
|
||||||
|
paths:
|
||||||
|
/valid:
|
||||||
|
get:
|
||||||
|
operationId: valid
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
/referenced:
|
||||||
|
$ref: '#/x-path-items/referenced'
|
||||||
|
x-path-items:
|
||||||
|
referenced:
|
||||||
|
post:
|
||||||
|
operationId: mustNotAppear
|
||||||
|
responses: { '201': { description: ok } }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let preview = preview_document_legacy_v1(document).unwrap();
|
||||||
|
assert_eq!(preview.source.version.as_deref(), Some("9.9.0"));
|
||||||
|
let keys = preview
|
||||||
|
.groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|group| group.operations.iter())
|
||||||
|
.map(|operation| operation.key.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(keys, vec!["GET /valid"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_v1_preview_exactly_matches_baseline_for_path_refs_servers_and_malformed_operations() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Legacy parity }
|
||||||
|
servers: [{ url: https://document.test }]
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
parameters:
|
||||||
|
- { $ref: '#/components/parameters/Query' }
|
||||||
|
- { name: session, in: cookie }
|
||||||
|
servers: [{ url: https://path.test }]
|
||||||
|
head: { responses: {} }
|
||||||
|
get: false
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
Query: { name: q, in: query, schema: { type: string } }
|
||||||
|
"#;
|
||||||
|
let preview = preview_document_legacy_v1(document).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(preview).unwrap(),
|
||||||
|
json!({
|
||||||
|
"source": {
|
||||||
|
"format": "openapi",
|
||||||
|
"version": "3.0.3",
|
||||||
|
"title": "Legacy parity",
|
||||||
|
"servers": ["https://document.test"]
|
||||||
|
},
|
||||||
|
"groups": [{
|
||||||
|
"key": "imported_operation",
|
||||||
|
"title": "Без группы",
|
||||||
|
"operations": [{
|
||||||
|
"key": "GET /items",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/items",
|
||||||
|
"suggested_name": "g_e_t_items",
|
||||||
|
"suggested_display_name": "G E T Items",
|
||||||
|
"description": "Выполняет GET /items",
|
||||||
|
"category": "imported",
|
||||||
|
"input_fields": 1,
|
||||||
|
"output_fields": 0,
|
||||||
|
"server_urls": ["https://document.test"],
|
||||||
|
"findings": [
|
||||||
|
{
|
||||||
|
"code": "missing_operation_id",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "У метода нет operationId, имя инструмента будет сгенерировано из метода и пути.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "missing_summary",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "У метода нет summary, отображаемое имя будет сгенерировано автоматически.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "missing_description",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "У метода нет description. Перед публикацией лучше описать, когда агенту стоит вызывать этот инструмент.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "missing_response_schema",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "У метода не найдена схема успешного ответа, результат будет описан как общий объект.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "parameter_descriptions_missing",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "У 1 входных параметров нет описания. Модели будет сложнее понять, какие значения туда передавать.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "empty_output_schema",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "В ответе не найдено отдельных полей. Перед публикацией проверьте схему ответа и маппинг результата.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "weak_tool_description",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "Описание инструмента слишком короткое или техническое. Перед публикацией добавьте, когда агент должен вызывать инструмент и что будет в успешном ответе.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "weak_tool_name",
|
||||||
|
"severity": "warning",
|
||||||
|
"message": "Имя инструмента `g_e_t_items` выглядит слишком общим. Лучше использовать имя с конкретным действием и объектом.",
|
||||||
|
"operation_key": "GET /items"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"draft": {
|
||||||
|
"name": "g_e_t_items",
|
||||||
|
"display_name": "G E T Items",
|
||||||
|
"category": "imported",
|
||||||
|
"target": {
|
||||||
|
"base_url": "https://document.test",
|
||||||
|
"method": "GET",
|
||||||
|
"path_template": "/items"
|
||||||
|
},
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Входные параметры MCP-инструмента",
|
||||||
|
"required": true,
|
||||||
|
"nullable": false,
|
||||||
|
"fields": {
|
||||||
|
"q": {
|
||||||
|
"type": "string",
|
||||||
|
"required": false,
|
||||||
|
"nullable": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Ответ API",
|
||||||
|
"required": true,
|
||||||
|
"nullable": false
|
||||||
|
},
|
||||||
|
"input_mapping": {
|
||||||
|
"rules": [{
|
||||||
|
"source": "$.mcp.q",
|
||||||
|
"target": "$.request.query.q",
|
||||||
|
"required": false
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"output_mapping": {
|
||||||
|
"rules": [{
|
||||||
|
"source": "$.response.body",
|
||||||
|
"target": "$.output",
|
||||||
|
"required": true
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
"tool_description": {
|
||||||
|
"title": "G E T Items",
|
||||||
|
"description": "Выполняет GET /items",
|
||||||
|
"examples": [{ "input": {} }]
|
||||||
|
},
|
||||||
|
"wizard_state": {}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"findings": []
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_missing_descriptions_as_recommendations() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Minimal API }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
'204': { description: Empty }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let preview = preview_document(document).unwrap();
|
||||||
|
let operation = &preview.groups[0].operations[0];
|
||||||
|
let codes = operation
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.map(|finding| finding.code.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert!(codes.contains(&"missing_operation_id"));
|
||||||
|
assert!(codes.contains(&"missing_summary"));
|
||||||
|
assert!(codes.contains(&"missing_description"));
|
||||||
|
assert!(codes.contains(&"missing_response_schema"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expands_json_request_body_object_into_tool_inputs() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: CRM API }
|
||||||
|
servers:
|
||||||
|
- url: https://crm.example.test
|
||||||
|
paths:
|
||||||
|
/leads:
|
||||||
|
post:
|
||||||
|
operationId: createLead
|
||||||
|
summary: Создать лид
|
||||||
|
description: Создает лид в CRM.
|
||||||
|
tags: [crm]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [email]
|
||||||
|
properties:
|
||||||
|
email: { type: string }
|
||||||
|
name: { type: string }
|
||||||
|
responses:
|
||||||
|
'201':
|
||||||
|
description: Created
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let preview = preview_document(document).unwrap();
|
||||||
|
let operation = &preview.groups[0].operations[0];
|
||||||
|
let input_fields = &operation.draft.input_schema.fields;
|
||||||
|
let targets = operation
|
||||||
|
.draft
|
||||||
|
.input_mapping
|
||||||
|
.rules
|
||||||
|
.iter()
|
||||||
|
.map(|rule| rule.target.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert!(input_fields.contains_key("email"));
|
||||||
|
assert!(input_fields.contains_key("name"));
|
||||||
|
assert!(targets.contains(&"$.request.body.email"));
|
||||||
|
assert!(targets.contains(&"$.request.body.name"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_tool_quality_recommendations_for_imported_operations() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.0.3
|
||||||
|
info: { title: Wide API }
|
||||||
|
paths:
|
||||||
|
/items:
|
||||||
|
get:
|
||||||
|
operationId: getItems
|
||||||
|
summary: Get items
|
||||||
|
description: Get items.
|
||||||
|
parameters:
|
||||||
|
- name: page
|
||||||
|
in: query
|
||||||
|
schema: { type: integer }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: OK
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
field01: { type: string }
|
||||||
|
field02: { type: string }
|
||||||
|
field03: { type: string }
|
||||||
|
field04: { type: string }
|
||||||
|
field05: { type: string }
|
||||||
|
field06: { type: string }
|
||||||
|
field07: { type: string }
|
||||||
|
field08: { type: string }
|
||||||
|
field09: { type: string }
|
||||||
|
field10: { type: string }
|
||||||
|
field11: { type: string }
|
||||||
|
field12: { type: string }
|
||||||
|
field13: { type: string }
|
||||||
|
"#;
|
||||||
|
|
||||||
|
let preview = preview_document(document).unwrap();
|
||||||
|
let operation = &preview.groups[0].operations[0];
|
||||||
|
let codes = operation
|
||||||
|
.findings
|
||||||
|
.iter()
|
||||||
|
.map(|finding| finding.code.as_str())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
assert!(codes.contains(&"parameter_descriptions_missing"));
|
||||||
|
assert!(codes.contains(&"weak_tool_description"));
|
||||||
|
assert!(codes.contains(&"weak_tool_name"));
|
||||||
|
assert!(codes.contains(&"too_many_output_fields"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+509
-172
@@ -1,44 +1,8 @@
|
|||||||
mod unit {
|
mod unit {
|
||||||
use crank_core::HttpMethod;
|
use crank_import::rest::{
|
||||||
use crank_import::rest::preview_document;
|
ImportFindingSeverity, ImportParseError, NormalizationConfig, normalize_verified_document,
|
||||||
|
preview_document,
|
||||||
const OPENAPI3: &str = r#"
|
};
|
||||||
openapi: 3.0.3
|
|
||||||
info:
|
|
||||||
title: Frankfurter API
|
|
||||||
servers:
|
|
||||||
- url: https://api.frankfurter.dev
|
|
||||||
paths:
|
|
||||||
/v2/latest:
|
|
||||||
get:
|
|
||||||
operationId: getLatestRates
|
|
||||||
summary: Получить последние курсы
|
|
||||||
description: Возвращает последние курсы валют для базовой валюты.
|
|
||||||
tags: [currency]
|
|
||||||
parameters:
|
|
||||||
- name: base
|
|
||||||
in: query
|
|
||||||
required: true
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: symbols
|
|
||||||
in: query
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
responses:
|
|
||||||
'200':
|
|
||||||
description: OK
|
|
||||||
content:
|
|
||||||
application/json:
|
|
||||||
schema:
|
|
||||||
type: object
|
|
||||||
required: [amount, base]
|
|
||||||
properties:
|
|
||||||
amount:
|
|
||||||
type: number
|
|
||||||
base:
|
|
||||||
type: string
|
|
||||||
"#;
|
|
||||||
|
|
||||||
const SWAGGER2: &str = r#"
|
const SWAGGER2: &str = r#"
|
||||||
swagger: "2.0"
|
swagger: "2.0"
|
||||||
@@ -74,170 +38,543 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
#[test]
|
fn normalize_document(
|
||||||
fn previews_openapi3_rest_operations_grouped_by_tag() {
|
document: &str,
|
||||||
let preview = preview_document(OPENAPI3).unwrap();
|
config: &NormalizationConfig,
|
||||||
|
) -> Result<crank_import::rest::NormalizedIr, ImportParseError> {
|
||||||
|
normalize_verified_document(
|
||||||
|
document,
|
||||||
|
crank_import::rest::SourceDigest::parse("b".repeat(64)).unwrap(),
|
||||||
|
config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
assert_eq!(preview.source.format, "openapi");
|
fn matrix_source() -> String {
|
||||||
assert_eq!(preview.source.servers, vec!["https://api.frankfurter.dev"]);
|
"openapi: 3.1.0\ninfo: { title: Matrix }\npaths:\n /ok:\n get:\n operationId: getOk\n tags: [matrix]\n parameters: [{ name: q, in: query, schema: { type: string } }]\n responses: { '200': { description: ok, content: { application/json: { schema: { type: object, properties: { value: { type: string } } } } } } }".to_owned()
|
||||||
assert_eq!(preview.groups.len(), 1);
|
|
||||||
assert_eq!(preview.groups[0].key, "currency");
|
|
||||||
let operation = &preview.groups[0].operations[0];
|
|
||||||
assert_eq!(operation.method, HttpMethod::Get);
|
|
||||||
assert_eq!(operation.suggested_name, "get_latest_rates");
|
|
||||||
assert_eq!(operation.input_fields, 2);
|
|
||||||
assert_eq!(operation.output_fields, 2);
|
|
||||||
assert_eq!(operation.draft.target.path_template, "/v2/latest");
|
|
||||||
assert_eq!(operation.draft.input_mapping.rules.len(), 2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn previews_swagger2_and_resolves_definitions() {
|
fn normalizes_supported_versions_with_canonical_order_and_stable_ids() {
|
||||||
let preview = preview_document(SWAGGER2).unwrap();
|
let fixture_matrix = [
|
||||||
|
(include_str!("fixtures/openapi-3.0.yaml"), "3.0.3"),
|
||||||
assert_eq!(preview.source.format, "swagger");
|
(include_str!("fixtures/openapi-3.1.json"), "3.1.0"),
|
||||||
|
(include_str!("fixtures/swagger-2.0.yaml"), "2.0"),
|
||||||
|
];
|
||||||
|
for (fixture, version) in fixture_matrix {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
preview.source.servers,
|
normalize_document(fixture, &NormalizationConfig::default())
|
||||||
vec!["https://petstore.example.com/api"]
|
.unwrap()
|
||||||
);
|
.source
|
||||||
let operation = &preview.groups[0].operations[0];
|
.version
|
||||||
assert_eq!(operation.suggested_name, "get_pet");
|
.as_deref(),
|
||||||
assert_eq!(operation.input_fields, 1);
|
Some(version)
|
||||||
assert_eq!(operation.output_fields, 2);
|
|
||||||
assert_eq!(operation.draft.target.path_template, "/pets/{id}");
|
|
||||||
assert_eq!(
|
|
||||||
operation.draft.input_mapping.rules[0].target,
|
|
||||||
"$.request.path.id"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let openapi_31_json = r#"{
|
||||||
#[test]
|
"openapi":"3.1.0", "info":{"title":"Canonical"},
|
||||||
fn reports_missing_descriptions_as_recommendations() {
|
"paths": {
|
||||||
let document = r#"
|
"/z":{"post":{"responses":{"201":{"description":"ok"}}}},
|
||||||
|
"/a":{"get":{"responses":{"200":{"description":"ok"}}}}
|
||||||
|
}
|
||||||
|
}"#;
|
||||||
|
let openapi_30_yaml = r#"
|
||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info: { title: Minimal API }
|
info: { title: Canonical }
|
||||||
paths:
|
paths:
|
||||||
/items:
|
/a:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses: { '200': { description: ok } }
|
||||||
'204': { description: Empty }
|
"#;
|
||||||
|
let swagger = r#"
|
||||||
|
swagger: '2.0'
|
||||||
|
info: { title: Legacy }
|
||||||
|
paths:
|
||||||
|
/a:
|
||||||
|
get:
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
let preview = preview_document(document).unwrap();
|
let first = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap();
|
||||||
let operation = &preview.groups[0].operations[0];
|
let second = normalize_document(openapi_31_json, &NormalizationConfig::default()).unwrap();
|
||||||
let codes = operation
|
assert_eq!(
|
||||||
|
serde_json::to_vec(&first).unwrap(),
|
||||||
|
serde_json::to_vec(&second).unwrap()
|
||||||
|
);
|
||||||
|
assert_eq!(first.operations[0].path, "/a");
|
||||||
|
assert_eq!(first.operations[0].stable_id, "3.1.0:get:/paths/~1a/get");
|
||||||
|
assert!(first.coverage.len() >= 2);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(openapi_30_yaml, &NormalizationConfig::default())
|
||||||
|
.unwrap()
|
||||||
|
.source
|
||||||
|
.version
|
||||||
|
.as_deref(),
|
||||||
|
Some("3.0.3")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(swagger, &NormalizationConfig::default())
|
||||||
|
.unwrap()
|
||||||
|
.source
|
||||||
|
.version
|
||||||
|
.as_deref(),
|
||||||
|
Some("2.0")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_references_typed_without_resolving_them() {
|
||||||
|
let ir = normalize_document(SWAGGER2, &NormalizationConfig::default()).unwrap();
|
||||||
|
let operation = &ir.operations[0];
|
||||||
|
assert!(matches!(
|
||||||
|
operation
|
||||||
|
.response_schema
|
||||||
|
.as_ref()
|
||||||
|
.map(|schema| &schema.kind),
|
||||||
|
Some(crank_import::rest::NormalizedSchemaKind::Reference { .. })
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
operation
|
||||||
.findings
|
.findings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|finding| finding.code.as_str())
|
.any(|finding| finding.code == "unresolved_reference")
|
||||||
.collect::<Vec<_>>();
|
);
|
||||||
|
|
||||||
assert!(codes.contains(&"missing_operation_id"));
|
|
||||||
assert!(codes.contains(&"missing_summary"));
|
|
||||||
assert!(codes.contains(&"missing_description"));
|
|
||||||
assert!(codes.contains(&"missing_response_schema"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn expands_json_request_body_object_into_tool_inputs() {
|
fn preserves_valid_operations_when_another_path_item_is_malformed() {
|
||||||
let document = r#"
|
let document = r#"
|
||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info: { title: CRM API }
|
info: { title: Partial }
|
||||||
servers:
|
|
||||||
- url: https://crm.example.test
|
|
||||||
paths:
|
paths:
|
||||||
/leads:
|
/valid:
|
||||||
post:
|
get:
|
||||||
operationId: createLead
|
responses: { '200': { description: ok } }
|
||||||
summary: Создать лид
|
/broken: invalid
|
||||||
description: Создает лид в CRM.
|
"#;
|
||||||
tags: [crm]
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
requestBody:
|
assert_eq!(ir.operations.len(), 1);
|
||||||
required: true
|
assert!(ir.findings.iter().any(|finding| {
|
||||||
content:
|
finding.code == "invalid_path_item"
|
||||||
application/json:
|
&& finding.severity == crank_import::rest::ImportFindingSeverity::Error
|
||||||
schema:
|
}));
|
||||||
type: object
|
assert!(ir.findings.iter().any(|finding| {
|
||||||
required: [email]
|
finding.code == "invalid_path_item"
|
||||||
properties:
|
&& finding.location.pointer == "/paths/~1broken"
|
||||||
email: { type: string }
|
&& finding.construct_id.contains("/paths/~1broken")
|
||||||
name: { type: string }
|
}));
|
||||||
responses:
|
}
|
||||||
'201':
|
|
||||||
description: Created
|
#[test]
|
||||||
content:
|
fn malformed_method_values_are_blockers_while_valid_siblings_survive() {
|
||||||
application/json:
|
for (format, document) in [
|
||||||
schema:
|
(
|
||||||
type: object
|
"openapi",
|
||||||
properties:
|
"openapi: 3.0.3\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }",
|
||||||
id: { type: string }
|
),
|
||||||
"#;
|
(
|
||||||
|
"swagger",
|
||||||
let preview = preview_document(document).unwrap();
|
"swagger: '2.0'\ninfo: { title: malformed }\npaths: { /mixed: { get: { responses: { '200': { description: ok } } }, post: broken } }",
|
||||||
let operation = &preview.groups[0].operations[0];
|
),
|
||||||
let input_fields = &operation.draft.input_schema.fields;
|
] {
|
||||||
let targets = operation
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
.draft
|
assert_eq!(ir.operations.len(), 1, "{format}");
|
||||||
.input_mapping
|
assert_eq!(ir.operations[0].key, "GET /mixed", "{format}");
|
||||||
.rules
|
let finding = ir
|
||||||
.iter()
|
.findings
|
||||||
.map(|rule| rule.target.as_str())
|
.iter()
|
||||||
.collect::<Vec<_>>();
|
.find(|finding| finding.code == "invalid_operation")
|
||||||
|
.unwrap();
|
||||||
assert!(input_fields.contains_key("email"));
|
assert_eq!(finding.severity, ImportFindingSeverity::Error, "{format}");
|
||||||
assert!(input_fields.contains_key("name"));
|
assert_eq!(finding.location.pointer, "/paths/~1mixed/post", "{format}");
|
||||||
assert!(targets.contains(&"$.request.body.email"));
|
}
|
||||||
assert!(targets.contains(&"$.request.body.name"));
|
}
|
||||||
}
|
|
||||||
|
#[test]
|
||||||
#[test]
|
fn rejects_hostile_input_at_configured_limits() {
|
||||||
fn reports_tool_quality_recommendations_for_imported_operations() {
|
let document = r#"
|
||||||
let document = r#"
|
openapi: 3.0.3
|
||||||
openapi: 3.0.3
|
info: { title: Too deep }
|
||||||
info: { title: Wide API }
|
paths: {}
|
||||||
paths:
|
"#;
|
||||||
/items:
|
let config = NormalizationConfig {
|
||||||
|
max_bytes: 10,
|
||||||
|
..NormalizationConfig::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(document, &config),
|
||||||
|
Err(ImportParseError::LimitExceeded)
|
||||||
|
);
|
||||||
|
|
||||||
|
let unsupported = "openapi: 3.2.0\ninfo: { title: Nope }\npaths: {}\n";
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(unsupported, &NormalizationConfig::default()),
|
||||||
|
Err(ImportParseError::UnsupportedDocument)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn checked_in_hostile_regressions_are_bounded_and_redacted() {
|
||||||
|
// Minimal regression corpus distilled from malformed/fuzzed inputs.
|
||||||
|
// Assertions deliberately compare typed errors, never parser text.
|
||||||
|
for document in [
|
||||||
|
include_str!("fixtures/fuzz-regressions/invalid-root.json"),
|
||||||
|
include_str!("fixtures/fuzz-regressions/unsupported-version.yaml"),
|
||||||
|
include_str!("fixtures/fuzz-regressions/wrong-version-shape.yaml"),
|
||||||
|
] {
|
||||||
|
let error = normalize_document(document, &NormalizationConfig::default()).unwrap_err();
|
||||||
|
assert!(matches!(
|
||||||
|
error,
|
||||||
|
ImportParseError::InvalidDocument | ImportParseError::UnsupportedDocument
|
||||||
|
));
|
||||||
|
assert!(!error.to_string().contains("wrong-root-field"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verified_digest_stays_inside_ir_and_unknown_contract_fails_closed() {
|
||||||
|
let digest = crank_import::rest::SourceDigest::parse("a".repeat(64)).unwrap();
|
||||||
|
let ir = crank_import::rest::normalize_verified_document(
|
||||||
|
include_str!("fixtures/openapi-3.1.json"),
|
||||||
|
digest,
|
||||||
|
&NormalizationConfig::default(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ir.source_identity.digest.as_str(), "a".repeat(64));
|
||||||
|
let preview = crank_import::rest::preview_from_ir(&ir);
|
||||||
|
assert!(
|
||||||
|
!serde_json::to_string(&preview)
|
||||||
|
.unwrap()
|
||||||
|
.contains("source_identity")
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!serde_json::to_string(&preview)
|
||||||
|
.unwrap()
|
||||||
|
.contains(&"a".repeat(64))
|
||||||
|
);
|
||||||
|
let config = NormalizationConfig {
|
||||||
|
normalizer_version: "future".to_owned(),
|
||||||
|
..NormalizationConfig::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(include_str!("fixtures/openapi-3.1.json"), &config),
|
||||||
|
Err(ImportParseError::UnsupportedDocument)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yaml_alias_preflight_counts_inline_aliases_but_not_quotes_or_comments() {
|
||||||
|
let mut document =
|
||||||
|
"openapi: 3.0.3\ninfo: { title: aliases }\npaths: {}\nitems: [".to_owned();
|
||||||
|
document.push_str(
|
||||||
|
&std::iter::repeat_n("*a", 129)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
);
|
||||||
|
document.push(']');
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(&document, &NormalizationConfig::default()),
|
||||||
|
Err(ImportParseError::LimitExceeded)
|
||||||
|
);
|
||||||
|
let harmless = "openapi: 3.0.3\ninfo: { title: '*not_alias' } # *also_not_alias\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }";
|
||||||
|
assert!(normalize_document(harmless, &NormalizationConfig::default()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yaml_alias_preflight_keeps_quote_state_after_escaped_and_doubled_quotes() {
|
||||||
|
let mut escaped_quote = String::from(
|
||||||
|
"openapi: 3.0.3\ninfo:\n title: \"escaped \\\" quote\"\npaths: {}\nitems: [",
|
||||||
|
);
|
||||||
|
escaped_quote.push_str(
|
||||||
|
&std::iter::repeat_n("*alias", 129)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
);
|
||||||
|
escaped_quote.push(']');
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(&escaped_quote, &NormalizationConfig::default()),
|
||||||
|
Err(ImportParseError::LimitExceeded)
|
||||||
|
);
|
||||||
|
|
||||||
|
let harmless = r#"openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: 'it''s *not_an_alias'
|
||||||
|
description: "escaped \" *also_not_an_alias"
|
||||||
|
paths:
|
||||||
|
/ok:
|
||||||
|
get:
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
assert!(normalize_document(harmless, &NormalizationConfig::default()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yaml_alias_preflight_fails_closed_for_stars_in_block_scalars() {
|
||||||
|
let hidden_alias = r#"openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Block scalar
|
||||||
|
description: |
|
||||||
|
this text contains *an_alias_like_token
|
||||||
|
paths:
|
||||||
|
/ok:
|
||||||
|
get:
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(hidden_alias, &NormalizationConfig::default()),
|
||||||
|
Err(ImportParseError::LimitExceeded)
|
||||||
|
);
|
||||||
|
|
||||||
|
let safe_block = r#"openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: Block scalar
|
||||||
|
description: |
|
||||||
|
this text contains no alias-looking marker
|
||||||
|
paths:
|
||||||
|
/ok:
|
||||||
|
get:
|
||||||
|
responses: { '200': { description: ok } }
|
||||||
|
"#;
|
||||||
|
assert!(normalize_document(safe_block, &NormalizationConfig::default()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_scalar_literals_round_trip_without_stringification() {
|
||||||
|
let document = r#"
|
||||||
|
openapi: 3.1.0
|
||||||
|
info: { title: Scalars }
|
||||||
|
paths:
|
||||||
|
/value:
|
||||||
get:
|
get:
|
||||||
operationId: getItems
|
|
||||||
summary: Get items
|
|
||||||
description: Get items.
|
|
||||||
parameters:
|
|
||||||
- name: page
|
|
||||||
in: query
|
|
||||||
schema: { type: integer }
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: ok
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
field01: { type: string }
|
count: { type: integer, default: 7, enum: [1, 2] }
|
||||||
field02: { type: string }
|
enabled: { type: boolean, default: true, enum: [true, false] }
|
||||||
field03: { type: string }
|
ratio: { type: number, default: 1.5, enum: [0.5, 1.5] }
|
||||||
field04: { type: string }
|
|
||||||
field05: { type: string }
|
|
||||||
field06: { type: string }
|
|
||||||
field07: { type: string }
|
|
||||||
field08: { type: string }
|
|
||||||
field09: { type: string }
|
|
||||||
field10: { type: string }
|
|
||||||
field11: { type: string }
|
|
||||||
field12: { type: string }
|
|
||||||
field13: { type: string }
|
|
||||||
"#;
|
"#;
|
||||||
|
let ir = normalize_document(document, &NormalizationConfig::default()).unwrap();
|
||||||
let preview = preview_document(document).unwrap();
|
let preview = preview_document(document).unwrap();
|
||||||
let operation = &preview.groups[0].operations[0];
|
let fields = &preview.groups[0].operations[0].draft.output_schema.fields;
|
||||||
let codes = operation
|
assert_eq!(fields["count"].kind, crank_schema::SchemaKind::Integer);
|
||||||
|
assert_eq!(fields["enabled"].kind, crank_schema::SchemaKind::Boolean);
|
||||||
|
let rendered = serde_json::to_value(&ir.operations[0].response_schema).unwrap();
|
||||||
|
assert!(rendered.to_string().contains("integer"));
|
||||||
|
assert!(rendered.to_string().contains("1.5"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn coverage_validator_rejects_nested_gap_and_duplicate_findings() {
|
||||||
|
let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap();
|
||||||
|
let nested = ir.operations[0]
|
||||||
|
.response_schema
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.construct_id
|
||||||
|
.clone();
|
||||||
|
ir.coverage.retain(|entry| entry.construct_id != nested);
|
||||||
|
assert_eq!(
|
||||||
|
crank_import::rest::validate_normalized_ir(&ir),
|
||||||
|
Err(ImportParseError::InvalidDocument)
|
||||||
|
);
|
||||||
|
let mut ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap();
|
||||||
|
ir.coverage
|
||||||
|
.retain(|entry| entry.construct_id != "source:/paths/~1ok/get/responses/200");
|
||||||
|
assert_eq!(
|
||||||
|
crank_import::rest::validate_normalized_ir(&ir),
|
||||||
|
Err(ImportParseError::InvalidDocument)
|
||||||
|
);
|
||||||
|
let ir = normalize_document(&matrix_source(), &NormalizationConfig::default()).unwrap();
|
||||||
|
let findings = &ir.operations[0].findings;
|
||||||
|
assert_eq!(
|
||||||
|
findings.len(),
|
||||||
|
findings
|
||||||
|
.iter()
|
||||||
|
.map(|finding| (&finding.code, &finding.construct_id))
|
||||||
|
.collect::<std::collections::BTreeSet<_>>()
|
||||||
|
.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_malformed_paths_keep_distinct_internal_locations() {
|
||||||
|
let ir = normalize_document(
|
||||||
|
"openapi: 3.0.3\ninfo: { title: broken }\npaths: { /one: bad, /two: bad }",
|
||||||
|
&NormalizationConfig::default(),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(ir, ImportParseError::NoMethods);
|
||||||
|
// Structural parser findings require at least one valid operation to return IR.
|
||||||
|
let ir = normalize_document("openapi: 3.0.3\ninfo: { title: broken }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } }, /one: bad, /two: bad }", &NormalizationConfig::default()).unwrap();
|
||||||
|
let pointers = ir
|
||||||
.findings
|
.findings
|
||||||
.iter()
|
.iter()
|
||||||
.map(|finding| finding.code.as_str())
|
.filter(|finding| finding.code == "invalid_path_item")
|
||||||
.collect::<Vec<_>>();
|
.map(|finding| finding.location.pointer.as_str())
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
pointers,
|
||||||
|
std::collections::BTreeSet::from(["/paths/~1one", "/paths/~1two"])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
assert!(codes.contains(&"parameter_descriptions_missing"));
|
#[test]
|
||||||
assert!(codes.contains(&"weak_tool_description"));
|
fn canonical_matrix_covers_all_supported_versions_syntaxes_and_concurrency() {
|
||||||
assert!(codes.contains(&"weak_tool_name"));
|
let matrix = [
|
||||||
assert!(codes.contains(&"too_many_output_fields"));
|
(
|
||||||
|
"oas30-yaml",
|
||||||
|
"openapi: 3.0.3\ninfo: { title: OAS30 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oas30-json",
|
||||||
|
r#"{"openapi":"3.0.3","info":{"title":"OAS30"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oas31-yaml",
|
||||||
|
"openapi: 3.1.0\ninfo: { title: OAS31 }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"oas31-json",
|
||||||
|
r#"{"openapi":"3.1.0","info":{"title":"OAS31"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"swagger-yaml",
|
||||||
|
"swagger: '2.0'\ninfo: { title: Swagger }\npaths: { /ok: { get: { responses: { '200': { description: ok } } } } }",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"swagger-json",
|
||||||
|
r#"{"swagger":"2.0","info":{"title":"Swagger"},"paths":{"/ok":{"get":{"responses":{"200":{"description":"ok"}}}}}}"#,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (name, source) in matrix {
|
||||||
|
let first = normalize_document(source, &NormalizationConfig::default()).unwrap();
|
||||||
|
let second = normalize_document(source, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_vec(&first).unwrap(),
|
||||||
|
serde_json::to_vec(&second).unwrap(),
|
||||||
|
"{name}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let source = std::sync::Arc::new(matrix_source());
|
||||||
|
let results = (0..8)
|
||||||
|
.map(|_| {
|
||||||
|
let source = std::sync::Arc::clone(&source);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
serde_json::to_vec(
|
||||||
|
&normalize_document(&source, &NormalizationConfig::default()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.map(|thread| thread.join().unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert!(results.windows(2).all(|pair| pair[0] == pair[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_small_path_order_permutation_has_the_same_canonical_ir() {
|
||||||
|
let paths = [
|
||||||
|
("/a", "get", "200"),
|
||||||
|
("/b", "post", "201"),
|
||||||
|
("/c", "delete", "204"),
|
||||||
|
];
|
||||||
|
let permutations = [
|
||||||
|
[0, 1, 2],
|
||||||
|
[0, 2, 1],
|
||||||
|
[1, 0, 2],
|
||||||
|
[1, 2, 0],
|
||||||
|
[2, 0, 1],
|
||||||
|
[2, 1, 0],
|
||||||
|
];
|
||||||
|
let mut canonical = None;
|
||||||
|
for permutation in permutations {
|
||||||
|
let path_entries = permutation
|
||||||
|
.into_iter()
|
||||||
|
.map(|index| {
|
||||||
|
let (path, method, status) = paths[index];
|
||||||
|
format!(
|
||||||
|
"\"{path}\":{{\"{method}\":{{\"responses\":{{\"{status}\":{{\"description\":\"ok\"}}}}}}}}"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(",");
|
||||||
|
let source = format!(
|
||||||
|
"{{\"openapi\":\"3.1.0\",\"info\":{{\"title\":\"Permutation\"}},\"paths\":{{{path_entries}}}}}"
|
||||||
|
);
|
||||||
|
let bytes = serde_json::to_vec(
|
||||||
|
&normalize_document(&source, &NormalizationConfig::default()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
if let Some(expected) = &canonical {
|
||||||
|
assert_eq!(&bytes, expected);
|
||||||
|
} else {
|
||||||
|
canonical = Some(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn limit_and_reference_regression_matrix_is_bounded_and_typed() {
|
||||||
|
let source = matrix_source();
|
||||||
|
for config in [
|
||||||
|
NormalizationConfig {
|
||||||
|
max_bytes: 1,
|
||||||
|
..NormalizationConfig::default()
|
||||||
|
},
|
||||||
|
NormalizationConfig {
|
||||||
|
max_depth: 1,
|
||||||
|
..NormalizationConfig::default()
|
||||||
|
},
|
||||||
|
NormalizationConfig {
|
||||||
|
max_nodes: 1,
|
||||||
|
..NormalizationConfig::default()
|
||||||
|
},
|
||||||
|
NormalizationConfig {
|
||||||
|
max_collection_items: 1,
|
||||||
|
..NormalizationConfig::default()
|
||||||
|
},
|
||||||
|
NormalizationConfig {
|
||||||
|
max_scalar_bytes: 1,
|
||||||
|
..NormalizationConfig::default()
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(&source, &config),
|
||||||
|
Err(ImportParseError::LimitExceeded)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document(
|
||||||
|
"openapi: 3.0.3\ninfo: { title: none }\npaths: {}",
|
||||||
|
&NormalizationConfig::default()
|
||||||
|
),
|
||||||
|
Err(ImportParseError::NoMethods)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_document("[]", &NormalizationConfig::default()),
|
||||||
|
Err(ImportParseError::UnsupportedDocument)
|
||||||
|
);
|
||||||
|
|
||||||
|
for reference in [
|
||||||
|
"#/components/schemas/Loop",
|
||||||
|
"https://example.test/schema.json",
|
||||||
|
"#/components/schemas/Loop",
|
||||||
|
] {
|
||||||
|
let document = format!(
|
||||||
|
"openapi: 3.0.3\ninfo: {{ title: refs }}\npaths:\n /ok:\n get:\n responses:\n '200': {{ description: ok, content: {{ application/json: {{ schema: {{ $ref: '{reference}' }} }} }} }}"
|
||||||
|
);
|
||||||
|
let ir = normalize_document(&document, &NormalizationConfig::default()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
ir.operations[0]
|
||||||
|
.response_schema
|
||||||
|
.as_ref()
|
||||||
|
.map(|schema| &schema.kind),
|
||||||
|
Some(crank_import::rest::NormalizedSchemaKind::Reference { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user