feat(import): add deterministic OpenAPI normalized IR
CI / Rust Checks (push) Failing after 3m12s
CI / UI Checks (push) Has been skipped
CI / Frontend E2E (push) Has been skipped
CI / Community Image Smoke (push) Has been skipped
CI / Deploy (push) Has been skipped

This commit is contained in:
2026-08-29 04:15:37 +03:00
parent bc03c33387
commit 6c2a3712d8
22 changed files with 6051 additions and 352 deletions
+12 -3
View File
@@ -2,6 +2,9 @@ mod mapping;
pub mod model;
mod naming;
mod normalize;
mod normalize_coverage;
mod normalize_limits;
mod normalize_schema;
mod openapi3;
mod payload;
mod recommendations;
@@ -10,8 +13,14 @@ mod swagger2;
pub use model::{
ImportFinding, ImportFindingSeverity, ImportGroupPreview, ImportOperationCandidate,
ImportPreview, ImportSourcePreview, RestImportCandidate, RestImportDocument,
RestImportOperation, RestImportParameter, RestParameterLocation,
ImportPreview, ImportSourcePreview, NORMALIZER_VERSION, NormalizationConfig, NormalizedFinding,
NormalizedIr, NormalizedOperation, NormalizedParameter, NormalizedReference, NormalizedSchema,
NormalizedSchemaKind, PROJECTION_VERSION, RestImportCandidate, RestImportDocument,
RestImportOperation, RestImportParameter, RestParameterLocation, SourceDigest, SourceIdentity,
SourceLocation, UnresolvedReference,
};
pub use normalize::{
ImportParseError, normalize_verified_document, preview_document, preview_document_legacy_v1,
preview_from_ir, validate_normalized_ir,
};
pub use normalize::preview_document;
pub use payload::operation_draft_from_candidate;
+307 -5
View File
@@ -1,9 +1,294 @@
use std::collections::BTreeMap;
use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState};
use crank_mapping::MappingSet;
use crank_schema::Schema;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, de};
use serde_json::Value;
/// The immutable contract used to normalize an OpenAPI source. These names
/// deliberately travel with an import job: changing either contract must not
/// silently reinterpret a pending preview.
pub const NORMALIZER_VERSION: &str = "normalized-ir-v2";
pub const PROJECTION_VERSION: &str = "preview-v2";
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct SourceDigest(String);
impl SourceDigest {
pub fn parse(value: impl Into<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)]
#[serde(rename_all = "snake_case")]
pub enum ImportFindingSeverity {
@@ -80,7 +365,7 @@ pub struct RestImportCandidate {
pub wizard_state: Option<WizardState>,
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RestImportDocument {
pub format: String,
pub version: Option<String>,
@@ -88,9 +373,11 @@ pub struct RestImportDocument {
pub servers: Vec<String>,
pub operations: Vec<RestImportOperation>,
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 key: String,
pub method: HttpMethod,
@@ -101,21 +388,36 @@ pub struct RestImportOperation {
pub tags: Vec<String>,
pub parameters: Vec<RestImportParameter>,
pub request_body_schema: Option<Value>,
#[serde(skip)]
pub request_body_schema_location: Option<SourceLocation>,
pub response_schema: Option<Value>,
#[serde(skip)]
pub response_schema_location: Option<SourceLocation>,
pub servers: Vec<String>,
pub findings: Vec<ImportFinding>,
}
#[derive(Clone, Debug, PartialEq)]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RestImportParameter {
pub name: String,
pub location: RestParameterLocation,
pub required: bool,
pub description: Option<String>,
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 {
Path,
Query,
+787 -37
View File
@@ -4,43 +4,156 @@ use serde_json::Value;
use thiserror::Error;
use crate::rest::{
model::{ImportGroupPreview, ImportPreview, RestImportDocument},
model::{
CoverageDisposition, CoverageEntry, ImportFinding, ImportFindingSeverity,
ImportGroupPreview, ImportPreview, ImportSourcePreview, NORMALIZER_VERSION,
NormalizationConfig, NormalizedApiMetadata, NormalizedFinding, NormalizedIr,
NormalizedLiteral, NormalizedOperation, NormalizedParameter, NormalizedScalarKind,
NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION, RestImportDocument,
RestImportOperation, RestImportParameter, SourceDigest, SourceIdentity, SourceLocation,
SourceSyntax,
},
openapi3,
payload::candidate_from_operation,
swagger2,
};
#[derive(Debug, Error)]
use super::normalize_coverage;
use super::normalize_limits;
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ImportParseError {
#[error("document is not valid YAML or JSON: {0}")]
InvalidDocument(String),
#[error("document is not valid YAML or JSON")]
InvalidDocument,
#[error("unsupported OpenAPI document")]
UnsupportedDocument,
#[error("OpenAPI document exceeds normalization limits")]
LimitExceeded,
#[error("OpenAPI document contains no supported operations")]
NoMethods,
}
pub fn preview_document(document: &str) -> Result<ImportPreview, ImportParseError> {
let yaml: serde_yaml::Value = serde_yaml::from_str(document)
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?;
let root = serde_json::to_value(yaml)
.map_err(|error| ImportParseError::InvalidDocument(error.to_string()))?;
let digest = SourceDigest::parse("0".repeat(64)).expect("fixed digest is valid");
let ir = normalize_verified_document(document, digest, &NormalizationConfig::default())?;
Ok(preview_from_ir(&ir))
}
let normalized = if root.get("openapi").is_some() {
openapi3::parse_document(&root)?
} else if root.get("swagger").and_then(Value::as_str) == Some("2.0") {
swagger2::parse_document(&root)?
/// Compatibility projection for jobs created before `NormalizedIr`. It is
/// deliberately isolated from the v2 pipeline and may be removed only after
/// the import-job TTL has elapsed.
pub fn preview_document_legacy_v1(document: &str) -> Result<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 {
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 {
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 used_names = BTreeSet::new();
for operation in &document.operations {
let candidate = candidate_from_operation(operation, &document.servers, &mut used_names);
for operation in operations {
let candidate = candidate_from_operation(operation, &source.servers, &mut used_names);
let group_title = operation
.tags
.first()
@@ -58,36 +171,674 @@ fn preview_from_document(document: RestImportDocument) -> ImportPreview {
.push(candidate);
}
sort_findings(&mut findings);
ImportPreview {
source: crate::rest::model::ImportSourcePreview {
format: document.format,
version: document.version,
title: document.title,
servers: document.servers,
},
source: source.clone(),
groups: groups.into_values().collect(),
findings: document.findings,
findings,
}
}
fn decode(document: &str) -> Result<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,
version: document.version,
title: document.title,
servers: document.servers,
};
let version = source.version.as_deref().unwrap_or("unknown");
let mut coverage = Vec::new();
let source_tree = normalize_coverage::canonical_source_tree(&root, "", &mut coverage);
let unresolved_references = normalize_coverage::references_from_source_tree(&source_tree);
coverage.extend(unresolved_references.iter().map(|reference| CoverageEntry {
construct_id: reference.construct_id.clone(),
location: reference.location.clone(),
disposition: CoverageDisposition::Mapped,
}));
coverage.push(CoverageEntry {
construct_id: "document".to_owned(),
location: SourceLocation {
pointer: String::new(),
},
disposition: CoverageDisposition::Mapped,
});
coverage.extend(normalize_coverage::server_coverage(
&source,
&source_tree,
version,
)?);
let mut covered_paths = BTreeSet::new();
let mut covered_parameters = BTreeSet::new();
let internal_finding_locations = document.internal_finding_locations.clone();
let mut findings = document
.findings
.into_iter()
.enumerate()
.map(|(index, finding)| {
let location = internal_finding_locations.get(index).cloned().flatten();
NormalizedFinding {
code: finding.code.clone(),
severity: finding.severity,
message: finding.message,
construct_id: location
.as_ref()
.map(|location| {
if location.pointer.is_empty() {
"document".to_owned()
} else {
format!("{version}:{}", location.pointer)
}
})
.unwrap_or_else(|| "document".to_owned()),
location: location.unwrap_or(SourceLocation {
pointer: String::new(),
}),
operation_key: finding.operation_key,
}
})
.collect::<Vec<_>>();
let mut operations = document
.operations
.into_iter()
.map(|operation| {
let method = method_name(operation.method).to_ascii_lowercase();
let pointer = format!(
"/paths/{}/{}",
normalize_coverage::escape_pointer(&operation.path),
method
);
let stable_id = format!("{version}:{method}:{pointer}");
let location = SourceLocation {
pointer: pointer.clone(),
};
let mut operation_findings = Vec::new();
if covered_paths.insert(operation.path.clone()) {
coverage.push(CoverageEntry {
construct_id: format!("{version}:path:{}", operation.path),
location: SourceLocation {
pointer: format!(
"/paths/{}",
normalize_coverage::escape_pointer(&operation.path)
),
},
disposition: CoverageDisposition::Mapped,
});
}
if operation
.operation_id
.as_deref()
.is_some_and(|value| !value.trim().is_empty())
{
coverage.push(CoverageEntry {
construct_id: format!("{stable_id}:operation_id"),
location: SourceLocation {
pointer: format!("{pointer}/operationId"),
},
disposition: CoverageDisposition::Mapped,
});
} else {
operation_findings.push(NormalizedFinding {
code: "missing_operation_id".to_owned(),
severity: ImportFindingSeverity::Warning,
message: "У метода нет operationId.".to_owned(),
construct_id: format!("{stable_id}:operation_id"),
location: SourceLocation {
pointer: format!("{pointer}/operationId"),
},
operation_key: Some(operation.key.clone()),
});
coverage.push(CoverageEntry {
construct_id: format!("{stable_id}:operation_id"),
location: SourceLocation {
pointer: format!("{pointer}/operationId"),
},
disposition: CoverageDisposition::Finding,
});
}
for (index, location) in
normalize_coverage::tag_locations(&root, &pointer, &operation.tags)?
.into_iter()
.enumerate()
{
coverage.push(CoverageEntry {
construct_id: format!("{stable_id}:tag:{index}"),
location,
disposition: CoverageDisposition::Mapped,
});
}
operation_findings.extend(operation.findings.iter().map(|finding| NormalizedFinding {
code: finding.code.clone(),
severity: finding.severity.clone(),
message: finding.message.clone(),
construct_id: stable_id.clone(),
location: location.clone(),
operation_key: Some(operation.key.clone()),
}));
let parameters = operation
.parameters
.into_iter()
.map(|parameter| {
let parameter_pointer = parameter.source_location.pointer;
let parameter_id = format!(
"parameter:{}",
normalize_coverage::escape_pointer(&parameter_pointer)
);
if covered_parameters.insert(parameter_id.clone()) {
coverage.push(CoverageEntry {
construct_id: parameter_id.clone(),
location: SourceLocation {
pointer: parameter_pointer.clone(),
},
disposition: CoverageDisposition::Mapped,
});
}
NormalizedParameter {
name: parameter.name,
location: parameter.location,
required: parameter.required,
description: parameter.description,
schema: parameter.schema.as_ref().map(|schema| {
normalize_coverage::typed_schema(
schema,
parameter
.schema_source_location
.as_ref()
.map(|location| location.pointer.as_str())
.unwrap_or(&format!("{parameter_pointer}/schema")),
&format!("{parameter_id}:schema"),
&mut coverage,
)
}),
construct_id: parameter_id,
source_location: SourceLocation {
pointer: parameter_pointer,
},
}
})
.collect::<Vec<_>>();
let request_body_schema = operation.request_body_schema.as_ref().map(|schema| {
let schema_location = operation
.request_body_schema_location
.as_ref()
.cloned()
.unwrap_or(SourceLocation {
pointer: format!("{pointer}/requestBody"),
});
normalize_coverage::typed_schema(
schema,
&schema_location.pointer,
&format!(
"{stable_id}:request:{}",
normalize_coverage::escape_pointer(&schema_location.pointer)
),
&mut coverage,
)
});
let response_schema = operation.response_schema.as_ref().map(|schema| {
let schema_location = operation
.response_schema_location
.as_ref()
.cloned()
.unwrap_or(SourceLocation {
pointer: format!("{pointer}/responses"),
});
normalize_coverage::typed_schema(
schema,
&schema_location.pointer,
&format!(
"{stable_id}:response:{}",
normalize_coverage::escape_pointer(&schema_location.pointer)
),
&mut coverage,
)
});
if normalize_coverage::has_reference_schema(request_body_schema.as_ref())
|| normalize_coverage::has_reference_schema(response_schema.as_ref())
|| parameters.iter().any(|parameter| {
normalize_coverage::has_reference_schema(parameter.schema.as_ref())
})
{
operation_findings.push(NormalizedFinding {
code: "unresolved_reference".to_owned(),
severity: ImportFindingSeverity::Error,
message: "Ссылка сохранена в NormalizedIR и будет разрешена отдельным этапом."
.to_owned(),
construct_id: stable_id.clone(),
location: location.clone(),
operation_key: Some(operation.key.clone()),
});
}
coverage.push(CoverageEntry {
construct_id: stable_id.clone(),
location: location.clone(),
disposition: CoverageDisposition::Mapped,
});
Ok::<_, ImportParseError>(NormalizedOperation {
stable_id,
location,
key: operation.key,
method: operation.method,
path: operation.path,
operation_id: operation.operation_id,
summary: operation.summary,
description: operation.description,
tags: operation.tags,
parameters,
request_body_schema,
response_schema,
servers: operation.servers,
findings: operation_findings,
})
})
.collect::<Result<Vec<_>, _>>()?;
operations.sort_by(|left, right| {
(
left.path.as_str(),
method_rank(left.method),
left.location.pointer.as_str(),
)
.cmp(&(
right.path.as_str(),
method_rank(right.method),
right.location.pointer.as_str(),
))
});
for operation in &mut operations {
sort_normalized_findings(&mut operation.findings);
}
let mut operation_id_counts = BTreeMap::new();
for operation in &operations {
if let Some(operation_id) = operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
*operation_id_counts
.entry(operation_id.to_owned())
.or_insert(0usize) += 1;
}
}
for operation in &mut operations {
if let Some(operation_id) = operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
&& operation_id_counts[operation_id] > 1
{
operation.findings.push(NormalizedFinding {
code: "duplicate_operation_id".to_owned(),
severity: ImportFindingSeverity::Warning,
message: "operationId повторяется в документе.".to_owned(),
construct_id: format!("{}:operation_id", operation.stable_id),
location: SourceLocation {
pointer: format!("{}/operationId", operation.location.pointer),
},
operation_key: Some(operation.key.clone()),
});
}
sort_normalized_findings(&mut operation.findings);
}
// Resolution is deliberately deferred. References already represented by
// an operation schema use that operation's blocker; all other references
// need a document-level blocker so omitted object-level semantics can
// never reach Apply silently.
findings.extend(
unresolved_references
.iter()
.filter(|reference| {
!operations.iter().any(|operation| {
operation
.parameters
.iter()
.filter_map(|parameter| parameter.schema.as_ref())
.chain(operation.request_body_schema.iter())
.chain(operation.response_schema.iter())
.any(|schema| schema_contains_reference(schema, &reference.location))
})
})
.map(|reference| NormalizedFinding {
code: "unresolved_reference".to_owned(),
severity: ImportFindingSeverity::Error,
message: "Ссылка требует разрешения перед применением импорта.".to_owned(),
construct_id: reference.construct_id.clone(),
location: reference.location.clone(),
operation_key: None,
}),
);
sort_normalized_findings(&mut findings);
for finding in &findings {
if !coverage.iter().any(|entry| {
entry.construct_id == finding.construct_id
&& entry.location == finding.location
&& entry.disposition == CoverageDisposition::Mapped
}) {
coverage.push(CoverageEntry {
construct_id: finding.construct_id.clone(),
location: finding.location.clone(),
disposition: CoverageDisposition::Finding,
});
}
}
coverage.sort_by(|left, right| {
(
left.construct_id.as_str(),
left.location.pointer.as_str(),
coverage_disposition_rank(&left.disposition),
)
.cmp(&(
right.construct_id.as_str(),
right.location.pointer.as_str(),
coverage_disposition_rank(&right.disposition),
))
});
coverage.dedup();
let ir = NormalizedIr {
normalizer_version: config.normalizer_version.clone(),
projection_version: config.projection_version.clone(),
source_identity: SourceIdentity { digest },
source_syntax,
source_tree,
metadata: NormalizedApiMetadata {
title: source.title.clone(),
version: root
.pointer("/info/version")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: root
.pointer("/info/description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
},
base_path_candidates: root
.get("basePath")
.and_then(Value::as_str)
.map(|value| vec![value.to_owned()])
.unwrap_or_default(),
paths: normalize_coverage::paths_from_operations(&operations, version),
unresolved_references,
source,
operations,
findings,
coverage,
};
normalize_coverage::validate_coverage(&ir)?;
Ok(ir)
}
pub fn validate_normalized_ir(ir: &NormalizedIr) -> Result<(), ImportParseError> {
if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION {
return Err(ImportParseError::InvalidDocument);
}
normalize_coverage::validate_coverage(ir)
}
fn coverage_disposition_rank(disposition: &CoverageDisposition) -> u8 {
match disposition {
CoverageDisposition::Mapped => 0,
CoverageDisposition::Finding => 1,
}
}
fn legacy_operation_from_normalized(operation: &NormalizedOperation) -> RestImportOperation {
RestImportOperation {
key: operation.key.clone(),
method: operation.method,
path: operation.path.clone(),
operation_id: operation.operation_id.clone(),
summary: operation.summary.clone(),
description: operation.description.clone(),
tags: operation.tags.clone(),
parameters: operation
.parameters
.iter()
.map(|parameter| RestImportParameter {
name: parameter.name.clone(),
location: parameter.location,
required: parameter.required,
description: parameter.description.clone(),
schema: parameter.schema.as_ref().map(legacy_schema_value),
source_location: parameter.source_location.clone(),
schema_source_location: None,
})
.collect(),
request_body_schema: operation
.request_body_schema
.as_ref()
.map(legacy_schema_value),
request_body_schema_location: None,
response_schema: operation.response_schema.as_ref().map(legacy_schema_value),
response_schema_location: None,
servers: operation.servers.clone(),
findings: operation
.findings
.iter()
.map(|finding| ImportFinding {
code: finding.code.clone(),
severity: finding.severity.clone(),
message: finding.message.clone(),
operation_key: finding.operation_key.clone(),
})
.collect(),
}
}
fn legacy_schema_value(schema: &NormalizedSchema) -> Value {
let mut value = match &schema.kind {
NormalizedSchemaKind::Reference { reference } => serde_json::json!({"$ref": reference.uri}),
NormalizedSchemaKind::Composition { operator, variants } => {
serde_json::json!({operator: variants.iter().map(legacy_schema_value).collect::<Vec<_>>() })
}
NormalizedSchemaKind::Object {
properties,
required,
} => {
serde_json::json!({"type":"object", "properties": properties.iter().map(|(name, schema)| (name.clone(), legacy_schema_value(schema))).collect::<serde_json::Map<_, _>>(), "required": required})
}
NormalizedSchemaKind::Array { items } => {
let mut value = serde_json::json!({"type":"array"});
if let Some(items) = items {
value["items"] = legacy_schema_value(items);
}
value
}
NormalizedSchemaKind::Scalar {
scalar_type,
format,
nullable,
default_value,
enum_values,
} => {
let scalar_type = match scalar_type {
NormalizedScalarKind::String => "string",
NormalizedScalarKind::Integer => "integer",
NormalizedScalarKind::Number => "number",
NormalizedScalarKind::Boolean => "boolean",
NormalizedScalarKind::Null => "null",
};
serde_json::json!({"type":scalar_type, "format":format, "nullable":nullable, "default":default_value.as_ref().map(legacy_literal_value), "enum":enum_values.iter().map(legacy_literal_value).collect::<Vec<_>>()})
}
NormalizedSchemaKind::Unknown => serde_json::json!({}),
};
if let Some(description) = &schema.description {
value["description"] = Value::String(description.clone());
}
value
}
fn legacy_literal_value(value: &NormalizedLiteral) -> Value {
match value {
NormalizedLiteral::String(value) => Value::String(value.clone()),
NormalizedLiteral::Integer(value) => Value::Number((*value).into()),
NormalizedLiteral::Unsigned(value) => Value::Number((*value).into()),
NormalizedLiteral::Number(value) => serde_json::Number::from_f64(*value)
.map(Value::Number)
.unwrap_or(Value::Null),
NormalizedLiteral::Boolean(value) => Value::Bool(*value),
NormalizedLiteral::Null => Value::Null,
}
}
fn schema_contains_reference(schema: &NormalizedSchema, location: &SourceLocation) -> bool {
match &schema.kind {
NormalizedSchemaKind::Reference { reference } => reference.location == *location,
NormalizedSchemaKind::Object { properties, .. } => properties
.values()
.any(|schema| schema_contains_reference(schema, location)),
NormalizedSchemaKind::Array { items } => items
.as_deref()
.is_some_and(|schema| schema_contains_reference(schema, location)),
NormalizedSchemaKind::Composition { variants, .. } => variants
.iter()
.any(|schema| schema_contains_reference(schema, location)),
_ => false,
}
}
fn validate_value_limits(
value: &Value,
config: &NormalizationConfig,
) -> Result<(), ImportParseError> {
fn visit(
value: &Value,
depth: usize,
nodes: &mut usize,
config: &NormalizationConfig,
) -> Result<(), ImportParseError> {
*nodes += 1;
if depth > config.max_depth || *nodes > config.max_nodes {
return Err(ImportParseError::LimitExceeded);
}
match value {
Value::String(text) if text.len() > config.max_scalar_bytes => {
Err(ImportParseError::LimitExceeded)
}
Value::Array(items) => {
if items.len() > config.max_collection_items {
return Err(ImportParseError::LimitExceeded);
}
for item in items {
visit(item, depth + 1, nodes, config)?;
}
Ok(())
}
Value::Object(object) => {
if object.len() > config.max_collection_items {
return Err(ImportParseError::LimitExceeded);
}
for (key, item) in object {
if key.len() > config.max_scalar_bytes {
return Err(ImportParseError::LimitExceeded);
}
visit(item, depth + 1, nodes, config)?;
}
Ok(())
}
_ => Ok(()),
}
}
visit(value, 0, &mut 0, config)
}
fn method_name(method: crank_core::HttpMethod) -> &'static str {
match method {
crank_core::HttpMethod::Get => "GET",
crank_core::HttpMethod::Post => "POST",
crank_core::HttpMethod::Put => "PUT",
crank_core::HttpMethod::Patch => "PATCH",
crank_core::HttpMethod::Delete => "DELETE",
}
}
fn method_rank(method: crank_core::HttpMethod) -> u8 {
match method {
crank_core::HttpMethod::Get => 0,
crank_core::HttpMethod::Post => 1,
crank_core::HttpMethod::Put => 2,
crank_core::HttpMethod::Patch => 3,
crank_core::HttpMethod::Delete => 4,
}
}
fn severity_rank(severity: &ImportFindingSeverity) -> u8 {
match severity {
ImportFindingSeverity::Error => 0,
ImportFindingSeverity::Warning => 1,
ImportFindingSeverity::Info => 2,
}
}
fn sort_findings(findings: &mut [ImportFinding]) {
findings.sort_by(|left, right| {
(
severity_rank(&left.severity),
left.operation_key.as_deref().unwrap_or(""),
left.code.as_str(),
)
.cmp(&(
severity_rank(&right.severity),
right.operation_key.as_deref().unwrap_or(""),
right.code.as_str(),
))
});
}
fn sort_normalized_findings(findings: &mut [NormalizedFinding]) {
findings.sort_by(|left, right| {
(
severity_rank(&left.severity),
left.construct_id.as_str(),
left.code.as_str(),
)
.cmp(&(
severity_rank(&right.severity),
right.construct_id.as_str(),
right.code.as_str(),
))
});
}
// Kept private for pending pre-v2 job replays. New normalization never calls
// it: refs are represented above and are resolved only in Story 2.3.
#[allow(dead_code)]
pub(crate) fn resolve_local_ref(root: &Value, value: &Value, depth: usize) -> Value {
if depth > 12 {
return value.clone();
}
if let Some(reference) = value.get("$ref").and_then(Value::as_str) {
if let Some(resolved) = pointer(root, reference) {
return resolve_local_ref(root, resolved, depth + 1);
}
return value.clone();
if let Some(reference) = value.get("$ref").and_then(Value::as_str)
&& let Some(resolved) = pointer(root, reference)
{
return resolve_local_ref(root, resolved, depth + 1);
}
match value {
Value::Object(map) => {
let mut out = serde_json::Map::new();
for (key, item) in map {
out.insert(key.clone(), resolve_local_ref(root, item, depth + 1));
}
Value::Object(out)
}
Value::Object(map) => Value::Object(
map.iter()
.map(|(key, item)| (key.clone(), resolve_local_ref(root, item, depth + 1)))
.collect(),
),
Value::Array(items) => Value::Array(
items
.iter()
@@ -104,8 +855,7 @@ fn pointer<'a>(root: &'a Value, reference: &str) -> Option<&'a Value> {
}
let mut current = root;
for part in reference.trim_start_matches("#/").split('/') {
let part = part.replace("~1", "/").replace("~0", "~");
current = current.get(&part)?;
current = current.get(part.replace("~1", "/").replace("~0", "~"))?;
}
Some(current)
}
@@ -0,0 +1,887 @@
use std::collections::BTreeMap;
use serde_json::Value;
use crate::rest::model::{
CanonicalSourceNode, CanonicalSourceValue, CoverageDisposition, CoverageEntry,
ImportSourcePreview, NORMALIZER_VERSION, NormalizedIr, NormalizedOperation, NormalizedPath,
NormalizedReference, NormalizedSchema, NormalizedSchemaKind, PROJECTION_VERSION,
SourceLocation,
};
use super::ImportParseError;
pub(super) use super::normalize_schema::{has_reference_schema, typed_schema};
pub(super) fn validate_coverage(ir: &NormalizedIr) -> Result<(), ImportParseError> {
if ir.normalizer_version != NORMALIZER_VERSION || ir.projection_version != PROJECTION_VERSION {
return Err(ImportParseError::InvalidDocument);
}
let mut source_nodes = BTreeMap::new();
index_source_tree(&ir.source_tree, "", &mut source_nodes)?;
let version = validate_source_contract(ir, &source_nodes)?;
let mut expected = BTreeMap::<String, ExpectedCoverage>::new();
for node in source_nodes.values() {
insert_expected(
&mut expected,
node.construct_id.clone(),
node.location.clone(),
CoverageDisposition::Mapped,
)?;
}
insert_real_expected(
&mut expected,
&source_nodes,
"document".to_owned(),
SourceLocation {
pointer: String::new(),
},
CoverageDisposition::Mapped,
)?;
for (index, location) in server_locations(ir, &source_nodes)?.into_iter().enumerate() {
insert_real_expected(
&mut expected,
&source_nodes,
format!("{version}:server:{index}"),
location,
CoverageDisposition::Mapped,
)?;
}
let expected_references = references_from_source_tree(&ir.source_tree);
if ir.unresolved_references != expected_references {
return Err(ImportParseError::InvalidDocument);
}
for reference in &ir.unresolved_references {
insert_real_expected(
&mut expected,
&source_nodes,
reference.construct_id.clone(),
reference.location.clone(),
CoverageDisposition::Mapped,
)?;
}
let expected_paths = paths_from_operations(&ir.operations, &version);
if ir.paths != expected_paths {
return Err(ImportParseError::InvalidDocument);
}
for path in &ir.paths {
insert_real_expected(
&mut expected,
&source_nodes,
path.construct_id.clone(),
path.location.clone(),
CoverageDisposition::Mapped,
)?;
}
validate_operation_order(&ir.operations)?;
for operation in &ir.operations {
collect_operation_expectations(operation, &version, &source_nodes, &mut expected)?;
}
collect_finding_only_expectations(&version, ir, &source_nodes, &mut expected)?;
let findings = ir
.findings
.iter()
.chain(
ir.operations
.iter()
.flat_map(|operation| &operation.findings),
)
.collect::<Vec<_>>();
for finding in &findings {
let Some(target) = expected.get(&finding.construct_id) else {
return Err(ImportParseError::InvalidDocument);
};
if target.location != finding.location {
return Err(ImportParseError::InvalidDocument);
}
}
if expected.iter().any(|(construct_id, target)| {
target.disposition == CoverageDisposition::Finding
&& !findings.iter().any(|finding| {
finding.construct_id == *construct_id && finding.location == target.location
})
}) {
return Err(ImportParseError::InvalidDocument);
}
let mut actual = BTreeMap::new();
for entry in &ir.coverage {
if actual
.insert(
entry.construct_id.clone(),
ExpectedCoverage {
location: entry.location.clone(),
disposition: entry.disposition.clone(),
},
)
.is_some()
{
return Err(ImportParseError::InvalidDocument);
}
}
if actual != expected {
return Err(ImportParseError::InvalidDocument);
}
Ok(())
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ExpectedCoverage {
location: SourceLocation,
disposition: CoverageDisposition,
}
fn insert_expected(
expected: &mut BTreeMap<String, ExpectedCoverage>,
construct_id: String,
location: SourceLocation,
disposition: CoverageDisposition,
) -> Result<(), ImportParseError> {
let value = ExpectedCoverage {
location,
disposition,
};
if expected
.get(&construct_id)
.is_some_and(|existing| existing != &value)
{
return Err(ImportParseError::InvalidDocument);
}
expected.entry(construct_id).or_insert(value);
Ok(())
}
fn insert_real_expected(
expected: &mut BTreeMap<String, ExpectedCoverage>,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
construct_id: String,
location: SourceLocation,
disposition: CoverageDisposition,
) -> Result<(), ImportParseError> {
if !source_nodes.contains_key(&location.pointer) {
return Err(ImportParseError::InvalidDocument);
}
insert_expected(expected, construct_id, location, disposition)
}
fn index_source_tree<'a>(
node: &'a CanonicalSourceNode,
expected_pointer: &str,
nodes: &mut BTreeMap<String, &'a CanonicalSourceNode>,
) -> Result<(), ImportParseError> {
if node.location.pointer != expected_pointer
|| node.construct_id != format!("source:{expected_pointer}")
|| nodes.insert(expected_pointer.to_owned(), node).is_some()
{
return Err(ImportParseError::InvalidDocument);
}
match &node.value {
CanonicalSourceValue::Array(items) => {
for (index, child) in items.iter().enumerate() {
index_source_tree(child, &format!("{expected_pointer}/{index}"), nodes)?;
}
}
CanonicalSourceValue::Object(items) => {
for (key, child) in items {
index_source_tree(
child,
&format!("{expected_pointer}/{}", escape_pointer(key)),
nodes,
)?;
}
}
_ => {}
}
Ok(())
}
fn validate_source_contract(
ir: &NormalizedIr,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<String, ImportParseError> {
let version = ir
.source
.version
.as_deref()
.ok_or(ImportParseError::InvalidDocument)?;
let valid = match ir.source.format.as_str() {
"openapi" => string_at(nodes, "/openapi") == Some(version) && supported_oas(version),
"swagger" => version == "2.0" && string_at(nodes, "/swagger") == Some("2.0"),
_ => false,
};
if !valid
|| ir.metadata.title != ir.source.title
|| ir.source.title != string_at(nodes, "/info/title").unwrap_or("Imported API")
|| ir.metadata.version != string_at(nodes, "/info/version").map(ToOwned::to_owned)
|| ir.metadata.description != string_at(nodes, "/info/description").map(ToOwned::to_owned)
|| ir.base_path_candidates
!= string_at(nodes, "/basePath")
.map(|value| vec![value.to_owned()])
.unwrap_or_default()
{
return Err(ImportParseError::InvalidDocument);
}
Ok(version.to_owned())
}
fn supported_oas(version: &str) -> bool {
["3.0.", "3.1."].into_iter().any(|prefix| {
version.strip_prefix(prefix).is_some_and(|patch| {
!patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit())
})
})
}
fn string_at<'a>(
nodes: &'a BTreeMap<String, &CanonicalSourceNode>,
pointer: &str,
) -> Option<&'a str> {
match &nodes.get(pointer)?.value {
CanonicalSourceValue::String(value) => Some(value),
_ => None,
}
}
fn server_locations(
ir: &NormalizedIr,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<Vec<SourceLocation>, ImportParseError> {
server_locations_for_source(&ir.source, nodes)
}
fn server_locations_for_source(
source: &ImportSourcePreview,
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> Result<Vec<SourceLocation>, ImportParseError> {
let (urls, locations) = if source.format == "openapi" {
openapi_servers(nodes)
} else {
swagger_servers(nodes)
};
if urls != source.servers {
return Err(ImportParseError::InvalidDocument);
}
Ok(locations)
}
pub(super) fn server_coverage(
source: &ImportSourcePreview,
source_tree: &CanonicalSourceNode,
version: &str,
) -> Result<Vec<CoverageEntry>, ImportParseError> {
let mut nodes = BTreeMap::new();
index_source_tree(source_tree, "", &mut nodes)?;
Ok(server_locations_for_source(source, &nodes)?
.into_iter()
.enumerate()
.map(|(index, location)| CoverageEntry {
construct_id: format!("{version}:server:{index}"),
location,
disposition: CoverageDisposition::Mapped,
})
.collect())
}
pub(super) fn tag_locations(
root: &Value,
operation_pointer: &str,
tags: &[String],
) -> Result<Vec<SourceLocation>, ImportParseError> {
let source_tags = root
.pointer(&format!("{operation_pointer}/tags"))
.and_then(Value::as_array);
let locations = source_tags
.into_iter()
.flatten()
.enumerate()
.filter_map(|(index, value)| {
value.as_str().map(|value| {
(
value,
SourceLocation {
pointer: format!("{operation_pointer}/tags/{index}"),
},
)
})
})
.collect::<Vec<_>>();
if locations
.iter()
.map(|(value, _)| *value)
.ne(tags.iter().map(String::as_str))
{
return Err(ImportParseError::InvalidDocument);
}
Ok(locations
.into_iter()
.map(|(_, location)| location)
.collect())
}
fn openapi_servers(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> (Vec<String>, Vec<SourceLocation>) {
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::Array(items),
..
}) = nodes.get("/servers").copied()
else {
return (Vec::new(), Vec::new());
};
items
.iter()
.filter_map(|item| {
let CanonicalSourceValue::Object(fields) = &item.value else {
return None;
};
let CanonicalSourceValue::String(url) = &fields.get("url")?.value else {
return None;
};
Some((url.trim_end_matches('/').to_owned(), item.location.clone()))
})
.unzip()
}
fn swagger_servers(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
) -> (Vec<String>, Vec<SourceLocation>) {
let Some(host) = string_at(nodes, "/host") else {
return (Vec::new(), Vec::new());
};
let base_path = string_at(nodes, "/basePath").unwrap_or("");
let schemes = nodes
.get("/schemes")
.and_then(|node| match &node.value {
CanonicalSourceValue::Array(items) => Some(
items
.iter()
.filter_map(|item| match &item.value {
CanonicalSourceValue::String(value) => Some((value.as_str(), item)),
_ => None,
})
.collect::<Vec<_>>(),
),
_ => None,
})
.filter(|items| !items.is_empty());
let schemes = schemes.unwrap_or_else(|| {
vec![(
"https",
*nodes.get("/host").expect("host was checked above"),
)]
});
schemes
.into_iter()
.map(|(scheme, node)| {
(
format!("{scheme}://{host}{base_path}")
.trim_end_matches('/')
.to_owned(),
node.location.clone(),
)
})
.unzip()
}
fn validate_operation_order(operations: &[NormalizedOperation]) -> Result<(), ImportParseError> {
if operations
.windows(2)
.any(|pair| operation_sort_key(&pair[0]) >= operation_sort_key(&pair[1]))
{
return Err(ImportParseError::InvalidDocument);
}
Ok(())
}
fn operation_sort_key(operation: &NormalizedOperation) -> (&str, u8, &str) {
(
&operation.path,
method_rank(operation.method),
&operation.location.pointer,
)
}
fn method_rank(method: crank_core::HttpMethod) -> u8 {
match method {
crank_core::HttpMethod::Get => 0,
crank_core::HttpMethod::Post => 1,
crank_core::HttpMethod::Put => 2,
crank_core::HttpMethod::Patch => 3,
crank_core::HttpMethod::Delete => 4,
}
}
fn method_name(method: crank_core::HttpMethod) -> &'static str {
match method {
crank_core::HttpMethod::Get => "get",
crank_core::HttpMethod::Post => "post",
crank_core::HttpMethod::Put => "put",
crank_core::HttpMethod::Patch => "patch",
crank_core::HttpMethod::Delete => "delete",
}
}
fn collect_operation_expectations(
operation: &NormalizedOperation,
version: &str,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let method = method_name(operation.method);
let pointer = format!("/paths/{}/{method}", escape_pointer(&operation.path));
let stable_id = format!("{version}:{method}:{pointer}");
if operation.location.pointer != pointer
|| operation.stable_id != stable_id
|| operation.key != format!("{} {}", method.to_ascii_uppercase(), operation.path)
{
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
stable_id.clone(),
operation.location.clone(),
CoverageDisposition::Mapped,
)?;
let operation_id_location = SourceLocation {
pointer: format!("{pointer}/operationId"),
};
let operation_id_disposition = match operation
.operation_id
.as_deref()
.filter(|value| !value.trim().is_empty())
{
Some(value) if string_at(source_nodes, &operation_id_location.pointer) == Some(value) => {
CoverageDisposition::Mapped
}
Some(_) => return Err(ImportParseError::InvalidDocument),
None => CoverageDisposition::Finding,
};
if operation_id_disposition == CoverageDisposition::Mapped
|| source_nodes.contains_key(&operation_id_location.pointer)
{
insert_real_expected(
expected,
source_nodes,
format!("{stable_id}:operation_id"),
operation_id_location,
operation_id_disposition,
)?;
} else {
insert_expected(
expected,
format!("{stable_id}:operation_id"),
operation_id_location,
CoverageDisposition::Finding,
)?;
}
let tag_locations = normalized_tag_locations(source_nodes, &pointer, &operation.tags)?;
for (index, location) in tag_locations.into_iter().enumerate() {
insert_real_expected(
expected,
source_nodes,
format!("{stable_id}:tag:{index}"),
location,
CoverageDisposition::Mapped,
)?;
}
for parameter in &operation.parameters {
if parameter.construct_id
!= format!(
"parameter:{}",
escape_pointer(&parameter.source_location.pointer)
)
{
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
parameter.construct_id.clone(),
parameter.source_location.clone(),
CoverageDisposition::Mapped,
)?;
if let Some(schema) = &parameter.schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
}
if let Some(schema) = &operation.request_body_schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
if let Some(schema) = &operation.response_schema {
collect_schema_expectations(schema, source_nodes, expected)?;
}
Ok(())
}
fn normalized_tag_locations(
nodes: &BTreeMap<String, &CanonicalSourceNode>,
operation_pointer: &str,
tags: &[String],
) -> Result<Vec<SourceLocation>, ImportParseError> {
let tags_pointer = format!("{operation_pointer}/tags");
let source_tags = nodes.get(&tags_pointer).and_then(|node| match &node.value {
CanonicalSourceValue::Array(items) => Some(items),
_ => None,
});
let locations = source_tags
.into_iter()
.flatten()
.filter_map(|node| match &node.value {
CanonicalSourceValue::String(value) => Some((value, node.location.clone())),
_ => None,
})
.collect::<Vec<_>>();
if locations.iter().map(|(value, _)| *value).ne(tags.iter()) {
return Err(ImportParseError::InvalidDocument);
}
Ok(locations
.into_iter()
.map(|(_, location)| location)
.collect())
}
fn collect_schema_expectations(
schema: &NormalizedSchema,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
if schema.construct_id != format!("schema:{}", escape_pointer(&schema.location.pointer)) {
return Err(ImportParseError::InvalidDocument);
}
insert_real_expected(
expected,
source_nodes,
schema.construct_id.clone(),
schema.location.clone(),
CoverageDisposition::Mapped,
)?;
if schema.description
!= string_at(
source_nodes,
&format!("{}/description", schema.location.pointer),
)
.map(ToOwned::to_owned)
{
return Err(ImportParseError::InvalidDocument);
}
match &schema.kind {
NormalizedSchemaKind::Object { properties, .. } => {
for child in properties.values() {
collect_schema_expectations(child, source_nodes, expected)?;
}
}
NormalizedSchemaKind::Array { items: Some(child) } => {
collect_schema_expectations(child, source_nodes, expected)?;
}
NormalizedSchemaKind::Composition { variants, .. } => {
for child in variants {
collect_schema_expectations(child, source_nodes, expected)?;
}
}
_ => {}
}
Ok(())
}
fn collect_finding_only_expectations(
version: &str,
ir: &NormalizedIr,
source_nodes: &BTreeMap<String, &CanonicalSourceNode>,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
if ir.source.format == "openapi" && ir.source.servers.len() > 1 {
let location = SourceLocation {
pointer: "/servers".to_owned(),
};
insert_real_expected(
expected,
source_nodes,
format!("{version}:/servers"),
location,
CoverageDisposition::Finding,
)?;
}
if ir.source.format == "openapi"
&& let Some(servers) = source_nodes.get("/servers").copied()
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::Object(paths),
..
}) = source_nodes.get("/paths").copied()
else {
return Err(ImportParseError::InvalidDocument);
};
for path_item in paths.values() {
let CanonicalSourceValue::Object(methods) = &path_item.value else {
insert_finding_pointer(version, path_item, expected)?;
continue;
};
if ir.source.format == "openapi"
&& let Some(servers) = methods.get("servers")
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
if let Some(parameters) = methods.get("parameters") {
collect_dropped_parameter_findings(
version,
parameters,
ir.source.format.as_str(),
expected,
)?;
}
for method in ["head", "options", "trace", "connect"] {
if let Some(node) = methods.get(method) {
insert_finding_pointer(version, node, expected)?;
}
}
for method in ["get", "post", "put", "patch", "delete"] {
if let Some(node) = methods.get(method) {
match &node.value {
CanonicalSourceValue::Object(operation) => {
if ir.source.format == "openapi"
&& let Some(servers) = operation.get("servers")
{
collect_invalid_openapi_server_findings(version, servers, expected)?;
}
if let Some(tags) = operation.get("tags") {
collect_invalid_tag_findings(version, tags, expected)?;
}
if let Some(parameters) = operation.get("parameters") {
collect_dropped_parameter_findings(
version,
parameters,
ir.source.format.as_str(),
expected,
)?;
}
}
_ => insert_finding_pointer(version, node, expected)?,
}
}
}
}
Ok(())
}
fn collect_invalid_openapi_server_findings(
version: &str,
servers: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &servers.value else {
return Ok(());
};
for server in items {
let valid = matches!(
&server.value,
CanonicalSourceValue::Object(fields)
if matches!(fields.get("url").map(|node| &node.value), Some(CanonicalSourceValue::String(_)))
);
if !valid {
insert_finding_pointer(version, server, expected)?;
}
}
Ok(())
}
fn collect_invalid_tag_findings(
version: &str,
tags: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &tags.value else {
return Ok(());
};
for tag in items {
if !matches!(tag.value, CanonicalSourceValue::String(_)) {
insert_finding_pointer(version, tag, expected)?;
}
}
Ok(())
}
fn collect_dropped_parameter_findings(
version: &str,
parameters: &CanonicalSourceNode,
format: &str,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
let CanonicalSourceValue::Array(items) = &parameters.value else {
return Ok(());
};
for parameter in items {
let CanonicalSourceValue::Object(fields) = &parameter.value else {
insert_finding_pointer(version, parameter, expected)?;
continue;
};
if fields.contains_key("$ref") {
insert_finding_pointer(version, parameter, expected)?;
continue;
}
if !matches!(
fields.get("name").map(|node| &node.value),
Some(CanonicalSourceValue::String(_))
) || !matches!(
fields.get("in").map(|node| &node.value),
Some(CanonicalSourceValue::String(_))
) {
insert_finding_pointer(version, parameter, expected)?;
continue;
}
let Some(CanonicalSourceNode {
value: CanonicalSourceValue::String(location),
..
}) = fields.get("in")
else {
return Err(ImportParseError::InvalidDocument);
};
let supported = match format {
"openapi" => matches!(location.as_str(), "path" | "query" | "header"),
"swagger" => matches!(location.as_str(), "path" | "query" | "header" | "body"),
_ => false,
};
if !supported {
insert_finding_pointer(version, parameter, expected)?;
}
}
Ok(())
}
fn insert_finding_pointer(
version: &str,
node: &CanonicalSourceNode,
expected: &mut BTreeMap<String, ExpectedCoverage>,
) -> Result<(), ImportParseError> {
insert_expected(
expected,
format!("{version}:{}", node.location.pointer),
node.location.clone(),
CoverageDisposition::Finding,
)
}
pub(super) fn references_from_source_tree(
source_tree: &CanonicalSourceNode,
) -> Vec<NormalizedReference> {
let mut references = Vec::new();
collect_references(source_tree, &mut references);
references.sort_by(|left, right| left.construct_id.cmp(&right.construct_id));
references
}
fn collect_references(node: &CanonicalSourceNode, references: &mut Vec<NormalizedReference>) {
match &node.value {
CanonicalSourceValue::Array(items) => {
for item in items {
collect_references(item, references);
}
}
CanonicalSourceValue::Object(items) => {
if let Some(CanonicalSourceNode {
value: CanonicalSourceValue::String(uri),
location,
..
}) = items.get("$ref")
{
references.push(NormalizedReference {
construct_id: format!("reference:{}", escape_pointer(&location.pointer)),
uri: uri.clone(),
location: location.clone(),
});
}
for item in items.values() {
collect_references(item, references);
}
}
_ => {}
}
}
pub(super) fn canonical_source_tree(
value: &Value,
pointer: &str,
coverage: &mut Vec<CoverageEntry>,
) -> CanonicalSourceNode {
let id = format!("source:{pointer}");
let location = SourceLocation {
pointer: pointer.to_owned(),
};
coverage.push(CoverageEntry {
construct_id: id.clone(),
location: location.clone(),
disposition: CoverageDisposition::Mapped,
});
let value = match value {
Value::Null => CanonicalSourceValue::Null,
Value::Bool(value) => CanonicalSourceValue::Boolean(*value),
Value::Number(value) => CanonicalSourceValue::Number(value.to_string()),
Value::String(value) => CanonicalSourceValue::String(value.clone()),
Value::Array(items) => CanonicalSourceValue::Array(
items
.iter()
.enumerate()
.map(|(index, value)| {
canonical_source_tree(value, &format!("{pointer}/{index}"), coverage)
})
.collect(),
),
Value::Object(items) => CanonicalSourceValue::Object(
items
.iter()
.map(|(key, value)| {
(
key.clone(),
canonical_source_tree(
value,
&format!("{pointer}/{}", escape_pointer(key)),
coverage,
),
)
})
.collect(),
),
};
CanonicalSourceNode {
construct_id: id,
location,
value,
}
}
pub(super) fn paths_from_operations(
operations: &[NormalizedOperation],
version: &str,
) -> Vec<NormalizedPath> {
let mut paths = BTreeMap::<String, Vec<String>>::new();
for operation in operations {
paths
.entry(operation.path.clone())
.or_default()
.push(operation.stable_id.clone());
}
paths
.into_iter()
.map(|(path, operation_ids)| NormalizedPath {
construct_id: format!("{version}:path:{path}"),
location: SourceLocation {
pointer: format!("/paths/{}", escape_pointer(&path)),
},
path,
operation_ids,
})
.collect()
}
pub(super) fn escape_pointer(value: &str) -> String {
value.replace('~', "~0").replace('/', "~1")
}
@@ -0,0 +1,185 @@
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AliasQuote {
Single,
Double,
}
#[derive(Clone, Copy, Debug)]
struct BlockScalar {
parent_indent: usize,
content_indent: Option<usize>,
}
pub(super) fn alias_count(document: &str) -> usize {
// YAML lexical preflight: aliases are counted before serde_yaml can expand
// them. This is deliberately a small lexer rather than a second YAML
// parser: it understands quote escapes, comments, and flow aliases while
// keeping the scan bounded by the source size.
//
// Block scalars are the one intentionally conservative exception. Their
// contents are opaque YAML text, and reproducing YAML's indentation rules
// here would create a second parser with its own correctness risks. If a
// content line contains `*`, fail closed by returning usize::MAX; this
// prevents an alias-looking token from being hidden in a literal block at
// the cost of rejecting that block before decode.
let mut count = 0;
let mut quote = None;
let mut block_scalar: Option<BlockScalar> = None;
for line in document.split('\n') {
if let Some(block) = block_scalar {
if is_blank_line(line) {
continue;
}
let indent = leading_spaces(line);
let is_content = match block.content_indent {
Some(content_indent) => indent >= content_indent,
None => indent > block.parent_indent,
};
if is_content {
if line.as_bytes().contains(&b'*') {
return usize::MAX;
}
if block.content_indent.is_none() {
block_scalar = Some(BlockScalar {
content_indent: Some(indent),
..block
});
}
continue;
}
block_scalar = None;
}
let bytes = line.as_bytes();
let line_indent = leading_spaces(line);
let mut comment = false;
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if comment {
break;
}
if let Some(current) = quote {
match current {
AliasQuote::Single if byte == b'\'' => {
// YAML escapes a single quote by doubling it. Consume
// both bytes so the second quote cannot reopen a
// scalar and desynchronise the scan.
if bytes.get(index + 1) == Some(&b'\'') {
index += 2;
} else {
quote = None;
index += 1;
}
}
AliasQuote::Double if byte == b'\\' => {
// A backslash escapes the following byte, including a
// quote. At end of line it only folds the YAML line;
// the quote remains open for the next line.
index += if index + 1 < bytes.len() { 2 } else { 1 };
}
AliasQuote::Double if byte == b'"' => {
quote = None;
index += 1;
}
_ => index += 1,
}
continue;
}
match byte {
b'\'' => {
quote = Some(AliasQuote::Single);
index += 1;
}
b'"' => {
quote = Some(AliasQuote::Double);
index += 1;
}
b'#' if index == 0
|| bytes
.get(index - 1)
.is_some_and(|previous| previous.is_ascii_whitespace()) =>
{
comment = true;
index += 1;
}
b'*' => {
let previous = index.checked_sub(1).and_then(|i| bytes.get(i)).copied();
let next = bytes.get(index + 1).copied();
if previous
.is_none_or(|byte| byte.is_ascii_whitespace() || b"[:,[{".contains(&byte))
&& next.is_some_and(is_alias_name_byte)
{
count += 1;
}
index += 1;
}
b'|' | b'>' if is_block_scalar_indicator(bytes, index) => {
block_scalar = Some(BlockScalar {
parent_indent: line_indent,
content_indent: block_scalar_indent(bytes, index, line_indent),
});
// The rest of a block-scalar header cannot contain YAML
// aliases; its content starts on the next line.
break;
}
_ => index += 1,
}
}
}
count
}
fn is_alias_name_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
}
fn leading_spaces(line: &str) -> usize {
line.bytes().take_while(|byte| *byte == b' ').count()
}
fn is_blank_line(line: &str) -> bool {
line.bytes()
.all(|byte| byte == b' ' || byte == b'\t' || byte == b'\r')
}
fn is_block_scalar_indicator(bytes: &[u8], index: usize) -> bool {
let mut previous_position = index;
while previous_position > 0
&& bytes
.get(previous_position - 1)
.is_some_and(|byte| byte.is_ascii_whitespace())
{
previous_position -= 1;
}
let previous_is_value_boundary = previous_position == 0
|| bytes
.get(previous_position - 1)
.is_some_and(|byte| *byte == b':' || *byte == b'-');
let next = bytes.get(index + 1).copied();
let next_is_header_suffix = next.is_none_or(|byte| {
byte.is_ascii_whitespace()
|| byte == b'#'
|| byte == b'+'
|| byte == b'-'
|| byte.is_ascii_digit()
});
previous_is_value_boundary && next_is_header_suffix
}
fn block_scalar_indent(bytes: &[u8], index: usize, parent_indent: usize) -> Option<usize> {
let mut position = index + 1;
while let Some(byte) = bytes.get(position).copied() {
if byte == b'+' || byte == b'-' {
position += 1;
continue;
}
if byte.is_ascii_digit() && byte != b'0' {
return Some(parent_indent + usize::from(byte - b'0'));
}
break;
}
None
}
@@ -0,0 +1,183 @@
use serde_json::Value;
use crate::rest::model::{
CoverageDisposition, CoverageEntry, NormalizedLiteral, NormalizedScalarKind, NormalizedSchema,
NormalizedSchemaKind, SourceLocation, UnresolvedReference,
};
use super::normalize_coverage::escape_pointer;
pub(super) fn typed_schema(
value: &Value,
pointer: &str,
_id: &str,
coverage: &mut Vec<CoverageEntry>,
) -> NormalizedSchema {
// IDs are derived from RFC 6901 locations rather than user-controlled
// property names, so escaped keys cannot collide with one another.
let id = format!("schema:{}", escape_pointer(pointer));
let location = SourceLocation {
pointer: pointer.to_owned(),
};
coverage.push(CoverageEntry {
construct_id: id.clone(),
location: location.clone(),
disposition: CoverageDisposition::Mapped,
});
let kind = if let Some(reference) = value.get("$ref").and_then(Value::as_str) {
NormalizedSchemaKind::Reference {
reference: UnresolvedReference {
uri: reference.to_owned(),
location: SourceLocation {
pointer: format!("{pointer}/$ref"),
},
},
}
} else if let Some((operator, variants)) =
["allOf", "oneOf", "anyOf"]
.into_iter()
.find_map(|operator| {
value
.get(operator)
.and_then(Value::as_array)
.map(|items| (operator, items))
})
{
NormalizedSchemaKind::Composition {
operator: operator.to_owned(),
variants: variants
.iter()
.enumerate()
.map(|(index, item)| {
typed_schema(
item,
&format!("{pointer}/{operator}/{index}"),
&format!(
"schema:{}",
escape_pointer(&format!("{pointer}/{operator}/{index}"))
),
coverage,
)
})
.collect(),
}
} else if value.get("properties").is_some()
|| value.get("type").and_then(Value::as_str) == Some("object")
{
let required = value
.get("required")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default();
let properties = value
.get("properties")
.and_then(Value::as_object)
.map(|properties| {
properties
.iter()
.map(|(name, schema)| {
(
name.clone(),
typed_schema(
schema,
&format!("{pointer}/properties/{}", escape_pointer(name)),
&format!(
"schema:{}",
escape_pointer(&format!(
"{pointer}/properties/{}",
escape_pointer(name)
))
),
coverage,
),
)
})
.collect()
})
.unwrap_or_default();
NormalizedSchemaKind::Object {
properties,
required,
}
} else if value.get("type").and_then(Value::as_str) == Some("array") {
NormalizedSchemaKind::Array {
items: value.get("items").map(|items| {
Box::new(typed_schema(
items,
&format!("{pointer}/items"),
&format!("schema:{}", escape_pointer(&format!("{pointer}/items"))),
coverage,
))
}),
}
} else if let Some(raw_type) = value.get("type").and_then(Value::as_str) {
NormalizedSchemaKind::Scalar {
scalar_type: match raw_type {
"integer" => NormalizedScalarKind::Integer,
"number" => NormalizedScalarKind::Number,
"boolean" => NormalizedScalarKind::Boolean,
"null" => NormalizedScalarKind::Null,
_ => NormalizedScalarKind::String,
},
format: value
.get("format")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
nullable: value
.get("nullable")
.and_then(Value::as_bool)
.unwrap_or(false),
default_value: value.get("default").and_then(normalized_literal),
enum_values: value
.get("enum")
.and_then(Value::as_array)
.map(|items| items.iter().filter_map(normalized_literal).collect())
.unwrap_or_default(),
}
} else {
NormalizedSchemaKind::Unknown
};
NormalizedSchema {
construct_id: id,
location,
description: value
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
kind,
}
}
pub(super) fn has_reference_schema(schema: Option<&NormalizedSchema>) -> bool {
match schema.map(|schema| &schema.kind) {
Some(NormalizedSchemaKind::Reference { .. }) => true,
Some(NormalizedSchemaKind::Object { properties, .. }) => properties
.values()
.any(|schema| has_reference_schema(Some(schema))),
Some(NormalizedSchemaKind::Array { items }) => has_reference_schema(items.as_deref()),
Some(NormalizedSchemaKind::Composition { variants, .. }) => variants
.iter()
.any(|schema| has_reference_schema(Some(schema))),
_ => false,
}
}
fn normalized_literal(value: &Value) -> Option<NormalizedLiteral> {
match value {
Value::String(value) => Some(NormalizedLiteral::String(value.clone())),
Value::Bool(value) => Some(NormalizedLiteral::Boolean(*value)),
Value::Null => Some(NormalizedLiteral::Null),
Value::Number(value) => value
.as_i64()
.map(NormalizedLiteral::Integer)
.or_else(|| value.as_u64().map(NormalizedLiteral::Unsigned))
.or_else(|| value.as_f64().map(NormalizedLiteral::Number)),
_ => None,
}
}
+618 -70
View File
@@ -3,14 +3,18 @@ use serde_json::Value;
use crate::rest::{
model::{
ImportFinding, RestImportDocument, RestImportOperation, RestImportParameter,
RestParameterLocation,
ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation,
RestImportParameter, RestParameterLocation,
},
normalize::{ImportParseError, resolve_local_ref},
recommendations::document_finding,
normalize::ImportParseError,
recommendations::{document_blocker, document_finding},
};
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
parse_document_v2(root)
}
fn parse_document_v2(root: &Value) -> Result<RestImportDocument, ImportParseError> {
let version = root
.get("openapi")
.and_then(Value::as_str)
@@ -20,17 +24,196 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.and_then(Value::as_str)
.unwrap_or("Imported API")
.to_owned();
let servers = root
.get("servers")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("url").and_then(Value::as_str))
.map(|url| url.trim_end_matches('/').to_owned())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut findings = Vec::new();
let mut internal_finding_locations = Vec::new();
let servers = parse_servers(root.get("servers"), "/servers");
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
servers.findings,
);
let servers = servers.servers;
if servers.is_empty() {
findings.push(document_finding(
"missing_servers",
"В документе не указаны servers, base URL нужно будет выбрать вручную.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: String::new(),
}));
} else if servers.len() > 1 {
findings.push(document_finding(
"multiple_servers",
"В документе несколько servers, при импорте нужно выбрать нужный base URL.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: "/servers".to_owned(),
}));
}
let mut operations = Vec::new();
let paths = root
.get("paths")
.and_then(Value::as_object)
.ok_or(ImportParseError::UnsupportedDocument)?;
for (path, path_item) in paths {
if !path_item.is_object() {
findings.push(path_item_blocker(path));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!("/paths/{}", path.replace('~', "~0").replace('/', "~1")),
}));
continue;
}
for method_name in ["head", "options", "trace", "connect"] {
if path_item.get(method_name).is_some() {
findings.push(document_blocker(
"unsupported_http_method",
format!("Метод {method_name} для пути {path} пока не поддерживается."),
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!(
"/paths/{}/{}",
path.replace('~', "~0").replace('/', "~1"),
method_name
),
}));
}
}
let path_pointer = format!("/paths/{}", path.replace('~', "~0").replace('/', "~1"));
let path_parameters = parameters(
path_item.get("parameters"),
&format!("{path_pointer}/parameters"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
path_parameters.findings,
);
let path_parameters = path_parameters.parameters;
let path_servers =
parse_servers(path_item.get("servers"), &format!("{path_pointer}/servers"));
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
path_servers.findings,
);
let path_servers = path_servers.servers;
for method_name in ["get", "post", "put", "patch", "delete"] {
let Some(operation_value) = path_item.get(method_name) else {
continue;
};
if !operation_value.is_object() {
findings.push(document_blocker(
"invalid_operation",
"Операция имеет неверную структуру и не была интерпретирована.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: format!(
"/paths/{}/{}",
path.replace('~', "~0").replace('/', "~1"),
method_name
),
}));
continue;
}
let Some(method) = method_from_lower(method_name) else {
continue;
};
let operation_pointer = format!("{path_pointer}/{method_name}");
let mut operation_parameters = path_parameters.clone();
let parsed_parameters = parameters(
operation_value.get("parameters"),
&format!("{path_pointer}/{method_name}/parameters"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
parsed_parameters.findings,
);
operation_parameters.extend(parsed_parameters.parameters);
let operation_servers = parse_servers(
operation_value.get("servers"),
&format!("{operation_pointer}/servers"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
operation_servers.findings,
);
let operation_servers = operation_servers.servers;
let tags = tags(
operation_value.get("tags"),
&format!("{operation_pointer}/tags"),
);
append_findings_with_locations(
&mut findings,
&mut internal_finding_locations,
tags.findings,
);
operations.push(RestImportOperation {
key: format!("{} {}", method_name.to_uppercase(), path),
method,
path: path.clone(),
operation_id: operation_value
.get("operationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: operation_value
.get("summary")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: operation_value
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
tags: tags.tags,
parameters: operation_parameters,
request_body_schema: request_body_schema(operation_value, &operation_pointer)
.map(|(schema, _)| schema),
request_body_schema_location: request_body_schema(
operation_value,
&operation_pointer,
)
.map(|(_, location)| location),
response_schema: response_schema(operation_value, &operation_pointer)
.map(|(schema, _)| schema),
response_schema_location: response_schema(operation_value, &operation_pointer)
.map(|(_, location)| location),
servers: if operation_servers.is_empty() {
path_servers.clone()
} else {
operation_servers
},
findings: operation_findings(operation_value),
});
}
}
Ok(RestImportDocument {
format: "openapi".to_owned(),
version,
title,
servers,
operations,
findings,
internal_finding_locations,
})
}
pub fn parse_document_legacy_v1(root: &Value) -> Result<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();
if servers.is_empty() {
findings.push(document_finding(
@@ -49,67 +232,54 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.get("paths")
.and_then(Value::as_object)
.ok_or(ImportParseError::UnsupportedDocument)?;
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"] {
let Some(operation_value) = path_item.get(method_name) else {
let Some(operation) = path_item.get(method_name) else {
continue;
};
let Some(method) = method_from_lower(method_name) else {
continue;
};
let mut operation_parameters = path_parameters.clone();
operation_parameters.extend(parameters(root, operation_value.get("parameters")));
let operation_servers = operation_value
.get("servers")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("url").and_then(Value::as_str))
.map(|url| url.trim_end_matches('/').to_owned())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let operation_pointer = format!("{path_pointer}/{method_name}");
let mut parameters = path_parameters.clone();
parameters.extend(legacy_parameters(
root,
operation.get("parameters"),
&format!("{operation_pointer}/parameters"),
));
operations.push(RestImportOperation {
key: format!("{} {}", method_name.to_uppercase(), path),
method,
path: path.clone(),
operation_id: operation_value
operation_id: operation
.get("operationId")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
summary: operation_value
summary: operation
.get("summary")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
description: operation_value
description: operation
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
tags: operation_value
.get("tags")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default(),
parameters: operation_parameters,
request_body_schema: request_body_schema(root, operation_value),
response_schema: response_schema(root, operation_value),
servers: operation_servers,
findings: operation_findings(operation_value),
tags: legacy_tags(operation.get("tags")),
parameters,
request_body_schema: legacy_request_body_schema(root, operation),
request_body_schema_location: None,
response_schema: legacy_response_schema(root, operation),
response_schema_location: None,
servers: legacy_servers(operation.get("servers")),
findings: legacy_operation_findings(operation),
});
}
}
Ok(RestImportDocument {
format: "openapi".to_owned(),
version,
@@ -117,17 +287,49 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
servers,
operations,
findings,
internal_finding_locations: Vec::new(),
})
}
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
fn legacy_servers(value: Option<&Value>) -> Vec<String> {
value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let item = resolve_local_ref(root, item, 0);
.filter_map(|item| item.get("url").and_then(Value::as_str))
.map(|url| url.trim_end_matches('/').to_owned())
.collect()
})
.unwrap_or_default()
}
fn legacy_tags(value: Option<&Value>) -> Vec<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 location = match item.get("in").and_then(Value::as_str)? {
"path" => RestParameterLocation::Path,
"query" => RestParameterLocation::Query,
@@ -146,9 +348,13 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
schema: item
.get("schema")
.map(|schema| resolve_local_ref(root, schema, 0)),
schema: item.get("schema").map(|schema| {
crate::rest::normalize::resolve_local_ref(root, schema, 0)
}),
source_location: crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
},
schema_source_location: None,
})
})
.collect()
@@ -156,31 +362,31 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.unwrap_or_default()
}
fn request_body_schema(root: &Value, operation: &Value) -> Option<Value> {
let body = resolve_local_ref(root, operation.get("requestBody")?, 0);
fn legacy_request_body_schema(root: &Value, operation: &Value) -> Option<Value> {
let body = crate::rest::normalize::resolve_local_ref(root, operation.get("requestBody")?, 0);
let content = body.get("content")?.as_object()?;
for content_type in ["application/json", "application/*+json"] {
if let Some(schema) = content
.get(content_type)
.and_then(|media| media.get("schema"))
{
return Some(resolve_local_ref(root, schema, 0));
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
}
}
content
.iter()
.find(|(content_type, _)| content_type.contains("json"))
.and_then(|(_, media)| media.get("schema"))
.map(|schema| resolve_local_ref(root, schema, 0))
.map(|schema| crate::rest::normalize::resolve_local_ref(root, schema, 0))
}
fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
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 = resolve_local_ref(root, response, 0);
let response = crate::rest::normalize::resolve_local_ref(root, response, 0);
let Some(content) = response.get("content").and_then(Value::as_object) else {
continue;
};
@@ -189,7 +395,7 @@ fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
.get(content_type)
.and_then(|media| media.get("schema"))
{
return Some(resolve_local_ref(root, schema, 0));
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
}
}
if let Some(schema) = content
@@ -197,17 +403,350 @@ fn response_schema(root: &Value, operation: &Value) -> Option<Value> {
.find(|(content_type, _)| content_type.contains("json"))
.and_then(|(_, media)| media.get("schema"))
{
return Some(resolve_local_ref(root, schema, 0));
return Some(crate::rest::normalize::resolve_local_ref(root, schema, 0));
}
}
None
}
fn legacy_operation_findings(operation: &Value) -> Vec<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> {
let mut findings = Vec::new();
if operation.get("requestBody").is_some()
&& request_body_schema(&Value::Null, operation).is_none()
{
if operation.get("requestBody").is_some() && request_body_schema(operation, "").is_none() {
findings.push(document_finding(
"unsupported_request_body",
"У метода есть requestBody, но JSON schema не найдена.",
@@ -226,3 +765,12 @@ fn method_from_lower(value: &str) -> Option<HttpMethod> {
_ => 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(
operation_key: &str,
code: &str,
@@ -45,6 +54,9 @@ pub fn operation_recommendations(operation: &RestImportOperation) -> Vec<ImportF
.unwrap_or_default()
.trim()
.is_empty()
&& !findings
.iter()
.any(|finding| finding.code == "missing_operation_id")
{
findings.push(operation_finding(
&operation.key,
+13 -11
View File
@@ -32,6 +32,8 @@ pub fn schema_from_openapi(
let Some(value) = value else {
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);
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 {
Schema {
kind: SchemaKind::Object,
@@ -190,14 +203,3 @@ fn text(value: &Value, key: &str) -> Option<String> {
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
fn collapse_composition(value: &Value) -> Value {
for key in ["allOf", "oneOf", "anyOf"] {
if let Some(items) = value.get(key).and_then(Value::as_array)
&& let Some(first) = items.first()
{
return first.clone();
}
}
value.clone()
}
+491 -41
View File
@@ -2,12 +2,19 @@ use crank_core::HttpMethod;
use serde_json::Value;
use crate::rest::{
model::{RestImportDocument, RestImportOperation, RestImportParameter, RestParameterLocation},
normalize::{ImportParseError, resolve_local_ref},
recommendations::{document_finding, operation_finding},
model::{
ImportFinding, ImportFindingSeverity, RestImportDocument, RestImportOperation,
RestImportParameter, RestParameterLocation,
},
normalize::ImportParseError,
recommendations::{document_blocker, document_finding, operation_finding},
};
pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseError> {
parse_document_v2(root)
}
fn parse_document_v2(root: &Value) -> Result<RestImportDocument, ImportParseError> {
let title = root
.pointer("/info/title")
.and_then(Value::as_str)
@@ -15,11 +22,15 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.to_owned();
let servers = swagger_servers(root);
let mut findings = Vec::new();
let mut internal_finding_locations = Vec::new();
if servers.is_empty() {
findings.push(document_finding(
"missing_servers",
"В Swagger 2.0 документе не указаны host/schemes, base URL нужно будет выбрать вручную.",
));
internal_finding_locations.push(Some(crate::rest::model::SourceLocation {
pointer: String::new(),
}));
}
let mut operations = Vec::new();
@@ -29,20 +40,95 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.ok_or(ImportParseError::UnsupportedDocument)?;
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"] {
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 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
.iter()
.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
.into_iter()
.filter(|parameter| parameter.name != "body")
@@ -64,20 +150,22 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
tags: operation_value
.get("tags")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect()
})
.unwrap_or_default(),
tags: tags.tags,
parameters: operation_parameters,
request_body_schema,
response_schema: response_schema(root, operation_value),
request_body_schema: request_body_schema
.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(),
findings: swagger_operation_findings(path, operation_value),
});
@@ -91,9 +179,201 @@ pub fn parse_document(root: &Value) -> Result<RestImportDocument, ImportParseErr
servers,
operations,
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> {
let Some(host) = root.get("host").and_then(Value::as_str) else {
return Vec::new();
@@ -116,14 +396,51 @@ fn swagger_servers(root: &Value) -> Vec<String> {
.collect()
}
fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
value
fn parameters(_root: &Value, value: Option<&Value>, base_pointer: &str) -> ParsedParameters {
let mut findings = Vec::new();
let parameters = value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let item = resolve_local_ref(root, item, 0);
.enumerate()
.filter_map(|(index, item)| {
let pointer = format!("{base_pointer}/{index}");
{
let Some(object) = item.as_object() else {
findings.push(parameter_blocker(
"invalid_parameter",
"Параметр должен быть объектом и не был интерпретирован.",
&pointer,
));
return None;
};
if object.contains_key("$ref") {
findings.push(parameter_blocker(
"unresolved_parameter_reference",
"Параметр по $ref требует разрешения перед импортом.",
&pointer,
));
return None;
}
if !object.get("name").is_some_and(Value::is_string) {
findings.push(parameter_blocker(
"missing_parameter_name",
"У параметра отсутствует строковое поле name.",
&pointer,
));
return None;
}
if !object.get("in").is_some_and(Value::is_string) {
findings.push(parameter_blocker(
"missing_parameter_location",
"У параметра отсутствует строковое поле in.",
&pointer,
));
return None;
}
}
let item = item.clone();
let raw_location = item.get("in").and_then(Value::as_str)?;
if raw_location == "body" {
return Some(RestImportParameter {
@@ -137,16 +454,37 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
schema: item
.get("schema")
.map(|schema| resolve_local_ref(root, schema, 0)),
schema: item.get("schema").cloned(),
schema_source_location: item.get("schema").map(|_| {
crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}/schema"),
}
}),
source_location: crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
},
});
}
let location = match raw_location {
"path" => RestParameterLocation::Path,
"query" => RestParameterLocation::Query,
"header" => RestParameterLocation::Header,
_ => return None,
"cookie" => {
findings.push(parameter_blocker(
"unsupported_cookie_parameter",
"Cookie parameter пока не поддерживается и не был импортирован.",
&pointer,
));
return None;
}
_ => {
findings.push(parameter_blocker(
"unsupported_parameter_location",
"Параметр использует неподдерживаемое значение in и не был импортирован.",
&pointer,
));
return None;
}
};
Some(RestImportParameter {
name: item.get("name").and_then(Value::as_str)?.to_owned(),
@@ -160,22 +498,95 @@ fn parameters(root: &Value, value: Option<&Value>) -> Vec<RestImportParameter> {
.get("description")
.and_then(Value::as_str)
.map(ToOwned::to_owned),
schema: swagger_parameter_schema(root, &item),
schema: swagger_parameter_schema(_root, &item),
schema_source_location: Some(crate::rest::model::SourceLocation {
pointer: format!("{base_pointer}/{index}"),
}),
source_location: crate::rest::model::SourceLocation {
pointer,
},
})
})
.collect()
})
.unwrap_or_default()
.unwrap_or_default();
ParsedParameters {
parameters,
findings,
}
}
fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option<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") {
return Some(resolve_local_ref(root, schema, 0));
return Some(schema.clone());
}
let mut schema = serde_json::Map::new();
for key in ["type", "format", "items", "enum", "default", "description"] {
if let Some(value) = parameter.get(key) {
schema.insert(key.to_owned(), resolve_local_ref(root, value, 0));
schema.insert(key.to_owned(), value.clone());
}
}
if schema.is_empty() {
@@ -185,18 +596,48 @@ fn swagger_parameter_schema(root: &Value, parameter: &Value) -> Option<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()?;
for code in ["200", "201", "202", "default"] {
let Some(response) = responses.get(code) else {
continue;
};
let response = resolve_local_ref(root, response, 0);
if let Some(schema) = response.get("schema") {
return Some(resolve_local_ref(root, schema, 0));
let mut numeric = responses
.iter()
.filter_map(|(code, response)| {
(code.len() == 3)
.then(|| code.parse::<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_schema_at(response, operation_pointer, code) {
return Some(schema);
}
}
None
responses
.get("default")
.and_then(|response| response_schema_at(response, operation_pointer, "default"))
}
fn response_schema_at(
response: &Value,
operation_pointer: &str,
code: &str,
) -> Option<(Value, crate::rest::model::SourceLocation)> {
response.get("schema").map(|schema| {
(
schema.clone(),
crate::rest::model::SourceLocation {
pointer: format!(
"{operation_pointer}/responses/{}/schema",
code.replace('~', "~0").replace('/', "~1")
),
},
)
})
}
fn swagger_operation_findings(
@@ -232,3 +673,12 @@ fn method_from_lower(value: &str) -> Option<HttpMethod> {
_ => 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,
}
}