Skip to content

feat: generate typed tuples for fixed-length positional items (#62) - #64

Merged
lightsofapollo merged 1 commit into
mainfrom
feat/tuple-codegen
Aug 26, 2026
Merged

feat: generate typed tuples for fixed-length positional items (#62)#64
lightsofapollo merged 1 commit into
mainfrom
feat/tuple-codegen

Conversation

@lightsofapollo

Copy link
Copy Markdown
Contributor

Summary

Closes #62. Positional item schemas — 2020-12 prefixItems and the draft-04
items: [A, B] spelling parsed since #60 — 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:

///Total number of observed requests. First element of the tuple is a key,
///the second one is its counter value.
-pub total: Vec<Vec<serde_json::Value>>,
+pub total: Vec<(String, i64)>,

The length is the load-bearing part. prefixItems: [A, B] 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. Mapping every prefixItems to a tuple would emit code that
compiles and then fails on payloads the spec permits, which is the failure mode
0.12.3 fixed for nullability. So the analyzer types three tiers:

  1. Length pinned (minItems >= len and closed) → a tuple, one element per
    position. $ref positions keep their named type; an inline object position
    is hoisted to a named type ({Parent}Item{N}) rather than degrading.
  2. Closed but variable length, every position the same schema → Vec<T>,
    which accepts any permitted length.
  3. Anything elseVec<serde_json::Value>, unchanged.

Also fixes a parse gap found while implementing this: items: false — the
canonical 2020-12 way to close a tuple — did not parse, failing the whole
document the way #60 did. Items now models boolean schemas.

Generated compatibility

  • Generated model or method signatures: yes, this is the point. Fields whose
    schema has fixed-length positional items change from
    Vec<serde_json::Value> to a tuple; closed homogeneous ones change to
    Vec<T>. Across the 57-spec corpus this touches 7 schemas in 3 specs
    (gcore ×4 → Vec<(String, i64)>, langsmith ×2 → Vec<(String, String)>,
    opencode ×1 → (String, ConfigPluginItem…)). Open prefixItems output is
    unchanged.
  • Query/path/header/body wire behavior: unchanged. serde reads and writes Rust
    tuples as JSON arrays, which is the same wire shape as before. A tuple-typed
    query parameter degrades to the existing opaque-string path rather than
    guessing.
  • Generated runtime dependencies or features: none.
  • Configuration defaults or migrations: none.
  • Remaining unsupported OpenAPI shapes: additionalItems as a "rest" element
    type is still carried but unused; boolean subschemas outside items (e.g.
    properties: {a: true}) still fail to parse — filed as Boolean subschemas (true/false) fail to parse outside items #63.

Validation

  • Added or updated a focused fixture and behavioral regression test.
    tests/tuple_codegen_test.rs covers all three tiers, both spellings, the
    three ways to close an array, $ref and inline-object positions, the
    one-element (T,) trailing comma, and a named tuple schema aliasing.
    Two open/variable cases assert the conservative fallback.
  • Reviewed every changed snapshot; no unrelated churn is included (no
    snapshot changed — no existing fixture used positional items).
  • cargo fmt --check
  • cargo clippy --all-features -- -D warnings
  • cargo test --all-features
  • RUSTDOCFLAGS=-Dwarnings cargo doc --no-deps --all-features
  • Ran scripts/install-smoke.sh for packaging/dependency changes.
  • Ran a targeted or full scripts/spec-compile.sh for generator changes
    (full 55-spec corpus: 55 passed, 0 gen-failed, 0 check-failed — gcore,
    langsmith, and opencode, whose types change, all compile clean).
  • Updated README, rustdoc, or changelog for user-visible behavior.

Generated tuples round-trip real payloads, checked against a compiled scratch
crate rather than only asserted on the generated text:

parsed: ("alpha", "beta")
round-trip: {"pair":["alpha","beta"]}
three elements rejected as the spec requires: trailing characters at line 1 column 25

Notes for reviewers

git diff -w is much smaller than the raw diff: extracting analyze_item_schema
out of analyze_array_schema re-indents ~200 lines that are otherwise unchanged.
The real change is ~174 lines in src/analysis.rs.

Smallest useful review path: positional_items_are_closed /
positional_items_are_exact in src/openapi.rs (the rule), then
analyze_positional_items in src/analysis.rs (the tiers), then
generate_tuple_type in src/generator.rs (the rendering, including the
one-element trailing comma).

This warrants a minor release (0.14.0) — generated APIs change for any spec with
fixed-length positional items.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TD3TSeWKu4VqLtEnRMDjry

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
@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
openapi-to-rust Ready Ready Preview Aug 26, 2026 8:49pm

Request Review

@lightsofapollo
lightsofapollo merged commit e40a057 into main Aug 26, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Positional item schemas generate Vec<serde_json::Value> instead of typed tuples

1 participant