feat(import): add deterministic OpenAPI normalized IR
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user