Skip to content

Commit 539a7c5

Browse files
feat: generate typed tuples for fixed-length positional items
Positional item schemas — 2020-12 `prefixItems` and the draft-04 `items: [A, B]` spelling — were parsed and then discarded, so every tuple generated `Vec<serde_json::Value>`. gcore said it out loud, in the description directly above the generated field: "First element of the tuple is a key, the second one is its counter value." The length is the load-bearing part. `prefixItems` alone does not cap an array's length — extra elements of any type are legal unless `items: false`, `additionalItems: false`, or `maxItems` says otherwise — and a Rust tuple is fixed-arity, so mapping every `prefixItems` to one would emit code that compiles and then fails on payloads the spec permits. Three tiers instead: 1. length pinned -> a tuple, one element per position; 2. closed, variable length, positions interchangeable -> `Vec<T>`; 3. otherwise -> `Vec<serde_json::Value>`, unchanged. A `$ref` position keeps its named type and an inline object position is hoisted to one, so `analyze_item_schema` now takes the hoist name explicitly rather than deriving it from the parent. Also models 2020-12 boolean schemas for `items`: `items: false` is the canonical way to close a tuple and did not parse at all, failing the whole document the way #60 did. Closes #62 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry
1 parent 6201b0d commit 539a7c5

8 files changed

Lines changed: 666 additions & 190 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,27 @@ when correcting output that was wrong or incomplete on the wire.
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- **Breaking (generated API).** Positional item schemas — 2020-12
12+
`prefixItems` and the draft-04 `items: [A, B]` spelling — now generate typed
13+
Rust tuples instead of `Vec<serde_json::Value>`, when the spec pins the
14+
array's length (`minItems`/`maxItems`, `items: false`, or
15+
`additionalItems: false`). A `[string, integer]` pair becomes
16+
`(String, i64)`; a `$ref` position keeps its named type, and an inline object
17+
position is hoisted to one. When no extras are allowed but the length varies
18+
and every position shares a type, the array becomes `Vec<T>`.
19+
20+
An *open* `prefixItems` still generates `Vec<serde_json::Value>` on purpose:
21+
it permits extra elements of any type, and a fixed-arity tuple would reject
22+
payloads the spec allows (#62).
23+
24+
### Fixed
25+
26+
- `items: false` and `items: true` — 2020-12 boolean schemas, and the canonical
27+
way to close a tuple — now parse instead of failing the document with "data
28+
did not match any variant of untagged enum Schema" (#62).
29+
930
## [0.13.0] - 2026-08-26
1031

1132
### Changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,7 @@ focused fixture when relying on a less-common OpenAPI or JSON Schema keyword.
557557
| `type` as array (e.g. `["string", "null"]`) | typed + used for nullability |
558558
| `prefixItems`, `unevaluatedItems`, `contains` / `minContains` / `maxContains` | typed |
559559
| draft-04 positional `items: [A, B]` (FastAPI/pydantic v1 emits it under 3.1) | typed as `prefixItems` |
560+
| Fixed-length `prefixItems` | generated as Rust tuples, e.g. `(String, i64)` |
560561
| `patternProperties`, `propertyNames`, `unevaluatedProperties` | typed |
561562
| `dependentRequired`, `dependentSchemas`, `if` / `then` / `else` | typed |
562563
| `contentEncoding`, `contentMediaType`, `contentSchema` | typed |

src/analysis.rs

Lines changed: 308 additions & 184 deletions
Large diffs are not rendered by default.

src/generator.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1502,6 +1502,20 @@ impl CodeGenerator {
15021502
Ok(TokenStream::new())
15031503
}
15041504
}
1505+
SchemaType::Tuple { element_types } => {
1506+
let tuple_type = self.generate_tuple_type(element_types, analysis);
1507+
let type_name = format_ident!("{}", self.to_rust_type_name(&schema.name));
1508+
let doc_comment = if let Some(description) = &schema.description {
1509+
let sanitized = self.sanitize_doc_comment(description);
1510+
quote! { #[doc = #sanitized] }
1511+
} else {
1512+
TokenStream::new()
1513+
};
1514+
Ok(quote! {
1515+
#doc_comment
1516+
pub type #type_name = #tuple_type;
1517+
})
1518+
}
15051519
SchemaType::Array { item_type } => {
15061520
// Generate type alias for named array schemas.
15071521
//
@@ -2716,13 +2730,37 @@ impl CodeGenerator {
27162730
let inner_type = self.generate_array_item_type(item_type, analysis);
27172731
quote! { Vec<#inner_type> }
27182732
}
2733+
SchemaType::Tuple { element_types } => {
2734+
self.generate_tuple_type(element_types, analysis)
2735+
}
27192736
_ => {
27202737
// Fallback for complex types
27212738
quote! { serde_json::Value }
27222739
}
27232740
}
27242741
}
27252742

2743+
/// Render positional element types as a Rust tuple. serde reads and writes
2744+
/// these as JSON arrays of exactly this length, which is what makes the
2745+
/// analyzer's exact-length rule load-bearing.
2746+
fn generate_tuple_type(
2747+
&self,
2748+
element_types: &[crate::analysis::SchemaType],
2749+
analysis: &crate::analysis::SchemaAnalysis,
2750+
) -> TokenStream {
2751+
let elements = element_types
2752+
.iter()
2753+
.map(|element_type| self.generate_array_item_type(element_type, analysis))
2754+
.collect::<Vec<_>>();
2755+
// A one-element Rust tuple needs the trailing comma; `(T)` is just `T`,
2756+
// which serde would read as a bare value instead of a single-element
2757+
// array.
2758+
if let [only] = elements.as_slice() {
2759+
return quote! { (#only,) };
2760+
}
2761+
quote! { (#(#elements),*) }
2762+
}
2763+
27262764
fn generate_serde_field_attrs(
27272765
&self,
27282766
schema_name: &str,
@@ -3430,6 +3468,9 @@ impl CodeGenerator {
34303468
let inner_type = self.generate_array_item_type(item_type, analysis);
34313469
quote! { Vec<#inner_type> }
34323470
}
3471+
SchemaType::Tuple { element_types } => {
3472+
self.generate_tuple_type(element_types, analysis)
3473+
}
34333474
_ => {
34343475
// Fallback for complex types
34353476
quote! { serde_json::Value }

src/openapi.rs

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,10 @@ pub enum Items {
385385
Single(Box<Schema>),
386386
/// Draft-04 tuple form: one schema per position.
387387
Positional(Vec<Schema>),
388+
/// 2020-12 boolean schema. `items: false` is the canonical way to close a
389+
/// tuple — no elements beyond `prefixItems` — and `items: true` is the
390+
/// no-op "anything goes" schema.
391+
Bool(bool),
388392
}
389393

390394
#[derive(Debug, Clone, Deserialize, Serialize)]
@@ -865,7 +869,7 @@ impl Schema {
865869
// Infer from structure
866870
if details.properties.is_some() {
867871
Some(SchemaType::Object)
868-
} else if details.items.is_some() {
872+
} else if details.items.is_some() || details.prefix_items.is_some() {
869873
Some(SchemaType::Array)
870874
} else if details.enum_values.is_some() {
871875
Some(SchemaType::String) // Assume string enum
@@ -882,11 +886,12 @@ impl SchemaDetails {
882886
/// The schema every array element must satisfy, i.e. `items` in its
883887
/// 2020-12 single-schema spelling. Returns `None` for the draft-04 tuple
884888
/// form, which constrains positions rather than every element — read that
885-
/// through [`Self::positional_items`].
889+
/// through [`Self::positional_items`] — and for a boolean schema, which
890+
/// constrains nothing worth typing.
886891
pub fn item_schema(&self) -> Option<&Schema> {
887892
match self.items.as_ref()? {
888893
Items::Single(schema) => Some(schema),
889-
Items::Positional(_) => None,
894+
Items::Positional(_) | Items::Bool(_) => None,
890895
}
891896
}
892897

@@ -898,8 +903,40 @@ impl SchemaDetails {
898903
}
899904
match self.items.as_ref()? {
900905
Items::Positional(schemas) => Some(schemas),
901-
Items::Single(_) => None,
906+
Items::Single(_) | Items::Bool(_) => None,
907+
}
908+
}
909+
910+
/// Whether the array admits no elements beyond its positional schemas.
911+
///
912+
/// This is not the default: `prefixItems: [A, B]` on its own permits extra
913+
/// elements of any type. An array is closed only when 2020-12 `items:
914+
/// false`, draft-04 `additionalItems: false`, or `maxItems` says so.
915+
pub fn positional_items_are_closed(&self) -> bool {
916+
let Some(positions) = self.positional_items() else {
917+
return false;
918+
};
919+
if matches!(self.items, Some(Items::Bool(false))) {
920+
return true;
921+
}
922+
if self.extra.get("additionalItems") == Some(&Value::Bool(false)) {
923+
return true;
902924
}
925+
self.max_items
926+
.is_some_and(|maximum| maximum <= positions.len() as u64)
927+
}
928+
929+
/// Whether every valid instance has exactly one element per positional
930+
/// schema — the only case a fixed-arity Rust tuple can represent. A closed
931+
/// array that also permits shorter instances is not exact.
932+
pub fn positional_items_are_exact(&self) -> bool {
933+
let Some(positions) = self.positional_items() else {
934+
return false;
935+
};
936+
self.positional_items_are_closed()
937+
&& self
938+
.min_items
939+
.is_some_and(|minimum| minimum >= positions.len() as u64)
903940
}
904941

905942
/// Check if this schema is nullable

src/server/codegen.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,11 @@ fn collect_schema_type_refs(
190190
}
191191
}
192192
SchemaType::Array { item_type } => collect_schema_type_refs(item_type, queue, keep),
193+
SchemaType::Tuple { element_types } => {
194+
for element_type in element_types {
195+
collect_schema_type_refs(element_type, queue, keep);
196+
}
197+
}
193198
SchemaType::Reference { target } => seed(target, queue, keep),
194199
}
195200
}

0 commit comments

Comments
 (0)