504 lines
15 KiB
Rust
504 lines
15 KiB
Rust
use std::collections::BTreeMap;
|
|
|
|
use crank_core::{HttpMethod, RestTarget, ToolDescription, WizardState};
|
|
use crank_mapping::MappingSet;
|
|
use crank_schema::Schema;
|
|
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-v3";
|
|
pub const PROJECTION_VERSION: &str = "preview-v3";
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, 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,
|
|
/// Maximum number of reference hops followed from one source location.
|
|
pub max_reference_depth: usize,
|
|
/// Maximum number of `$ref` occurrences inspected across the bundle.
|
|
pub max_references: usize,
|
|
/// Maximum number of immutable external documents supplied to the pure resolver.
|
|
pub max_reference_documents: usize,
|
|
/// Maximum number of nodes copied while expanding resolved references.
|
|
pub max_expanded_nodes: usize,
|
|
pub max_external_document_bytes: usize,
|
|
/// Signals that orchestration enabled external fetching. It never permits
|
|
/// I/O in this crate; it only distinguishes default-deny from a missing or
|
|
/// rejected supplied snapshot in exact findings.
|
|
pub external_references_enabled: bool,
|
|
}
|
|
|
|
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,
|
|
max_reference_depth: 32,
|
|
max_references: 4_096,
|
|
max_reference_documents: 32,
|
|
max_expanded_nodes: 100_000,
|
|
max_external_document_bytes: 256 * 1024,
|
|
external_references_enabled: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
/// Immutable external input for the pure reference resolver. The caller owns
|
|
/// URL policy and I/O; `crank-import` only consumes already verified bytes.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub struct ExternalDocumentSnapshot {
|
|
pub canonical_uri: String,
|
|
pub digest: SourceDigest,
|
|
pub document: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ResolvedReferenceNode {
|
|
pub snapshot_digest: SourceDigest,
|
|
pub location: SourceLocation,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ResolvedReferenceEdge {
|
|
pub source: ResolvedReferenceNode,
|
|
pub target: ResolvedReferenceNode,
|
|
pub recursive: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ResolvedReferenceGraph {
|
|
/// Sorted, deduplicated immutable dependency identities. Canonical URLs
|
|
/// deliberately do not enter the IR or public diagnostics.
|
|
#[serde(default)]
|
|
pub dependency_digests: Vec<SourceDigest>,
|
|
#[serde(default)]
|
|
pub edges: Vec<ResolvedReferenceEdge>,
|
|
}
|
|
|
|
/// 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>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub discriminator: Option<NormalizedDiscriminator>,
|
|
#[serde(default)]
|
|
pub constraints: NormalizedSchemaConstraints,
|
|
pub kind: NormalizedSchemaKind,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct NormalizedSchemaConstraints {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub minimum: Option<f64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub maximum: Option<f64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub min_length: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub max_length: Option<u64>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub pattern: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct NormalizedDiscriminator {
|
|
pub property_name: String,
|
|
#[serde(default)]
|
|
pub mapping: BTreeMap<String, String>,
|
|
}
|
|
|
|
#[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>,
|
|
#[serde(default)]
|
|
pub reference_graph: ResolvedReferenceGraph,
|
|
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 {
|
|
Info,
|
|
Warning,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct ImportFinding {
|
|
pub code: String,
|
|
pub severity: ImportFindingSeverity,
|
|
pub message: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub operation_key: Option<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct ImportSourcePreview {
|
|
pub format: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub version: Option<String>,
|
|
pub title: String,
|
|
#[serde(default)]
|
|
pub servers: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct ImportPreview {
|
|
pub source: ImportSourcePreview,
|
|
pub groups: Vec<ImportGroupPreview>,
|
|
#[serde(default)]
|
|
pub findings: Vec<ImportFinding>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct ImportGroupPreview {
|
|
pub key: String,
|
|
pub title: String,
|
|
pub operations: Vec<ImportOperationCandidate>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct ImportOperationCandidate {
|
|
pub key: String,
|
|
pub method: HttpMethod,
|
|
pub path: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub operation_id: Option<String>,
|
|
pub suggested_name: String,
|
|
pub suggested_display_name: String,
|
|
pub description: String,
|
|
pub category: String,
|
|
pub input_fields: usize,
|
|
pub output_fields: usize,
|
|
#[serde(default)]
|
|
pub server_urls: Vec<String>,
|
|
#[serde(default)]
|
|
pub findings: Vec<ImportFinding>,
|
|
pub draft: RestImportCandidate,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct RestImportCandidate {
|
|
pub name: String,
|
|
pub display_name: String,
|
|
pub category: String,
|
|
pub target: RestTarget,
|
|
pub input_schema: Schema,
|
|
pub output_schema: Schema,
|
|
pub input_mapping: MappingSet,
|
|
pub output_mapping: MappingSet,
|
|
pub tool_description: ToolDescription,
|
|
pub wizard_state: Option<WizardState>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct RestImportDocument {
|
|
pub format: String,
|
|
pub version: Option<String>,
|
|
pub title: String,
|
|
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, Serialize, Deserialize)]
|
|
pub struct RestImportOperation {
|
|
pub key: String,
|
|
pub method: HttpMethod,
|
|
pub path: String,
|
|
pub operation_id: Option<String>,
|
|
pub summary: Option<String>,
|
|
pub description: Option<String>,
|
|
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, 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>,
|
|
}
|
|
|
|
fn empty_source_location() -> SourceLocation {
|
|
SourceLocation {
|
|
pointer: String::new(),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RestParameterLocation {
|
|
Path,
|
|
Query,
|
|
Header,
|
|
}
|