Files
crank/crates/crank-import/src/rest/model.rs
T

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