openapi-to-rust
A Rust code generator that turns OpenAPI 3.1 (and 3.2-experimental) specifications into strongly-typed structs, async HTTP clients, SSE streaming clients, and opt-in Axum server scaffolding — including for the messy, real-world specs everyone actually ships.
We originally built this internally at GPU CLI to generate typed Rust clients for OpenAI, Anthropic, Cloudflare, and other large APIs. After battle-testing it against real-world specs with complex union types, discriminated enums, streaming endpoints, and the occasional spec/API drift, we decided to open source it.
It currently compiles cleanly against 54 real-world specs in specs/ (Stripe, OpenAI, Anthropic, Cloudflare's 14k-schema spec, GitHub, Discord, Microsoft Graph, Spotify, Twilio, …), guarded by CI.
What's new in 0.6
- Typed query parameter serialization (#27) — object and array query params are generated per their OAS
style/explode: form-exploded objects become struct arguments serialized as?color=red&size=5, explode=false objects comma-join, deepObject objects emit?filter[color]=red, and form arrays becomeVec<T>(repeated or comma-joined). Breaking for regenerated clients — the oldOption<impl AsRef<str>>passthrough put a single opaquename=<string>pair on the wire, which no server expecting the declared style could parse. See Breaking changes (pre-1.0).
What's new in 0.5
- Server codegen (Axum) — opt-in
[server]section emits a trait per tag, a status-code-typed response enum (withIntoResponse), an SSE-aware variant, and aRouterfactory. Pick operations one-by-one or--all-tag. Two end-to-end examples ship inexamples/server-{openai-responses,anthropic-messages}/. - OpenAPI 3.1 + 3.2 conformance — strict spec-extension parsing (typed
Extensionsnewtype rejects unknown non-x-keys), full Components family (Server/SecurityScheme/Header/Example/Link/Callback/Tag/ExternalDocs/Encoding), JSON Schema 2020-12 keywords (prefixItems,patternProperties,propertyNames,unevaluatedItems/Properties,dependentRequired/Schemas,contains/min/maxContains,contentEncoding/MediaType/Schema,if/then/else,$dynamicRef/$dynamicAnchor,$defs), and 3.2 deltas (queryHTTP method,additionalOperations, OAuthdeviceAuthorization,Server.name,Tag.parent/kind/summary,Discriminator.defaultMapping,mediaTypescomponents, item-level encoding). - Real-world spec compile guarantee — fixed a long tail of generator bugs surfaced by 54 specs in CI (
r#selfpanics, operationId collisions, exclusiveMinimum bool-vs-number, signed enum variants,</>Twilio param sanitization, self-referential union sizing, $ref-typed params, optional request bodies, and more). The full corpus runs locally; CI gates on the gold list. - Codegen quality fixes — header params wired through the signature, HEAD/OPTIONS/PATCH/TRACE methods emitted, $ref-typed parameters resolved to their referenced enum types, path-templating percent-encoded per RFC 3986, range status codes (
2XX/4XX/5XX) matched correctly, auth scheme (Bearer/ApiKey/Custom) honored at runtime, optional request bodies wrapped inOption<T>, operationId collisions detected loudly. - Webhook ingest — operations declared under top-level
webhooks:flow through analysis like any other operation. - SSE auto-detect — responses with
text/event-streamautomatically mark the operation as streaming; the streaming config still wins when present.
See the full release notes at the bottom of this README for the per-area breakdown.
Highlights
- OpenAPI 3.1 first, 3.2 experimental — handles
type: ["X", "null"],anyOf/oneOf/allOf, discriminated unions,const, inline objects, and accepts paths-less specs (components-only or webhooks-only). - Generates clients and servers — pick what you want via
[features]and[server]. Both share the sametypes.rs. - Typed scalars —
format: date-time→chrono::DateTime<chrono::Utc>,uri→url::Url,binary→bytes::Bytes,uuid→uuid::Uuid,byte→Vec<u8>+ base64 codec, unsigned-int formats →u32/u64. All opt-out per-format in TOML. - Async HTTP client — typed methods per operation, retry/backoff via
reqwest-retry, distributed tracing viareqwest-tracing, Bearer / API-key / custom auth (honored at runtime), default headers, path-template percent-encoding. - Axum server scaffolding — trait per tag, status-code-typed response enum, SSE-ready
OkStreamvariant, required-param HTTP 400 short-circuit at the handler boundary, combinedbuild_router(...)factory for multi-tag selections. - SSE streaming clients — first-class Server-Sent Events with reconnection.
- Smart discriminated unions — auto-detects implicit discriminators from
constproperties, falls back to#[serde(untagged)]when a union mixes scalar and object branches (e.g."auto"or a tagged object). - Per-operation typed errors — each operation gets its own error enum with
Status4xx(...)typed bodies; you can match on the exact API error shape. - Typed
additionalProperties— extra keys becomeBTreeMap<String, T>instead of falling toserde_json::Valuewhen the spec gives a value-type schema. - Constraint-as-doc —
minLength/maxLength/minimum/patternetc. are emitted as/// Constraint: …doc comments. No runtime validation is added, so generated code stays free of validator-crate dependencies. - TOML configuration with overrides for spec quirks (nullable, extensible enums, type aliases).
- Snapshot testing —
instasnapshots for generated output. - Optional
specta::Typederives for cross-language type sharing.
Install
[]
= "0.5"
Or as a CLI:
Quick start — client
openapi-to-rust.toml:
[]
= "openapi.json"
= "src/generated"
= "api"
[]
= true
[]
= "https://api.example.com"
= 30
[]
= 3
[]
= "Bearer"
= "Authorization"
Then:
Quick start — Axum server
Pick the operations you want to host. The generator emits a trait, a typed response enum, and a router factory. You implement the trait; axum does the rest.
[]
= "openai.yaml"
= "src/gen"
= "openai"
[]
= false # server-only
[]
= "axum"
= ["createResponse", "listInputItems"]
# Drop schemas not reachable from the picked operations. Safe when
# you're not also generating the HTTP client (the client would lose types).
= true
Discover and scaffold operations from the CLI:
# List operations in the spec (filter by tag / method / substring)
# Add an operation to [server].operations (preserves TOML formatting)
# Remove
Then implement the trait:
use ;
use CreateResponse;
use Event;
use stream;
;
async
Two complete examples are in the repo:
examples/server-openai-responses—createResponse+listInputItems+usage-costs(multi-tag, body + SSE, four query params, required param 400)examples/server-anthropic-messages—messages_postwith a small overlay that declarestext/event-streamon the 200 (Anthropic's published spec omits it)
Generated Output
| File | Description |
|---|---|
types.rs |
All struct/enum definitions from OpenAPI schemas |
client.rs |
Async HTTP client with typed methods per operation (when enable_async_client) |
streaming.rs |
SSE streaming client with event parsing (when configured) |
server/mod.rs |
Module re-exports for the server (when [server] is set) |
server/api.rs |
trait <Tag>Api { async fn <op>(&self, …) -> <Op>Response; } per tag |
server/errors.rs |
enum <Op>Response { Ok(T), BadRequest(E), …, OkStream(Sse<…>) } with IntoResponse |
server/router.rs |
Per-tag Router factory; combined build_router<…>(…) for multi-tag selections |
mod.rs |
Module declarations + re-exports |
REQUIRED_DEPS.toml |
Optional crates the generated code references (chrono, uuid, url, bytes, base64) — copy into your consuming crate's Cargo.toml |
Generated client usage
use crateHttpClient;
use crate*;
async
What the generated types look like
A tour of patterns the generator emits, from real outputs.
Typed scalars
// format: date-time → chrono::DateTime<chrono::Utc>
pub created_at: DateTime,
pub archived_at: ,
// format: uri → url::Url
pub url: Url,
pub callback_url: ,
// format: binary (multipart) → bytes::Bytes
Binary,
// format: uuid → uuid::Uuid
pub request_id: Uuid,
Typed additionalProperties
pub additional_properties: BTreeMap, // usage maps
pub additional_properties: BTreeMap, // labels
Constraints as doc comments
///Constraint: minLength=1, maxLength=64, pattern=`^[a-zA-Z0-9_-]{1,64}$`
pub custom_id: String,
///Constraint: minimum=0, maximum=1
pub temperature: ,
No runtime validation is generated. The generator never adds the
validatorcrate or#[validate(...)]attributes — constraints are documentation only. Validate at boundaries you control.
Discriminated unions (tagged enums)
Hybrid string-or-object unions
When an anyOf/oneOf mixes a string-enum branch with tagged-object branches (a common OpenAI pattern), the generator emits an untagged enum so both forms deserialize:
// not #[serde(tag="type")]
Extensible enums (with Custom(String) fallback)
When the spec declares an anyOf of const strings plus an open string branch (or you opt in via [extensible_enums], see below), the enum has a Custom(String) arm so unknown values still deserialize:
Per-operation typed errors
Each operation has its own error enum that wraps the typed body of each documented response code:
let resp = client.create_response.await;
match resp
Streaming (SSE)
use *;
let streaming_config = StreamingConfig ;
Generated event types are tagged enums you can match on directly:
match ?
The generator also auto-detects streaming endpoints: any response declaring content: text/event-stream flips supports_streaming = true automatically. Explicit [[streaming.endpoints]] config still wins when present.
Spec-quirk overrides
Real specs lie. These TOML knobs let you patch quirks without forking the spec.
schema_extensions — overlay JSON/YAML fragments onto the spec
A list of files whose top-level objects are deep-merged into the main spec before analysis. Use it to add a text/event-stream content entry, add a missing operation, or tweak a schema without forking the upstream spec. Example: the Anthropic server example overlays sse-overlay.json so messages_post gets an SSE response variant.
[]
= ["sse-overlay.json"]
nullable_overrides — force a field to Option<T>
When a spec marks a field as required + non-nullable but the API actually returns null. Format: "SchemaName.fieldName" = true.
[]
# OpenAI's spec uses a bare $ref for `error`; the API actually returns null on success.
= true
extensible_enums — force a closed enum to accept unknown values
When the spec declares a fixed enum but the API actually returns values outside the set (real-world drift). Renders the enum with a Custom(String) fallback variant. Accepts either the raw spec name or the rendered Rust type name.
[]
# CF spec declares lowercase ["apac", ..., "wnam"] but the API returns "WNAM".
= true
# OpenAI spec declares ["in-memory", "24h"] but the API returns "in_memory".
= true
type_mappings — override a primitive's Rust type
[]
= "chrono::DateTime<chrono::Utc>"
[generator.types] — typed-scalar strategy per format
Opt out of any individual typed scalar (e.g. fall back to String for date-times if you don't want chrono):
[]
= "string" # default: "chrono"
= "string" # default: "url"
= "string" # default: "bytes"
The CLI also supports --types-conservative, which collapses every typed scalar to String/i64/etc. Use it when you want zero optional-crate dependencies.
OpenAPI 3.1 / 3.2 support
The generator accepts 3.0.x and 3.1.x specs; 3.2.x parses with an experimental warning. Unknown OpenAPI extensions (anything not prefixed with x-) surface as a hard error at parse time — silent drops are not a thing here.
3.1 (JSON Schema 2020-12) keywords now modeled and read from typed fields:
| Keyword group | Status |
|---|---|
type as array (e.g. ["string", "null"]) |
typed + used for nullability |
prefixItems, unevaluatedItems, contains / minContains / maxContains |
typed |
patternProperties, propertyNames, unevaluatedProperties |
typed |
dependentRequired, dependentSchemas, if / then / else |
typed |
contentEncoding, contentMediaType, contentSchema |
typed |
$dynamicRef, $dynamicAnchor, $defs, $id, $schema, $comment |
typed (anchor-scope resolution is a follow-up) |
Path Item $ref resolution |
resolved at analysis time |
Webhooks (webhooks:) |
ingested as operations |
examples, example, title, deprecated, readOnly/writeOnly |
typed |
3.2 deltas (experimental):
| Delta | Status |
|---|---|
query HTTP method + PathItem.additionalOperations |
parses + emits client methods via reqwest::Method::from_bytes(...) |
OAuth deviceAuthorization flow + oauth2MetadataUrl |
typed |
Server.name, Tag.parent/kind/summary |
typed |
Discriminator.defaultMapping |
typed (captured; _Other(Value) fallback emission is a follow-up) |
MediaType.itemSchema, prefixEncoding, itemEncoding |
typed |
mediaTypes in Components |
typed |
Components family (typed end-to-end): Server, ServerVariable, SecurityScheme (apiKey / http / mutualTLS / oauth2 / openIdConnect with all flows), OAuthFlows, Encoding, Header, Example, Link, Callback, Tag, ExternalDocs, Discriminator. Everything is Extensions-strict: unknown non-x-* fields fail loudly at deserialize time.
A full conformance harness lives under tests/conformance/ with fixture files and a status.toml recording what's L0-typed vs. L3-flowing-through-codegen. The JSON-Schema-Test-Suite (git submodule) runs as a separate test.
CLI
# Server scope management — preserves TOML formatting (toml_edit)
Selectors are forgiving — operationId, METHOD /path, or tag:<name>. Typos surface Levenshtein-based "Did you mean …?" suggestions.
TOML reference
[]
= "openapi.json" # required
= "src/generated" # required
= "types" # informational label, not a directory
= [] # optional list of JSON/YAML overlays merged into the spec
[]
= false # generate SSE streaming client (requires [[streaming.endpoints]])
= true # generate HTTP REST client
= false # add specta::Type derives
= false # generate static operation registry (CLI/proxy routing)
= false # only generate the registry (skip types/client/streaming)
[]
= "https://api.example.com"
= 30 # 1-3600
[]
= 3 # 0-10
= 500 # 100-10000
= 16000 # 1000-300000
[]
= true
[]
= "Bearer" # Bearer | ApiKey | Custom (honored at runtime)
= "Authorization"
[[]]
= "content-type"
= "application/json"
[[]]
= "createChatCompletion"
= "chat/completions"
= "POST"
= "stream"
= "ChatCompletionStreamEvent"
= "text/event-stream"
[]
= "StartDeltaStop" # or "Continuous"
= ["response.created"]
= ["response.output_text.delta"]
= ["response.completed"]
[]
= "axum" # only axum supported today
= [ # selectors: operationId | "METHOD /path" | "tag:<name>"
"createResponse",
"POST /v1/messages",
"tag:Responses",
]
= false # drop schemas unreachable from picked ops
# (safe only when not also generating the HTTP client)
[]
= true # see "Spec-quirk overrides" above
[]
= true # see "Spec-quirk overrides" above
[]
= "chrono::DateTime<chrono::Utc>"
[]
= "chrono" # chrono (default) | string
= "url" # url (default) | string
= "bytes" # bytes (default) | string
= "uuid" # uuid (default) | string
= "vec_u8_base64" # default; encodes/decodes via base64
Testing
CI runs the gold list (Anthropic, OpenAI, and a curated set covering the worst-behaved real-world specs) as a regression guard. Local scripts/spec-compile.sh runs the full 54-spec corpus.
Examples
# Library / generator examples
# End-to-end Axum server examples (generate + run)
Breaking changes (pre-1.0)
Until 1.0.0, a minor version bump may change the generated API surface — usually because the previous output was wrong on the wire. Regenerating makes the compiler point at every affected call site; there is no silent behavior change without a signature change.
0.6.0
Object- and array-schema query parameters are now serialized according to
their OpenAPI style/explode (issue #27).
Previously every such parameter was Option<impl AsRef<str>> and the caller's
string went out as a single opaque name=<string> pair — which no server
expecting the declared style could parse. Signatures change as follows:
| Parameter shape | Old argument | New argument | Wire format |
|---|---|---|---|
| object, form + explode=true (OAS defaults) | Option<impl AsRef<str>> |
Option<Struct> |
?color=red&size=5 |
| object, form + explode=false | Option<impl AsRef<str>> |
Option<Struct> |
?filter=color,red,size,5 |
| object, deepObject | Option<impl AsRef<str>> |
Option<Struct> |
?filter[color]=red |
| array, form + explode=true (OAS defaults) | Option<impl AsRef<str>> |
Option<Vec<T>> |
?tags=a&tags=b |
| array, form + explode=false | Option<impl AsRef<str>> |
Option<Vec<T>> |
?tags=a,b,c |
Struct is the referenced component model for $ref schemas or a
synthesized {Operation}{Param} struct for inline objects. T is the scalar
item type (via the same type mapping as properties) or the referenced
string-enum model. Unchanged (still the opaque string passthrough): deepObject
arrays, spaceDelimited/pipeDelimited, arrays of objects, and server-side
extraction (tracked separately).
Release notes
0.6 (this release)
Typed query parameter serialization (#27)
- Object and array query parameters are generated per their OAS
style/explodeinstead of an opaqueOption<impl AsRef<str>>passthrough: form-exploded objects (?color=red&size=5), explode=false objects (?filter=color,red,size,5), deepObject objects (?filter[color]=red), andVec<T>form arrays (repeated or comma-joined pairs, scalar or string-enum items). - Breaking for regenerated clients — see Breaking changes (pre-1.0) for the full signature table.
scripts/spec-compile.shchecks all scratch crates as one cargo workspace against a shared persistent target dir — full-sweep verification dropped from hours to minutes.
0.5
Server codegen (Axum)
[server]TOML section with selector grammar (operationId /METHOD /path/tag:<name>).openapi-to-rust server list / add / removewith--all-tag,--dry-run,--regenerate,--json. Edits preserve TOML formatting viatoml_edit. Typos get Levenshtein "Did you mean …?" suggestions.- Emits
server/{mod,api,errors,router}.rs: trait per tag, status-code-typed response enum withIntoResponse, conditionalOkStream(Sse<…>)variant, per-tag and combinedbuild_router<…>(…)factory. - Query + header parameters wired through the trait. Required query/header params arrive unwrapped; missing-required short-circuits to HTTP 400 +
{"error": "missing required …"}JSON. - Generated
sse_response(stream)helper — wraps anyStream<Item = Result<Event, Infallible>>so the SSE branch stays two lines. - End-of-generate hint prints a paste-ready impl skeleton.
[server].prune_models = true(opt-in) trimstypes.rsto schemas reachable from picked ops (OpenAI example: drops 280 of 2154 schemas, ~3300 fewer lines).- Two end-to-end examples:
server-openai-responses(multi-tag, body + SSE, four query params, required-param 400) andserver-anthropic-messages(SSE overlay).
OpenAPI 3.1 + 3.2 conformance (38 of 51 beads from #14)
- Strict spec extensions (
Extensionsnewtype rejects unknown non-x-keys) + version gate (3.0 / 3.1 accepted, 3.2 experimental, others hard error). - Full Components family typed end-to-end:
Server,ServerVariable,SecurityScheme(apiKey/http/mutualTLS/oauth2/openIdConnect),OAuthFlows,Encoding,Header,Example,Link,Callback,Tag,ExternalDocs,Discriminator. - JSON Schema 2020-12 keywords typed:
prefixItems,patternProperties,propertyNames,unevaluatedItems/Properties,dependentRequired/Schemas,contains/min/maxContains,contentEncoding/MediaType/Schema,if/then/else,$dynamicRef/$dynamicAnchor,$defs,$id,$schema,$comment. - 3.2 deltas:
queryHTTP method,additionalOperations(arbitrary verbs), OAuthdeviceAuthorization,Server.name,Tag.parent/kind/summary,Discriminator.defaultMapping,MediaType.itemSchema/prefixEncoding/itemEncoding,mediaTypescomponents. typeas array (3.1 canonical nullability —type: ["string", "null"]) merges cleanly with the 3.0nullable: truefield.- Webhooks (
webhooks:) ingested as operations. - Path Item
$refresolution ($ref: "#/components/pathItems/X"). - Conformance harness with fixtures, layered
fails_at:markers,status.toml, and the JSON-Schema-Test-Suite as a git submodule.
Real-world spec compile guarantee (54 specs in CI)
r#self/r#super/r#crate/r#Selfpanic → rename to<keyword>_field/<keyword>_param(proc_macro2 doesn't accept those as raw idents).- OperationId collisions → auto-disambiguate by HTTP method (
opId_post) with a stderr warning, instead of rejecting the whole document. exclusiveMinimummodeled asbool | f64(3.0/Swagger used bool; 3.1 uses number).- Signed enum variants disambiguated (
Variant1vsVariantNeg1). - Twilio-style
</>/<=/>=filter params sanitized to_lt/_gt/_lte/_gte. - Self-referential union variants boxed to break infinite-size enums.
- Nullable-anyOf wrapper collisions resolved (don't synthesize a wrapper that overwrites the inner
$ref's schema). $refshape variants accepted (#/definitions/XSwagger carry-over,#/components/parameters/X/schemafalls back toValue).- Per-method parameter ident collisions resolved at analysis time (
exclude_ids+exclude-idsget uniquerust_idents). - Empty/non-string enum values coerced via Display (gitpod's numeric values on a string-typed schema).
- Optional request bodies (
required: false) wrapped inOption<T>and chained withif let Some(...)so the request builder typechecks. $ref-typed parameter types resolved to the referenced enum (instead of silently falling back toString).- String enums emit
as_str(),Display, andAsRef<str>impls so the wire form drops cleanly into headers, query strings, and path segments. cargo clippy --all-features -- -D warningsis clean.
Codegen quality fixes
- T1: header request parameters wired through codegen.
- T2: HEAD/OPTIONS/PATCH/TRACE methods emitted explicitly (was a silent
_ => getfallback). - T3:
auth_configApiKey/Customvariants honored at runtime — README's multi-scheme-auth claim is now actually true. - T4: webhooks walked in analysis.
- T5: path templating percent-encoded per RFC 3986 §3.3 via an emitted
__pct_encode_path_segmenthelper. - T6: operationId collisions detected loudly at analysis time.
- T8: range status codes (
1XX/2XX/3XX/4XX/5XX) get guarded match arms instead of falling through to the generic default. - T10:
$ref-typed parameter types resolved. - T11: optional request bodies →
Option<T>. - T13: spec
description/summarysurfaced as rustdoc on each operation method. - T15: SSE auto-detection from
text/event-streamresponses. - F1: strict spec extensions + version gate.
- F2:
typeas array (3.1 nullability). - F4: paths-less specs (components-only / webhooks-only) accepted.
Typed scalars (Q2 series)
TypeMapperchokepoint for format-driven type mapping.format: date-time→chrono::DateTime<Utc>,uri→url::Url,binary→bytes::Bytes,uuid→uuid::Uuid,byte→Vec<u8>+ base64 codec.- Unsigned-int formats (
uint32,uint64, etc.) →u32/u64. - Typed
additionalProperties→BTreeMap<String, T>. x-enum-varnameshonored.- Constraint doc comments (
minLength/maxLength/minimum/pattern). - Per-format strategy opt-out via
[generator.types.strategies];--types-conservativeflag. REQUIRED_DEPS.tomlemitted listing optional crates the generated code references (+ stderr advisory).- Clean primitive variants for
anyOfunions.
Other
- Hybrid string-or-object unions,
allOf-nullable handling,[extensible_enums]override.
Contributing
- Fork the repo
- Add your OpenAPI spec or pattern to
specs/ortests/fixtures/ - Write a snapshot test (
insta) - Run
cargo insta testand review output - Open a PR
License
MIT