Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ 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).
- 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

### Fixed
Expand Down
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
253 changes: 244 additions & 9 deletions src/analysis.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Self> {
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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -4615,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
Expand Down Expand Up @@ -5737,7 +5737,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<ArrayItemType> {
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.
Expand Down Expand Up @@ -6025,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<OpenApiSpec> {
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::<Vec<_>>();
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<String> {
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<String> {
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")
Expand Down
7 changes: 7 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
Loading
Loading