From 8a66eb574bf38b24ff70f973394c1ffbc3f6e4ba Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 10:58:44 -0600 Subject: [PATCH 1/2] fix: parse draft-04 positional `items: [A, B]` as a tuple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SchemaDetails.items` only modeled the JSON Schema 2020-12 single-schema form, so a positional array failed the untagged `Schema` enum and took the whole document down. Tooling that predates 2020-12 still emits the draft-04 tuple spelling under `openapi: "3.1.0"` — FastAPI/pydantic v1 does. Model both spellings with an `Items` enum and unify them with `prefixItems` through `SchemaDetails::positional_items`, leaving `item_schema` for the single-schema form. Generated types are unchanged: a tuple generates exactly what `prefixItems` already generated. The embedded Axum validator bundle rewrites the tuple into `prefixItems` for 2020-12, where an array-valued `items` is ignored — without it the declared positions went unchecked at runtime. Refs #60 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- CHANGELOG.md | 8 +++ README.md | 1 + src/analysis.rs | 11 ++-- src/openapi.rs | 43 ++++++++++++- src/server/validation.rs | 25 ++++++++ tests/tuple_items_test.rs | 130 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 212 insertions(+), 6 deletions(-) create mode 100644 tests/tuple_items_test.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b18d4e..2af9f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ when correcting output that was wrong or incomplete on the wire. ## [Unreleased] +### Fixed + +- The draft-04 positional tuple form `items: [A, B]` — still emitted under + `openapi: "3.1.0"` by FastAPI/pydantic v1 — now parses instead of failing the + whole document, generating what the 2020-12 spelling `prefixItems: [A, B]` + generates. Generated Axum validators receive the canonical spelling, so the + positions are actually checked at runtime (#60). + ## [0.12.3] - 2026-08-22 ### Fixed diff --git a/README.md b/README.md index a38db65..cea220d 100644 --- a/README.md +++ b/README.md @@ -556,6 +556,7 @@ focused fixture when relying on a less-common OpenAPI or JSON Schema keyword. |---|---| | `type` as array (e.g. `["string", "null"]`) | typed + used for nullability | | `prefixItems`, `unevaluatedItems`, `contains` / `minContains` / `maxContains` | typed | +| draft-04 positional `items: [A, B]` (FastAPI/pydantic v1 emits it under 3.1) | typed as `prefixItems` | | `patternProperties`, `propertyNames`, `unevaluatedProperties` | typed | | `dependentRequired`, `dependentSchemas`, `if` / `then` / `else` | typed | | `contentEncoding`, `contentMediaType`, `contentSchema` | typed | diff --git a/src/analysis.rs b/src/analysis.rs index 107a442..ba94a2f 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -1155,7 +1155,7 @@ impl SchemaAnalyzer { if type_hint == "Array" && matches!(schema.schema_type(), Some(OpenApiSchemaType::Array)) { - if let Some(items_schema) = &schema.details().items { + if let Some(items_schema) = schema.details().item_schema() { // Check for specific item types if let Some(item_type) = items_schema.schema_type() { match item_type { @@ -3967,9 +3967,9 @@ impl SchemaAnalyzer { let details = schema.details(); // Check if items field is present - if let Some(items_schema) = &details.items { + if let Some(items_schema) = details.item_schema() { // Analyze the item type - let item_type = match items_schema.as_ref() { + let item_type = match items_schema { Schema::Reference { reference, .. } => { // Array of referenced types let target = self @@ -4328,7 +4328,8 @@ impl SchemaAnalyzer { self.analyze_array_schema(schema, context_name, dependencies)?; // Create a unique name for this array type in the union - let array_type_name = if let Some(items_schema) = &schema.details().items { + let array_type_name = if let Some(items_schema) = schema.details().item_schema() + { if let Some(ref_str) = items_schema.reference() { if let Some(item_type_name) = self.extract_schema_name(ref_str) { dependencies.insert(item_type_name.to_string()); @@ -5737,7 +5738,7 @@ impl SchemaAnalyzer { /// items stay plain `String`: the op-scoped enum synthesis (issue #10) /// is wired for scalar params only. fn array_param_item_type(&self, schema: &crate::openapi::Schema) -> Option { - let items = schema.details().items.as_deref()?; + let items = schema.details().item_schema()?; // AWS query-protocol specs wrap item refs in an annotation-only allOf // (`items: {allOf: [$ref, {xml: ...}]}`). See through the wrapper when // every sibling is annotation-only, mirroring the type-alias rule. diff --git a/src/openapi.rs b/src/openapi.rs index e998da9..91bf6bb 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -246,7 +246,7 @@ pub struct SchemaDetails { // Array-specific #[serde(skip_serializing_if = "Option::is_none")] - pub items: Option>, + pub items: Option, // Number-specific #[serde(skip_serializing_if = "Option::is_none")] @@ -369,6 +369,24 @@ pub enum ExclusiveBound { Number(f64), } +/// The `items` keyword. +/// +/// JSON Schema 2020-12 spells it as a single schema applied to every element. +/// Draft-04 also allowed a positional array — the tuple form — and tooling +/// that predates 2020-12 (FastAPI/pydantic v1 emits it under +/// `openapi: "3.1.0"`) still writes `items: [A, B]` where 2020-12 would write +/// `prefixItems: [A, B]`. Both spellings parse; consumers reach positional +/// entries through [`SchemaDetails::positional_items`], which unifies them +/// with `prefixItems`. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Items { + /// 2020-12 `items`: one schema for every element. + Single(Box), + /// Draft-04 tuple form: one schema per position. + Positional(Vec), +} + #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(untagged)] pub enum AdditionalProperties { @@ -861,6 +879,29 @@ impl Schema { } impl SchemaDetails { + /// The schema every array element must satisfy, i.e. `items` in its + /// 2020-12 single-schema spelling. Returns `None` for the draft-04 tuple + /// form, which constrains positions rather than every element — read that + /// through [`Self::positional_items`]. + pub fn item_schema(&self) -> Option<&Schema> { + match self.items.as_ref()? { + Items::Single(schema) => Some(schema), + Items::Positional(_) => None, + } + } + + /// Positional element schemas, from either spelling: 2020-12 + /// `prefixItems` or the draft-04 `items: [A, B]` tuple form. + pub fn positional_items(&self) -> Option<&[Schema]> { + if let Some(prefix_items) = self.prefix_items.as_deref() { + return Some(prefix_items); + } + match self.items.as_ref()? { + Items::Positional(schemas) => Some(schemas), + Items::Single(_) => None, + } + } + /// Check if this schema is nullable pub fn is_nullable(&self) -> bool { self.nullable.unwrap_or(false) diff --git a/src/server/validation.rs b/src/server/validation.rs index 98499cc..2f1c1f0 100644 --- a/src/server/validation.rs +++ b/src/server/validation.rs @@ -273,6 +273,9 @@ fn normalize_schema(value: &Value, draft: ValidationDraft) -> Value { return value.clone(); }; let mut schema = source.clone(); + if draft == ValidationDraft::Draft202012 { + rewrite_tuple_items(&mut schema); + } for key in [ "items", "additionalProperties", @@ -369,6 +372,28 @@ fn normalize_schema(value: &Value, draft: ValidationDraft) -> Value { Value::Object(schema) } +/// Rewrite the draft-04 tuple spelling `items: [A, B]` into 2020-12's +/// `prefixItems`, with `additionalItems` becoming `items`. +/// +/// Tooling that predates 2020-12 still emits the tuple form under +/// `openapi: "3.1.0"` (FastAPI/pydantic v1 does). Left as-is, the embedded +/// 2020-12 validator ignores the keyword and the positions go unchecked. +fn rewrite_tuple_items(schema: &mut Map) { + if !schema.get("items").is_some_and(Value::is_array) { + return; + } + let Some(Value::Array(positional)) = schema.remove("items") else { + return; + }; + // An explicit `prefixItems` is already canonical and wins. + schema + .entry("prefixItems".to_string()) + .or_insert(Value::Array(positional)); + if let Some(additional) = schema.remove("additionalItems") { + schema.insert("items".to_string(), additional); + } +} + /// Normalize an OpenAPI `pattern` (ECMA-262 / Java-flavoured) into a pattern /// Rust's linear-time `regex` engine can compile offline. fn normalize_pattern(pattern: &str) -> String { diff --git a/tests/tuple_items_test.rs b/tests/tuple_items_test.rs new file mode 100644 index 0000000..df44bcb --- /dev/null +++ b/tests/tuple_items_test.rs @@ -0,0 +1,130 @@ +//! Draft-04 positional `items` (issue #60). +//! +//! JSON Schema 2020-12 spells a tuple `prefixItems: [A, B]`, but tooling that +//! predates it still emits the draft-04 positional form `items: [A, B]` under +//! `openapi: "3.1.0"` — FastAPI/pydantic v1 does. The generator used to reject +//! those documents outright with "data did not match any variant of untagged +//! enum Schema" and no indication of where the offending node lived. + +use openapi_to_rust::config::{ServerSection, ServerValidationSection}; +use openapi_to_rust::openapi::{Items, Schema}; +use openapi_to_rust::server::codegen::ServerCodegen; +use openapi_to_rust::{CodeGenerator, GeneratorConfig, SchemaAnalyzer}; +use serde_json::json; + +fn spec_with_pair_keyword(keyword: &str) -> serde_json::Value { + json!({ + "openapi": "3.1.0", + "info": { "title": "repro", "version": "1.0.0" }, + "paths": { "/example": { "post": { + "operationId": "example", + "requestBody": { "required": true, "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/Body" } } + }}, + "responses": { "200": { "description": "OK" } } + }}}, + "components": { "schemas": { "Body": { + "type": "object", + "required": ["pair"], + "properties": { "pair": { + "type": "array", + "minItems": 2, + "maxItems": 2, + keyword: [{ "type": "string" }, { "type": "string" }] + }} + }}} + }) +} + +fn generate(spec: serde_json::Value) -> String { + let mut analysis = SchemaAnalyzer::new(spec) + .expect("spec parses") + .analyze() + .expect("spec analyzes"); + CodeGenerator::new(GeneratorConfig::default()) + .generate(&mut analysis) + .expect("code generates") +} + +#[test] +fn positional_items_generate_the_same_types_as_prefix_items() { + let tuple_form = generate(spec_with_pair_keyword("items")); + let canonical_form = generate(spec_with_pair_keyword("prefixItems")); + + assert!( + tuple_form.contains("pub pair: Vec"), + "positional items must produce an array field, got:\n{tuple_form}" + ); + assert_eq!( + tuple_form, canonical_form, + "`items: [A, B]` must generate exactly what `prefixItems: [A, B]` generates" + ); +} + +#[test] +fn both_tuple_spellings_read_back_through_positional_items() { + let tuple: Schema = serde_json::from_value(json!({ + "type": "array", + "items": [{ "type": "string" }, { "type": "integer" }] + })) + .expect("draft-04 tuple parses"); + let canonical: Schema = serde_json::from_value(json!({ + "type": "array", + "prefixItems": [{ "type": "string" }, { "type": "integer" }] + })) + .expect("2020-12 tuple parses"); + let single: Schema = serde_json::from_value(json!({ + "type": "array", + "items": { "type": "string" } + })) + .expect("single-schema items parses"); + + assert_eq!(tuple.details().positional_items().map(<[_]>::len), Some(2)); + assert_eq!( + canonical.details().positional_items().map(<[_]>::len), + Some(2) + ); + assert!(tuple.details().item_schema().is_none()); + assert!(single.details().positional_items().is_none()); + assert!(matches!(single.details().items, Some(Items::Single(_)))); +} + +#[test] +fn positional_items_reach_the_server_validator_as_prefix_items() { + let spec = spec_with_pair_keyword("items"); + let analysis = SchemaAnalyzer::new(spec) + .expect("spec parses") + .analyze() + .expect("spec analyzes"); + let server = ServerSection { + framework: "axum".into(), + operations: vec!["example".into()], + prune_models: true, + validation: ServerValidationSection::default(), + }; + let config = GeneratorConfig { + enable_async_client: false, + server: Some(server.clone()), + ..Default::default() + }; + let files = ServerCodegen::new(&config, &analysis, &server) + .generate() + .expect("server generates"); + let validation = files + .iter() + .find(|file| file.path.ends_with("validation.rs")) + .expect("validation module is emitted"); + + // A 2020-12 validator ignores an array-valued `items`, so the tuple must be + // rewritten or the positions would go unchecked at runtime. + assert!( + validation.content.contains("prefixItems"), + "embedded bundle must carry the canonical spelling:\n{}", + validation.content + ); + assert!( + !validation.content.contains(r#"\"items\":["#), + "embedded bundle must not keep the draft-04 spelling:\n{}", + validation.content + ); +} From 60ddb60a487b9221fe8ea023c929437ab1f5191b Mon Sep 17 00:00:00 2001 From: James Lal Date: Wed, 26 Aug 2026 10:59:04 -0600 Subject: [PATCH 2/2] fix: locate spec parse failures by JSON pointer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A document that failed to deserialize reported only serde's innermost message — for the untagged `Schema` enum, "data did not match any variant of untagged enum Schema" with no field, schema name, or position. Finding the offending node in a real spec meant bisecting component schemas by hand. Track the deserialization path with `serde_path_to_error`, then refine it: serde's own path stops at the first `#[serde(flatten)]` or untagged enum it buffers through (`#/paths` for an inline schema), so the descent continues here — through OpenAPI structure to real schema positions, then keyword-first inside the schema that failed. Only nodes in a schema position are tested. Inferring from shape does not work: a `properties` map whose single property is named `properties` fails to parse as a schema while being perfectly valid, and blaming it would point the author at the wrong node. Errors now read: Failed to parse OpenAPI spec at #/components/schemas/Body/properties/pair/items: data did not match any variant of untagged enum Schema Refs #60 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry --- CHANGELOG.md | 4 + Cargo.lock | 12 ++ Cargo.toml | 1 + src/analysis.rs | 242 +++++++++++++++++++++++++++++++++++++- src/error.rs | 7 ++ tests/tuple_items_test.rs | 93 +++++++++++++++ 6 files changed, 355 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2af9f93..1bcf638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ when correcting output that was wrong or incomplete on the wire. whole document, generating what the 2020-12 spelling `prefixItems: [A, B]` generates. Generated Axum validators receive the canonical spelling, so the positions are actually checked at runtime (#60). +- Document parse failures now name the offending node by JSON Pointer, e.g. + `Failed to parse OpenAPI spec at #/components/schemas/Body/properties/pair/items`, + instead of reporting only "data did not match any variant of untagged enum + Schema" with no way to find it in a large spec (#60). ## [0.12.3] - 2026-08-22 diff --git a/Cargo.lock b/Cargo.lock index f981d0e..7dd18b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1136,6 +1136,7 @@ dependencies = [ "reqwest", "serde", "serde_json", + "serde_path_to_error", "serde_yaml", "specta", "syn 2.0.115", @@ -1693,6 +1694,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" diff --git a/Cargo.toml b/Cargo.toml index 6b62886..d3666e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ specta = { version = "2.0.0-rc", features = ["derive"], optional = true } heck = "0.5" jsonschema = { version = "0.49", default-features = false } regex = "1" +serde_path_to_error = "0.1.20" [dev-dependencies] serde_yaml = "0.9" diff --git a/src/analysis.rs b/src/analysis.rs index ba94a2f..522da44 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -1,6 +1,7 @@ use crate::openapi::{Discriminator, OpenApiSpec, Schema, SchemaType as OpenApiSchemaType}; use crate::type_mapping::TypeMapper; use crate::{GeneratorError, Result}; +use serde::Deserialize; use serde_json::Value; use std::collections::{BTreeMap, HashSet}; use std::path::Path; @@ -1092,8 +1093,7 @@ impl SchemaAnalyzer { /// points use this so user TOML config drives type generation. pub fn with_type_mapper(mut openapi_spec: Value, type_mapper: TypeMapper) -> Result { disambiguate_component_schema_names(&mut openapi_spec); - let spec: OpenApiSpec = - serde_json::from_value(openapi_spec.clone()).map_err(GeneratorError::ParseError)?; + let spec: OpenApiSpec = parse_spec_document(&openapi_spec)?; let schemas = Self::extract_schemas(&spec)?; let component_parameters = spec @@ -4616,8 +4616,7 @@ impl SchemaAnalyzer { /// Analyze OpenAPI operations to extract request/response schemas fn analyze_operations(&mut self, analysis: &mut SchemaAnalysis) -> Result<()> { - let spec: crate::openapi::OpenApiSpec = serde_json::from_value(self.openapi_spec.clone()) - .map_err(GeneratorError::ParseError)?; + let spec: crate::openapi::OpenApiSpec = parse_spec_document(&self.openapi_spec)?; // Operation IDs are emitted into one Rust module, so collision // detection spans paths and webhooks. Index their canonical Rust type // names once instead of re-canonicalizing every previously analyzed @@ -6026,6 +6025,241 @@ impl SchemaAnalyzer { } } +/// Deserialize the whole document, locating any failure to the node that +/// caused it. +/// +/// `serde_json::from_value` reports only serde's innermost message — for the +/// untagged `Schema` enum that is "data did not match any variant of untagged +/// enum Schema", with no field, schema name, or position. Tracking the path +/// turns that into a JSON Pointer the author can jump straight to (issue #60). +fn parse_spec_document(openapi_spec: &Value) -> Result { + serde_path_to_error::deserialize(openapi_spec).map_err(|error| { + let mut pointer = json_pointer(error.path()); + // Untagged enums deserialize from a buffered copy, so serde's path + // stops at the outermost `Schema` — usually the component schema. + // Walk the failing subtree to name the node that actually failed. + pointer.push_str(&refine_schema_failure(openapi_spec, &pointer)); + GeneratorError::ParseErrorAt { + pointer, + message: error.into_inner().to_string(), + } + }) +} + +/// Schema keywords holding a single subschema. +const SUBSCHEMA_KEYWORDS: [&str; 11] = [ + "items", + "additionalProperties", + "propertyNames", + "unevaluatedProperties", + "unevaluatedItems", + "contains", + "contentSchema", + "if", + "then", + "else", + "not", +]; + +/// Schema keywords holding a list of subschemas. +const SUBSCHEMA_LIST_KEYWORDS: [&str; 4] = ["oneOf", "anyOf", "allOf", "prefixItems"]; + +/// Schema keywords holding a map of named subschemas. +const SUBSCHEMA_MAP_KEYWORDS: [&str; 5] = [ + "properties", + "patternProperties", + "dependentSchemas", + "$defs", + "definitions", +]; + +/// Budget on parse attempts while refining a located failure. Refinement runs +/// only on the error path, but a multi-megabyte document should still not turn +/// one bad keyword into an unbounded search. +const REFINE_PARSE_BUDGET: usize = 20_000; + +/// Extend a located parse failure with the pointer suffix of the malformed +/// schema below it, so the reported pointer names the offending keyword rather +/// than the enclosing component schema, path item, or `paths` map. +/// +/// Serde's own path stops at the first `#[serde(flatten)]` or untagged enum it +/// buffers through — for a document that is `#/paths` or the component schema — +/// so the rest of the descent happens here. +fn refine_schema_failure(openapi_spec: &Value, pointer: &str) -> String { + let Some(path) = pointer.strip_prefix('#') else { + return String::new(); + }; + let Some(node) = openapi_spec.pointer(path) else { + return String::new(); + }; + let segments = path.split('/').skip(1).collect::>(); + let last = segments.last().copied().unwrap_or_default(); + let parent = segments + .len() + .checked_sub(2) + .map(|index| segments[index]) + .unwrap_or_default(); + + if last == "schema" || holds_schemas(parent) { + return if parses_as_schema(node) { + String::new() + } else { + deepest_schema_failure(node) + }; + } + + let mut budget = REFINE_PARSE_BUDGET; + locate_failing_schema(node, holds_schemas(last), &mut budget).unwrap_or_default() +} + +/// Whether a key's members are schemas: the Components `schemas` map and the +/// JSON Schema `$defs` / `definitions` maps. +fn holds_schemas(key: &str) -> bool { + matches!(key, "schemas" | "$defs" | "definitions") +} + +/// Walk OpenAPI structure looking for the malformed schema, then drill into it +/// keyword-first. +/// +/// Only nodes in a schema position are tested. Guessing from shape does not +/// work: a `properties` map whose single property is named `properties` is +/// indistinguishable from a schema by its keys alone, and fails to parse as one +/// — naming it would point the author at a node that is perfectly valid. +fn locate_failing_schema( + node: &Value, + children_are_schemas: bool, + budget: &mut usize, +) -> Option { + for (segment, key, child) in child_nodes(node) { + if *budget == 0 { + return None; + } + *budget -= 1; + if children_are_schemas || key == "schema" { + if !parses_as_schema(child) { + return Some(format!("/{segment}{}", deepest_schema_failure(child))); + } + continue; + } + if let Some(rest) = locate_failing_schema(child, holds_schemas(key), budget) { + return Some(format!("/{segment}{rest}")); + } + } + None +} + +fn parses_as_schema(node: &Value) -> bool { + Schema::deserialize(node).is_ok() +} + +/// Depth-first search inside a malformed schema for the deepest subschema that +/// also fails to parse, so the pointer names the offending keyword rather than +/// the schema that contains it. The caller guarantees `node` already failed. +fn deepest_schema_failure(node: &Value) -> String { + let Some(object) = node.as_object() else { + return String::new(); + }; + + let descend = |segment: String, child: &Value| -> Option { + if parses_as_schema(child) { + return None; + } + Some(format!("/{segment}{}", deepest_schema_failure(child))) + }; + + for keyword in SUBSCHEMA_KEYWORDS { + if let Some(child) = object.get(keyword) + && let Some(suffix) = descend(escape_pointer_segment(keyword), child) + { + return suffix; + } + } + for keyword in SUBSCHEMA_LIST_KEYWORDS { + if let Some(Value::Array(children)) = object.get(keyword) { + for (index, child) in children.iter().enumerate() { + if let Some(suffix) = descend( + format!("{}/{index}", escape_pointer_segment(keyword)), + child, + ) { + return suffix; + } + } + } + } + for keyword in SUBSCHEMA_MAP_KEYWORDS { + if let Some(Value::Object(children)) = object.get(keyword) { + for (name, child) in children { + if let Some(suffix) = descend( + format!( + "{}/{}", + escape_pointer_segment(keyword), + escape_pointer_segment(name) + ), + child, + ) { + return suffix; + } + } + } + } + String::new() +} + +/// Object members and array elements, paired with their JSON Pointer segment +/// and raw key. Scalars have no children and are skipped, as are members that +/// hold data rather than schemas: an `x-` extension or an `example` payload is +/// free-form JSON that never fails to deserialize, so anything schema-shaped +/// found in there is a coincidence, not the failure being located. +fn child_nodes(node: &Value) -> Vec<(String, &str, &Value)> { + const DATA_KEYWORDS: [&str; 5] = ["example", "examples", "default", "enum", "const"]; + + match node { + Value::Object(members) => members + .iter() + .filter(|(name, child)| { + (child.is_object() || child.is_array()) + && !name.starts_with("x-") + && !DATA_KEYWORDS.contains(&name.as_str()) + }) + .map(|(name, child)| (escape_pointer_segment(name), name.as_str(), child)) + .collect(), + Value::Array(elements) => elements + .iter() + .enumerate() + .filter(|(_, child)| child.is_object() || child.is_array()) + .map(|(index, child)| (index.to_string(), "", child)) + .collect(), + _ => Vec::new(), + } +} + +fn escape_pointer_segment(segment: &str) -> String { + segment.replace('~', "~0").replace('/', "~1") +} + +/// Render a serde path as an RFC 6901 JSON Pointer (`#/components/schemas/Foo`) +/// so it can be pasted into any spec tooling. `~` and `/` inside a key are +/// escaped per the RFC. +fn json_pointer(path: &serde_path_to_error::Path) -> String { + use serde_path_to_error::Segment; + + let mut pointer = String::from("#"); + for segment in path.iter() { + match segment { + Segment::Seq { index } => { + pointer.push('/'); + pointer.push_str(&index.to_string()); + } + Segment::Map { key } | Segment::Enum { variant: key } => { + pointer.push('/'); + pointer.push_str(&escape_pointer_segment(key)); + } + Segment::Unknown => pointer.push_str("/?"), + } + } + pointer +} + fn disambiguate_component_schema_names(openapi_spec: &mut Value) { let Some(schemas) = openapi_spec .pointer_mut("/components/schemas") diff --git a/src/error.rs b/src/error.rs index e1e7c2c..1d5bcf0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -5,6 +5,13 @@ pub enum GeneratorError { #[error("Failed to parse OpenAPI spec: {0}")] ParseError(#[from] serde_json::Error), + /// Document-level parse failure located to the node that failed. Serde's + /// own message for an untagged enum ("data did not match any variant of + /// untagged enum Schema") names no field, so a large spec would otherwise + /// have to be bisected by hand to find the offending node. + #[error("Failed to parse OpenAPI spec at {pointer}: {message}")] + ParseErrorAt { pointer: String, message: String }, + #[error("Unresolved schema reference: {0}")] UnresolvedReference(String), diff --git a/tests/tuple_items_test.rs b/tests/tuple_items_test.rs index df44bcb..acd1640 100644 --- a/tests/tuple_items_test.rs +++ b/tests/tuple_items_test.rs @@ -128,3 +128,96 @@ fn positional_items_reach_the_server_validator_as_prefix_items() { validation.content ); } + +#[test] +fn schema_parse_failures_name_the_offending_node() { + let mut spec = spec_with_pair_keyword("items"); + spec["components"]["schemas"]["Body"]["properties"]["pair"]["items"] = json!(5); + + let error = match SchemaAnalyzer::new(spec) { + Ok(_) => panic!("a numeric `items` is not a schema"), + Err(error) => error.to_string(), + }; + + assert!( + error.contains("#/components/schemas/Body/properties/pair/items"), + "the error must point at the offending node, got: {error}" + ); +} + +#[test] +fn inline_schema_parse_failures_name_the_offending_node() { + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "repro", "version": "1.0.0" }, + "paths": { "/example": { "post": { + "operationId": "example", + "requestBody": { "required": true, "content": { + "application/json": { "schema": { + "type": "object", + "properties": { "pair": { "type": "array", "items": 5 } } + }} + }}, + "responses": { "200": { "description": "OK" } } + }}} + }); + + let error = match SchemaAnalyzer::new(spec) { + Ok(_) => panic!("a numeric `items` is not a schema"), + Err(error) => error.to_string(), + }; + + assert!( + error.contains( + "#/paths/~1example/post/requestBody/content/application~1json/schema/properties/pair/items" + ), + "the error must point at the offending node, got: {error}" + ); +} + +#[test] +fn parse_failures_skip_valid_schemas_whose_property_names_look_like_keywords() { + // A `properties` map holding one property named `properties` fails to parse + // as a schema even though the document is valid, so shape alone cannot + // decide what is a schema. Ordered before the real failure on purpose. + let spec = json!({ + "openapi": "3.1.0", + "info": { "title": "repro", "version": "1.0.0" }, + "paths": { + "/a-valid": { "post": { + "operationId": "valid", + "requestBody": { "required": true, "content": { + "application/json": { "schema": { + "type": "object", + "properties": { "properties": { + "type": "array", + "description": "custom properties", + "items": { "type": "string" } + }} + }} + }}, + "responses": { "200": { "description": "OK" } } + }}, + "/b-broken": { "post": { + "operationId": "broken", + "requestBody": { "required": true, "content": { + "application/json": { "schema": { + "type": "object", + "properties": { "pair": { "type": "array", "items": 5 } } + }} + }}, + "responses": { "200": { "description": "OK" } } + }} + } + }); + + let error = match SchemaAnalyzer::new(spec) { + Ok(_) => panic!("a numeric `items` is not a schema"), + Err(error) => error.to_string(), + }; + + assert!( + error.contains("#/paths/~1b-broken/post/requestBody/content/application~1json/schema/properties/pair/items"), + "the error must name the malformed schema, not the valid one, got: {error}" + ); +}