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
+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,