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
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,27 @@ when correcting output that was wrong or incomplete on the wire.

## [Unreleased]

### Changed

- **Breaking (generated API).** Positional item schemas — 2020-12
`prefixItems` and the draft-04 `items: [A, B]` spelling — now generate typed
Rust tuples instead of `Vec<serde_json::Value>`, when the spec pins the
array's length (`minItems`/`maxItems`, `items: false`, or
`additionalItems: false`). A `[string, integer]` pair becomes
`(String, i64)`; a `$ref` position keeps its named type, and an inline object
position is hoisted to one. When no extras are allowed but the length varies
and every position shares a type, the array becomes `Vec<T>`.

An *open* `prefixItems` still generates `Vec<serde_json::Value>` on purpose:
it permits extra elements of any type, and a fixed-arity tuple would reject
payloads the spec allows (#62).

### Fixed

- `items: false` and `items: true` — 2020-12 boolean schemas, and the canonical
way to close a tuple — now parse instead of failing the document with "data
did not match any variant of untagged enum Schema" (#62).

## [0.13.0] - 2026-08-26

### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,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` |
| Fixed-length `prefixItems` | generated as Rust tuples, e.g. `(String, i64)` |
| `patternProperties`, `propertyNames`, `unevaluatedProperties` | typed |
| `dependentRequired`, `dependentSchemas`, `if` / `then` / `else` | typed |
| `contentEncoding`, `contentMediaType`, `contentSchema` | typed |
Expand Down
492 changes: 308 additions & 184 deletions src/analysis.rs

Large diffs are not rendered by default.

41 changes: 41 additions & 0 deletions src/generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1502,6 +1502,20 @@ impl CodeGenerator {
Ok(TokenStream::new())
}
}
SchemaType::Tuple { element_types } => {
let tuple_type = self.generate_tuple_type(element_types, analysis);
let type_name = format_ident!("{}", self.to_rust_type_name(&schema.name));
let doc_comment = if let Some(description) = &schema.description {
let sanitized = self.sanitize_doc_comment(description);
quote! { #[doc = #sanitized] }
} else {
TokenStream::new()
};
Ok(quote! {
#doc_comment
pub type #type_name = #tuple_type;
})
}
SchemaType::Array { item_type } => {
// Generate type alias for named array schemas.
//
Expand Down Expand Up @@ -2716,13 +2730,37 @@ impl CodeGenerator {
let inner_type = self.generate_array_item_type(item_type, analysis);
quote! { Vec<#inner_type> }
}
SchemaType::Tuple { element_types } => {
self.generate_tuple_type(element_types, analysis)
}
_ => {
// Fallback for complex types
quote! { serde_json::Value }
}
}
}

/// Render positional element types as a Rust tuple. serde reads and writes
/// these as JSON arrays of exactly this length, which is what makes the
/// analyzer's exact-length rule load-bearing.
fn generate_tuple_type(
&self,
element_types: &[crate::analysis::SchemaType],
analysis: &crate::analysis::SchemaAnalysis,
) -> TokenStream {
let elements = element_types
.iter()
.map(|element_type| self.generate_array_item_type(element_type, analysis))
.collect::<Vec<_>>();
// A one-element Rust tuple needs the trailing comma; `(T)` is just `T`,
// which serde would read as a bare value instead of a single-element
// array.
if let [only] = elements.as_slice() {
return quote! { (#only,) };
}
quote! { (#(#elements),*) }
}

fn generate_serde_field_attrs(
&self,
schema_name: &str,
Expand Down Expand Up @@ -3430,6 +3468,9 @@ impl CodeGenerator {
let inner_type = self.generate_array_item_type(item_type, analysis);
quote! { Vec<#inner_type> }
}
SchemaType::Tuple { element_types } => {
self.generate_tuple_type(element_types, analysis)
}
_ => {
// Fallback for complex types
quote! { serde_json::Value }
Expand Down
45 changes: 41 additions & 4 deletions src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,10 @@ pub enum Items {
Single(Box<Schema>),
/// Draft-04 tuple form: one schema per position.
Positional(Vec<Schema>),
/// 2020-12 boolean schema. `items: false` is the canonical way to close a
/// tuple — no elements beyond `prefixItems` — and `items: true` is the
/// no-op "anything goes" schema.
Bool(bool),
}

#[derive(Debug, Clone, Deserialize, Serialize)]
Expand Down Expand Up @@ -865,7 +869,7 @@ impl Schema {
// Infer from structure
if details.properties.is_some() {
Some(SchemaType::Object)
} else if details.items.is_some() {
} else if details.items.is_some() || details.prefix_items.is_some() {
Some(SchemaType::Array)
} else if details.enum_values.is_some() {
Some(SchemaType::String) // Assume string enum
Expand All @@ -882,11 +886,12 @@ 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`].
/// through [`Self::positional_items`] — and for a boolean schema, which
/// constrains nothing worth typing.
pub fn item_schema(&self) -> Option<&Schema> {
match self.items.as_ref()? {
Items::Single(schema) => Some(schema),
Items::Positional(_) => None,
Items::Positional(_) | Items::Bool(_) => None,
}
}

Expand All @@ -898,8 +903,40 @@ impl SchemaDetails {
}
match self.items.as_ref()? {
Items::Positional(schemas) => Some(schemas),
Items::Single(_) => None,
Items::Single(_) | Items::Bool(_) => None,
}
}

/// Whether the array admits no elements beyond its positional schemas.
///
/// This is not the default: `prefixItems: [A, B]` on its own permits extra
/// elements of any type. An array is closed only when 2020-12 `items:
/// false`, draft-04 `additionalItems: false`, or `maxItems` says so.
pub fn positional_items_are_closed(&self) -> bool {
let Some(positions) = self.positional_items() else {
return false;
};
if matches!(self.items, Some(Items::Bool(false))) {
return true;
}
if self.extra.get("additionalItems") == Some(&Value::Bool(false)) {
return true;
}
self.max_items
.is_some_and(|maximum| maximum <= positions.len() as u64)
}

/// Whether every valid instance has exactly one element per positional
/// schema — the only case a fixed-arity Rust tuple can represent. A closed
/// array that also permits shorter instances is not exact.
pub fn positional_items_are_exact(&self) -> bool {
let Some(positions) = self.positional_items() else {
return false;
};
self.positional_items_are_closed()
&& self
.min_items
.is_some_and(|minimum| minimum >= positions.len() as u64)
}

/// Check if this schema is nullable
Expand Down
5 changes: 5 additions & 0 deletions src/server/codegen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ fn collect_schema_type_refs(
}
}
SchemaType::Array { item_type } => collect_schema_type_refs(item_type, queue, keep),
SchemaType::Tuple { element_types } => {
for element_type in element_types {
collect_schema_type_refs(element_type, queue, keep);
}
}
SchemaType::Reference { target } => seed(target, queue, keep),
}
}
Expand Down
Loading
Loading