oas3-gen
oas3-gen reads an OpenAPI 3.1 specification and generates Rust code for the API it
describes. You can generate types to use in your own code, an HTTP client built on
reqwest::Client , or a server trait with an
axum::Router .
This book assumes you’re familiar with Rust structs, enums, and Cargo. The examples show how to generate code, use it in an application, and choose the options that affect it.
Overview
An OpenAPI document describes an API’s operations and the data they accept and return.
oas3-gen uses those descriptions to create Rust structs, enums, and type aliases. It
also generates
serde::Serialize and serde::Deserialize implementations where the types need them, along with validation
for supported schema constraints.
You can start with type generation and add a client or server as your project needs it. Client Generation walks through calling an API, reading responses, and sending API keys and bearer tokens. Server Generation shows how to implement a service, connect its router, stream events, and check incoming credentials.
The Builder Pattern chapter compares ways to construct generated values. Start with the client or server chapter, then use the reference chapters for details:
- Generation Options covers output layouts, filtering, names, visibility, and known limitations.
- Schemas and Types covers fields, collections, defaults, and validation.
- Enums and Unions covers variant selection and serialization.
- HTTP Requests and Responses covers parameters, security requirements, response types, and multipart encoding.
Features
You can use the generator to:
- Generate structs, enums, and type aliases from schemas, including unions
described with
oneOf,anyOf, and discriminators. - Call API operations through an asynchronous
reqwestclient. - Implement a generated server trait and connect it to an Axum router.
- Send and receive JSON, form, multipart, plain-text, and binary bodies, using the media type each operation declares.
- Stream Server-Sent Events for
text/event-streamresponses: the client reads them as a stream, and the server sends the events your service yields. - Send and receive API keys and bearer tokens declared by
apiKeyandhttpbearer security schemes. - Check supported field constraints, including
multipleOf, withvalidator::Validate. - Construct schema values and requests with builders by passing
--enable-builders.
Requirements
You’ll need Rust 1.89 or later and an OpenAPI 3.1.x specification. The generator accepts both JSON and YAML documents.
Installation
To install the command-line tool, run:
cargo install oas3-gen
Cargo builds the tool and installs the oas3-gen executable. If you’d like to build
from this repository instead, run:
git clone https://github.com/eklipse2k8/oas3-gen
cd oas3-gen
cargo build --release
The source build places the executable in target/release/ . You can also run it from
the repository with cargo run -- , followed by the same arguments used with oas3-gen
below.
Quick Start
Let’s start by generating types. The following commands assume you have an OpenAPI
document named api.json in your current directory. If you don’t have one yet, the
repository’s fixtures/petstore.json describes a pet store API used throughout this
book. Substitute that path for api.json in the commands below:
oas3-gen generate types -i api.json -o types.rs
The -i argument names the input document, and -o names the output file. This command
writes the types used by your API’s operations to types.rs .
To generate an HTTP client together with those types, change the mode to client :
oas3-gen generate client -i api.json -o client.rs
Both the types and the client go into client.rs . If you’d prefer separate files, use
client-mod and give -o a directory:
oas3-gen generate client-mod -i api.json -o src/api/
This creates types.rs , client.rs , and mod.rs in src/api/ . For a server, use
server-mod :
oas3-gen generate server-mod -i api.json -o src/server/
The server output contains types.rs , server.rs , and mod.rs . You’ll implement
the generated trait to provide the behavior of each API operation. Continue with
Client Generation to call an API or
Server Generation to implement one.
These commands generate source files to include in a Rust project. To generate an
accompanying Cargo.toml , add --workspace to either module mode. See
Workspace Crate Output for an example
and instructions on using the generated crate.
Supported Formats
You can use a JSON specification with a .json extension or a YAML specification with a
.yaml or .yml extension. The examples in this book use JSON; the same generation
options apply to YAML input.
License
oas3-gen is available under the MIT license.
Client Generation
Client generation creates a reqwest client and the request and response types for an
API. We’ll generate the petstore client, configure its credentials, and call an
operation. Later sections cover request bodies, response decoding, and event streams.
The commands run from a checkout of this repository and use the petstore fixture,
fixtures/petstore.json . If you installed the tool, replace cargo run -- with
oas3-gen and supply the path to your document. Rust excerpts assume you’ve imported
the generated types. Put snippets that use ? in a function returning
anyhow::Result , and use an async function for calls that
contain .await .
- Choosing the Output Layout
- Creating a Client
- Sending Credentials
- Making a Request
- Sending Request Bodies
- Reading Responses
- Reading Server-Sent Events
- Deprecated Operations
- Choosing Operations and Names
Choosing the Output Layout
The client mode puts the client and its types in one file:
cargo run -- generate client \
-i fixtures/petstore.json -o client.rs \
--enable-builders
Here, --enable-builders adds the request builders we’ll use below. The client’s name
comes from the document’s title, Petstore , so the generated client is
PetstoreClient . To choose a different name, add
--api-name .
Use client-mod if you’d like to keep the client and types in separate files:
cargo run -- generate client-mod \
-i fixtures/petstore.json -o src/api/ \
--enable-builders
The output directory contains three files:
src/api/
├── mod.rs
├── types.rs
└── client.rs
The mod.rs file declares both modules and re-exports their items:
mod types;
mod client;
pub use types::*;
pub use client::*;
To include this directory in your application, declare mod api; in your crate root.
You’ll also need the dependencies referenced by the generated code. If you’d prefer a
separate crate with its own manifest, add --workspace and use a crate directory as the
output path. See
Workspace Crate Output for the layout
and dependency rules.
Creating a Client
The client stores a reqwest::Client and a
reqwest::Url for the base URL. You can call new() to use the
first URL in the document’s servers list, https://api.petstore.example/v3 . To
choose a different endpoint, such as one of the petstore’s regional deployments, call
with_base_url() :
let client = PetstoreClient::with_base_url("https://eu-central.api.petstore.example/v3")?;
This constructor returns a Result because parsing the URL or
building the HTTP client can fail. Replace the example URL with the API endpoint you
want to call.
If your application already has a configured reqwest::Client , pass it to
PetstoreClient::with_client(base_url, http_client) . For clients that use cookie API
keys, you’ll also need to connect the generated cookie store, as shown in
Using a Custom HTTP Client .
Sending Credentials
For operations with apiKey or bearer security schemes, the client provides methods to
set credentials. See Security Schemes for how schemes and
requirements are declared.
The petstore’s top-level security requires a header key named ApiKeyAuth , so most
operations accept it. Individual operations also accept a query key named QueryKey , a
cookie key named SessionCookie , a bearer token named BearerAuth , and an apiKey
scheme named TokenAuth that travels in the Authorization header. To configure the
header key, read it from your application’s environment and pass it to the generated
setter:
let api_key = std::env::var("API_KEY")?;
let client = PetstoreClient::new().with_api_key_auth(api_key);
The client stores header keys as http::HeaderValue values
marked sensitive, and query keys and bearer tokens as
secrecy::SecretString values, each wrapped in
Option . A header key setter leaves the key unset when it’s empty or
isn’t a valid header value. The client gets one field and setter for each supported
scheme used by the selected operations. Names come from the scheme: ApiKeyAuth becomes
api_key_auth and with_api_key_auth() . If a name collides with another field,
including client or base_url , the generator adds a numeric suffix. Schemes the
generator doesn’t support, such as the petstore’s BasicAuth and PetstoreOAuth ,
don’t get a setter.
Each method attaches the configured header keys, query keys, and bearer tokens that its operation accepts. It doesn’t check whether you’ve supplied all the required credentials. An unset key is omitted from the request, and a generated server rejects it as described in Rejections from a Generated Server .
Bearer Tokens
A bearer scheme gets a field and setter, like a header key. The petstore’s BearerAuth
scheme produces with_bearer_auth() :
let token = std::env::var("STAFF_TOKEN")?;
let client = PetstoreClient::new().with_bearer_auth(token);
Pass the token by itself, without the Bearer prefix. For an operation that accepts the
scheme, such as revokeApiKey , the client method calls
reqwest::RequestBuilder::bearer_auth() . That method
sends the token in an Authorization: Bearer <token> header and marks the header value
as sensitive.
A request carries one Authorization header. When an operation accepts more than one
credential that travels in it, such as a bearer token and an apiKey scheme named
Authorization , the client sends only one of them: of the credentials you’ve
configured, the scheme declared last wins. The petstore’s listAuditEvents accepts
BearerAuth or TokenAuth , and TokenAuth is declared later, so a client with both
set sends the TokenAuth key.
Cookie Keys
Cookie keys use a shared
reqwest_cookie_store::CookieStoreMutex in the client’s
cookies field. The new() and with_base_url() constructors attach that store to the
underlying reqwest::Client . To add the petstore’s session
cookie, call its setter:
let session_key = std::env::var("SESSION_KEY")?;
let client = PetstoreClient::new().with_session_cookie(session_key);
The setter inserts a cookie named session for the base URL and sends your key as it
is, without percent-encoding, so a key with / , = , : , or @ reaches the server
unchanged. A generated server percent-decodes cookie values, so a key containing an
escape such as %41 arrives there as A . A key with a space or a semicolon can’t be
sent in a Cookie header. Reqwest then sends the cookie on requests that match its
scope, including requests to operations that don’t declare that scheme. The store also
retains cookies received from the server. Clones of the generated client share the
store, so setting a cookie key on a clone changes the store used by the original client
too.
Using a Custom HTTP Client
with_client() accepts an HTTP client that’s already built, so it can’t attach the
generated cookie store to it. To configure your own client with cookie support, build it
with the generated store and assign it to client :
use std::sync::Arc;
let mut api = PetstoreClient::with_base_url("https://api.petstore.example/v3")?;
api.client = reqwest::Client::builder()
.cookie_provider(Arc::clone(&api.cookies))
.build()?;
let api = api.with_session_cookie(std::env::var("SESSION_KEY")?);
The Arc lets api and its HTTP client share the same store. Add any
other configuration to the reqwest::ClientBuilder
before calling build() .
Keeping Keys Out of Logs
Formatting a generated client with {:?} hides its stored credentials. Sensitive
HeaderValue s print as Sensitive , and
SecretString redacts query keys and bearer tokens. The cookie store is omitted. See Keeping Keys Out of Logs
for the secret type’s behavior when you explicitly read a key.
Making a Request
Each operation has a request type. Path, query, header, and cookie parameters are grouped into nested structs. With builders enabled, setters assemble those structs for you. Let’s request a pet by its ID:
let pet_id = uuid::Uuid::parse_str("0192f0c1-7a6e-7c4e-9d2a-5b1e8f3c2a10")?;
let request = ShowPetByIdRequest::builder()
.pet_id(pet_id)
.x_api_version("2026-10".to_string())
.build()?;
let response = client.show_pet_by_id(request).await?;
The petId path parameter has format: uuid , so its setter takes a
uuid::Uuid . We haven’t called the setter for the optional
If-None-Match header, so the request leaves it out.
The builder checks the request’s generated validation constraints. The client method
also validates the request before constructing and sending the HTTP request. If
validation, transport, or response decoding fails, the method returns an error through
its outer anyhow::Result .
You can also construct requests with struct literals. The Builder Pattern chapter compares both forms and explains which checks happen during compilation and which happen at runtime.
Every HTTP method that OpenAPI allows gets a client method, including HEAD , OPTIONS
, and TRACE . For example, the petstore’s describePets operation is OPTIONS /pets
, and its 204 response lists the allowed methods in an Allow header:
if let ApiResponse::NoContent(WithHeaders { headers, .. }) =
client.describe_pets(DescribePetsRequest::default()).await?
{
println!("Allowed: {}", headers.allow);
}
Query Arrays
When a query parameter is an array, the client sends it according to the parameter’s
style and explode fields. The petstore’s searchPets operation has both kinds. This
excerpt shows two of its parameters:
{
"name": "colors",
"in": "query",
"style": "pipeDelimited",
"explode": false,
"schema": { "type": "array", "items": { "type": "string" } }
},
{
"name": "tags",
"in": "query",
"schema": { "type": "array", "items": { "type": "string" } }
}
Let’s search for pets with two tags and two coat colors:
let request = SearchPetsRequest::builder()
.q("golden retriever".to_string())
.tags(vec!["calm".to_string(), "shy".to_string()])
.colors(vec!["gold".to_string(), "cream".to_string()])
.build()?;
let response = client.search_pets(request).await?;
The client sends this query string:
q=golden+retriever&colors=gold%7Ccream&tags=calm&tags=shy
The tags parameter doesn’t set style or explode , so it uses OpenAPI’s default for
query parameters, form with explode: true . The client sends one tags= pair per
item. The colors parameter sets explode: false , so its items are joined with the
pipeDelimited separator into one value, gold|cream , which is percent-encoded as
gold%7Ccream .
The client serializes the query struct with serde_html_form
. It omits parameters set to None and accepts scalars or arrays of scalars. If the
untyped filter parameter contains a JSON object or nested array, the method returns an
error before sending the request.
The client percent-encodes reserved characters such as / , including when a parameter
sets allowReserved . The petstore’s q parameter sets that flag, but it doesn’t
change the generated request.
Cookie Parameters
Cookie parameters have setters like any other parameter. The petstore’s getPetCard
operation accepts a cardTheme cookie and a recentlyViewed list of pet IDs. Here,
first_viewed and second_viewed are two more uuid::Uuid values:
let request = GetPetCardRequest::builder()
.pet_id(pet_id)
.card_theme(GetPetCardRequestCookieCardTheme::Dark)
.recently_viewed(vec![first_viewed, second_viewed])
.build()?;
let response = client.get_pet_card(request).await?;
The client sends both cookies in one Cookie header:
cardTheme=dark; recentlyViewed=0192f0c1-7a6e-7c4e-9d2a-5b1e8f3c2a11%2C0192f0c1-7a6e-7c4e-9d2a-5b1e8f3c2a12
The client joins array items with commas, then uses the cookie crate
to percent-encode each value, including the commas. It omits optional cookie parameters
that you haven’t set.
Cookies from the client’s store, such as a cookie key , follow the
operation’s parameters in the same header: cardTheme=dark; session=... . If both
supply the same name, the parameter takes precedence. A stored cookie is still sent when
you leave the corresponding parameter unset.
Sending Request Bodies
When an operation declares a request body, its request type has a body field, and the
builder has a body() setter. The client sends the body in the media type the operation
declares and sets Content-Type to match:
| Declared media type | How the client sends the body |
|---|---|
application/json | As JSON, with Content-Type: application/json |
A +json type, such as application/merge-patch+json | As JSON, with the declared Content-Type |
application/x-www-form-urlencoded | As form fields |
text/*, such as text/plain | As text, with the declared Content-Type |
A binary type, such as image/png | As raw bytes, with the declared Content-Type |
A wildcard, such as */* or image/* | In the body’s own kind: JSON as application/json, text as text/plain, bytes as application/octet-stream |
multipart/form-data | As parts; see Sending Multipart Bodies |
A charset parameter other than UTF-8 on a text or JSON body is replaced with
charset=utf-8 , because the client sends that text as UTF-8. A binary body carries no
charset, so its charset parameter is dropped.
For example, the petstore’s updatePet operation takes a JSON merge patch. Its
update_pet() method sets the declared media type and then serializes the body:
let mut req_builder = self
.client
.patch(url)
.header(reqwest::header::CONTENT_TYPE, "application/merge-patch+json")
.json(&request.body);
In this excerpt from client.rs , optional patch fields use Option
. Serialization omits fields set to None , so this client can’t send null to clear a
nullable field such as nickname .
A body whose schema is a primitive, such as a string, keeps that schema’s constraints.
The petstore’s setPetNotes operation declares a text/plain body with
maxLength: 2000 , so the generated request validates the body like any other field:
let request = SetPetNotesRequest::builder()
.pet_id(pet_id)
.body("Prefers the quiet kennel by the window.".to_string())
.build()?;
let response = client.set_pet_notes(request).await?;
The client sends the notes as text/plain . Notes longer than 2,000 characters make
build() return a validation error, and the client method checks the same constraint
before sending.
Sending Multipart Bodies
When an operation’s request body uses multipart/form-data , the client method sends
the body struct as a form with one or more parts per property.
Multipart Form Bodies describes which part each
property becomes. The example below shows how the client builds the form.
The petstore’s submitIntakeForm operation takes an IntakeForm body with name ,
file , attachments , and quality fields, among others. Its submit_intake_form()
method moves each field into a reqwest::multipart::Form :
let body = request.body;
let mut form = reqwest::multipart::Form::new().percent_encode_noop();
form = form.text("name", body.name);
if let Some(value) = body.file {
form = form
.part(
"file",
reqwest::multipart::Part::bytes(value)
.file_name("file")
.mime_str("application/octet-stream")?,
);
}
for value in body.attachments {
form = form
.part(
"attachments",
reqwest::multipart::Part::bytes(value)
.file_name("attachments")
.mime_str("application/octet-stream")?,
);
}
if let Some(value) = body.quality {
form = form.text("quality", value.to_string());
}
req_builder = req_builder.multipart(form);
This is an excerpt from the generated client.rs , trimmed to those four fields. A
binary field becomes a file part with a filename, which is how servers such as FastAPI
tell an uploaded file from a text field. The filename is the property name because the
specification doesn’t name the file. An array sends one part per item under the same
name, and a None field sends no part at all. The bytes move into the part rather than
being copied. Part names are sent as raw UTF-8, as browsers send them, so the form’s
résumé property keeps its name.
When the multipart body itself is optional, as in the petstore’s uploadMedia
operation, the request’s body field is an Option . A None body
sends a request with no body and no Content-Type . A body whose fields are all unset
still sends an empty multipart/form-data body.
Reading Responses
ApiResponse represents a decoded HTTP response. Its variants identify statuses, and
its type parameters hold the operation’s success and failure payloads. For example, a
declared 404 response can decode successfully into an ApiResponse variant. A
transport or decoding failure returns an error through the method’s outer Result .
For show_pet_by_id , the success payload contains both a pet and response headers. Its
failures use two schemas: the 404 response returns a Problem , while the 4XX range
and the default response return an Error . The generator combines them in the
payload union ErrorOrProblem . We can match the response returned by
show_pet_by_id() in Making a Request :
match response {
ApiResponse::Ok(WithHeaders { headers, body: pet }) => {
println!("Pet: {}", pet.name);
println!("Cache status: {:?}", headers.x_cache);
}
ApiResponse::NotModified => println!("Pet unchanged"),
ApiResponse::NotFound(ErrorOrProblem::Problem(problem)) => {
eprintln!("Not found: {:?}", problem.detail);
}
ApiResponse::ClientError(status, ErrorOrProblem::Error(error))
| ApiResponse::Unknown(status, ErrorOrProblem::Error(error)) => {
eprintln!("{status}: {}", error.message);
}
other => anyhow::bail!("unexpected response: {other:?}"),
}
The Ok arm gives us the body and headers separately. NotModified is the 304
response, which has no body. ClientError represents the 4XX range and Unknown the
default response; both carry the status that arrived. The wildcard arm covers the
remaining variants because operations share one response enum, including variants that
this operation doesn’t return.
The client decodes each body in the media type its status declares. For example,
get_pet_card() returns its text/plain kennel card as ApiResponse<String, Problem>
, and get_pet_photo() returns its image/png photo as ApiResponse<Vec<u8>, Problem>
. See Response Media Types .
A response that the document doesn’t describe comes back as ApiResponse::Other ,
holding the unread reqwest::Response . That includes a
status the operation doesn’t declare, such as 405 Method Not Allowed . It also
includes a declared status under a media type the status doesn’t declare, such as an
empty text/html 404 page from a proxy where showPetById promises a JSON Problem
. The method returns these responses as values rather than errors, so you can still read
the status, headers, and body:
if let ApiResponse::Other(response) = response {
let status = response.status();
let body = response.text().await?;
eprintln!("{status}: {body}");
}
The method returns an error when the request fails, or when a response the document describes can’t be decoded as described.
An optional response header becomes None when it’s absent or can’t be parsed. For a
list header, one invalid element makes the whole value None . A missing or invalid
required header causes decoding to return an error. See Responses
for how the generator chooses payload types, combines headers, and represents undeclared
statuses.
Missing and Null Fields
A struct whose schema gives any property a default or a const has
#[serde(default)] , so a missing field takes its value from the struct’s Default
implementation, as Field Defaults describes. The
client also reads null in such a struct’s required fields. Each required field without
a default of its own gets a serde_with::DefaultOnNull adapter, which reads null as
the field type’s default:
#[serde_with::serde_as]
#[serde(default)]
pub struct Order {
/* ... */
#[serde_as(as = "serde_with::DefaultOnNull")]
#[serde(rename = "petId")]
pub pet_id: uuid::Uuid,
/* ... */
}
Both "petId": null and an omitted petId give pet_id the nil UUID. When a field
already has an adapter, DefaultOnNull wraps it; for example,
serde_with::DefaultOnNull<serde_with::base64::Base64> . A field with its own default
or const still rejects null .
Constant Properties
A property with a const , or with an enum that lists a single value, has that one
value as its default. The client reads the property from the response like any other
field, so it keeps the value the server sends. The optional object property of
ChatPreference becomes:
pub struct ChatPreference {
/* ... */
#[default(Some("chat.preference".to_string()))]
pub object: Option<String>,
/* ... */
}
If the response omits object , deserialization uses the constant from
#[default(...)] . If the response supplies a different value, deserialization keeps
it. The constant sets a default; it doesn’t validate the received value.
Rejections from a Generated Server
A generated server answers some requests before its service runs. Missing credentials
receive 401 , and malformed headers or bodies receive 400 . Each of these responses
carries a text/plain reason rather than a body from the document’s error schemas; see
Returning Responses . The client decodes a
body only under a media type that its status declares, so unless the status declares a
plain-text body or no body at all, the method returns ApiResponse::Other with the
unread response.
For example, calling list_api_keys() without setting the ApiKeyAuth key sends a
request that a generated server rejects with 401 . Although listApiKeys declares a
JSON Error body for 401 , the method returns ApiResponse::Other rather than
ApiResponse::Unauthorized , and the response’s text names the missing credential. Set
the required credentials before calling an operation. The service’s own 401 responses,
such as one for a key it doesn’t recognize, use the declared body and decode normally.
Decoding a Response from Your Own Transport
Client request types also provide a parse_response() associated function. If you send
the HTTP request yourself, you can pass the resulting
reqwest::Response to the same decoder the generated client
uses:
let response = ShowPetByIdRequest::parse_response(http_response).await?;
Here, http_response is the response you’ve already received. The decoder reads
declared headers before decoding the body and returns the operation’s ApiResponse
type.
Reading Server-Sent Events
Some operations send a stream of events rather than one body. OpenAPI describes this
with a text/event-stream response, the media type of Server-Sent Events
(SSE), and the response’s schema describes one event. The petstore’s streamPetVitals
operation streams VitalsReading events, so its client method returns
ApiResponse<oas3_gen_support::EventStream<oas3_gen_support::SseFrame<VitalsReading>>, ()>
.
EventStream is a Stream whose items are
Result<SseFrame<VitalsReading>, DiagnosticsError> . Each SseFrame contains one
received event. Its accessors return:
data(): the event data decoded from JSON.event(): the event name.id(): the last event ID set by the stream.retry(): the most recent reconnection delay requested by the stream.
The stream creates these read-only frames. To print readings as they arrive, iterate over them:
use futures::StreamExt;
let request = StreamPetVitalsRequest::builder().pet_id(pet_id).build()?;
let mut readings = match client.stream_pet_vitals(request).await? {
ApiResponse::Ok(readings) => readings,
other => anyhow::bail!("unexpected response: {other:?}"),
};
while let Some(reading) = readings.next().await {
match reading {
Ok(frame) => println!("{} bpm", frame.data().heart_rate_bpm),
Err(e) => eprintln!("skipped an event: {e}"),
}
}
The stream_pet_vitals() method returns once the response headers arrive. The readings
are decoded as the server sends them, and the loop ends when the server closes the
stream. StreamExt::next() comes from the futures
crate, which the generated client manifest doesn’t declare, so add futures to your own
dependencies. When you only need the data, SseFrame::into_data() gives it to you
without the rest of the frame.
An event whose data doesn’t match VitalsReading yields a
DiagnosticsError::DeserializationError whose path names the field under data that
failed, such as data.heartRateBpm , and the stream continues with the next event. An
event without data is skipped. If reading the response body fails, the stream yields
DiagnosticsError::BodyReadError and then ends.
The parser follows the HTML standard’s event-stream rules . Lines can end with LF, CR, or CRLF, comment lines and unknown fields are ignored, a leading byte order mark is dropped, and invalid UTF-8 becomes U+FFFD. An event that the server cuts off before its closing blank line is never yielded.
Reading Whole Frames
Some response schemas describe an event’s data and metadata together. When a
text/event-stream schema is an object whose properties all come from data , event
, id , and retry , with data among them, the generator treats it as a frame
type. Speakeasy uses this convention, and OpenAPI 3.2 adopts it for event streams. The
petstore’s streamAssistantMessage streams a frame type:
"AssistantEventFrame": {
"type": "object",
"required": ["data", "event"],
"properties": {
"data": { "$ref": "#/components/schemas/AssistantEvent" },
"event": { "type": "string", "enum": ["message_start", "message_stop", "ping"] },
"id": { "type": "string" },
"retry": { "type": "integer", "minimum": 0 }
}
}
The excerpt omits some event names. For this schema, the client generates an alias of
SseFrame using the declared data and event types:
pub type AssistantEventFrame = oas3_gen_support::SseFrame<Box<AssistantEvent>, AssistantEventFrameEvent>;
Each part of the frame decodes like this:
datadecodes as its own schema. A plainstringkeeps the frame’s text as it is. A string withcontentMediaType: application/jsonand acontentSchemadecodes as that schema. Anything else, such as theAssistantEventreference here, decodes from JSON.eventdecodes into its declared type, such asAssistantEventFrameEvent. An unrecognized name produces aDeserializationErrorwithpath: event. If the frame omitsevent:, its name ismessage. That name becomesNonewhen the property is optional. An event declared as a string orconstusesString, the default event type.id()andretry()come from the stream, whatever types theidandretryproperties declare. A frame without anid:line keeps the last ID the stream set, including one sent in a frame without data, as browsers report it.retry()works the same way for the reconnection time.
A text/event-stream schema can also be a oneOf of frame types that each fix event
to a constant. The petstore’s streamAssistantTokens streams AssistantTokenFrame , a
oneOf of TokenFrame and UsageFrame whose events are token and usage . The
client generates an enum of the frames the members alias, SseFrame<String> and
SseFrame<Usage> , and picks the member from the frame’s event name. Let’s print the
tokens as they arrive and then the usage:
use futures::StreamExt;
let mut frames = match client.stream_assistant_tokens(StreamAssistantTokensRequest {}).await? {
ApiResponse::Ok(frames) => frames,
other => anyhow::bail!("unexpected response: {other:?}"),
};
while let Some(frame) = frames.next().await {
match frame? {
AssistantTokenFrame::Token(token) => print!("{}", token.data()),
AssistantTokenFrame::Usage(usage) => println!("\n{} tokens", usage.data().output_tokens),
}
}
A frame whose event name no member declares yields a DeserializationError whose path
is event , and the stream continues with the next frame. A frame without an event:
line goes to the member whose event is message , if there is one.
A frame schema may also appear in a JSON body or as another schema’s property. In that
case, the client preserves the generated struct for those uses. The stream still reads
SseFrame<..> values with the struct’s data and event types. If another schema
refers to a union of frame types, that union uses ordinary JSON event data instead of
whole-frame decoding.
streamAssistantMessage answers 200 with either a ContentResponse document or the
stream, so its payload is the union ContentResponseOrAssistantEventFrameStream . The
stream member is named after its item type with a Stream suffix, and the response’s
Content-Type decides which member you receive.
Ending and Resuming a Stream
A text/event-stream media type can name the data that ends the stream with the
x-sse-sentinel or x-speakeasy-sse-sentinel extension. When a media type declares
both, x-sse-sentinel wins. streamAssistantMessage declares
x-speakeasy-sse-sentinel: "[DONE]" , so its stream returns None when an event’s data
is [DONE] , and streamAssistantTokens declares x-sse-sentinel: "[END]" . The
sentinel itself is never decoded, so it doesn’t produce an error item. Without a
sentinel, the stream ends when the server closes it.
To resume an interrupted stream, wait for the delay from EventStream::retry() and send
EventStream::last_event_id() to the server, for example in a Last-Event-ID header.
These methods report the stream’s current state, including updates from frames without
data. A previously received frame’s id() and retry() retain the state from when that
frame arrived.
Deprecated Operations
When an operation sets deprecated: true , its client method carries Rust’s
#[deprecated] attribute, so code that calls it gets a compiler
warning. The petstore’s listCats operation has been superseded by listPets . This
excerpt shows the start of its generated method:
/// List cats
///
/// Superseded by `listPets` with `x-only: cat`.
///
/// * Path: `GET /cats`
#[deprecated]
pub async fn list_cats(
&self,
request: ListCatsRequest,
) -> anyhow::Result<ApiResponse<WithHeaders<XNextHeaders, Cats>, Error>> {
The method still sends the request. The operation’s description, copied into the
method’s documentation, usually tells you what to use instead. To keep calling the
method while you migrate, add #[allow(deprecated)] to the calling function.
Choosing Operations and Names
Use --only or --exclude to select
operations. Use --fn-name to rename
methods and their request types, or
--api-name to name the client itself. The
shared reference also covers visibility, enums,
type adapters, and collection choices.
Server Generation
Server generation creates an Axum router, a service trait, and request and response types. You implement the trait to handle each operation. We’ll start with one petstore operation, then follow a request through credential extraction, body decoding, and response handling.
The commands run from a checkout of this repository and use the petstore fixture,
fixtures/petstore.json . If you installed the tool, replace cargo run -- with
oas3-gen and supply the path to your OpenAPI document. Rust excerpts assume you’ve
imported the generated types. They show the relevant part of a service, not a complete
application with a listening socket and runtime.
- Generating a Server Module
- Implementing the Service Trait
- Connecting the Router
- Receiving Credentials
- Receiving Cookie Parameters
- Receiving Request Bodies
- Returning Responses
- Streaming Server-Sent Events
- Choosing Operations and Names
Generating a Server Module
The server-mod mode creates a directory containing the types, server trait, and
router. Let’s generate the petstore’s listPets operation:
cargo run -- generate server-mod \
-i fixtures/petstore.json -o src/api/ \
--only list_pets
The --only flag takes the normalized operation ID, list_pets , and keeps this
example to one operation. Omit it to generate all the document’s operations. The output
directory contains:
src/api/
├── mod.rs
├── types.rs
└── server.rs
The mod.rs file declares the modules and re-exports their items. Add mod api; to
your crate root to include this directory, and add the dependencies referenced by the
generated code to your manifest.
You can also generate a separate crate by adding --workspace and choosing a crate
directory as the output path. The generator then creates a Cargo.toml and uses
src/lib.rs as the module root. See
Workspace Crate Output for details.
Implementing the Service Trait
The generated ApiServer trait has one method per selected operation. For our filtered
petstore, it contains list_pets() :
pub trait ApiServer: Send + Sync {
/// List pets
///
/// Lists pets one page at a time. Anonymous callers may list pets; an API key is optional.
///
/// * Path: `GET /pets`
fn list_pets(
&self,
request: ListPetsRequest,
) -> impl std::future::Future<
Output = anyhow::Result<
ApiResponse<
WithHeaders<XNextAndXTotalCountAndXRequestIdHeaders, Pets>,
Error,
>,
>,
> + Send;
}
The method returns a std::future::Future whose output is an
anyhow::Result . Your async implementation receives a request
struct and returns a response enum. The 200 response of listPets declares three
headers, x-next , X-Total-Count , and X-Request-Id , so the success payload pairs
the Pets body with a struct named after those headers. For example, this service
returns an empty page of pets with a total count of zero and no next-page link:
#[derive(Clone)]
struct PetService;
impl ApiServer for PetService {
async fn list_pets(
&self,
_request: ListPetsRequest,
) -> anyhow::Result<
ApiResponse<WithHeaders<XNextAndXTotalCountAndXRequestIdHeaders, Pets>, Error>,
> {
Ok(ApiResponse::Ok(WithHeaders {
headers: XNextAndXTotalCountAndXRequestIdHeaders {
x_next: None,
x_total_count: Some(0),
x_request_id: None,
},
body: vec![],
}))
}
}
The outer Ok is the method’s successful
Result. The inner ApiResponse::Ok selects HTTP status 200. WithHeaders holds the response headers and body together; Response Headers
explains how the generator names the header struct. Replace the empty
Vec with the pets your service retrieves.
The service must implement Send , Sync , and
Clone to work with the router. This example has no state and derives
Clone . When you add state, decide which values each clone should own and which it
should share.
The generated handlers don’t call validate() on a request. To check incoming values
against the document’s constraints, call it in your service method with
validator::Validate in scope. A trait method for an operation
marked deprecated: true has no #[deprecated] attribute.
Connecting the Router
Pass your service to the generated router() function:
let app = api::router(PetService);
Here, api is the module generated above, and app is an
axum::Router you can use in your Axum application. For the
selected petstore operation, the generated function connects the route and stores your
service as router state:
pub fn router<S>(service: S) -> Router
where
S: ApiServer + Clone + Send + Sync + 'static,
{
Router::new()
.route("/pets", axum::routing::get(list_pets::<S>))
.with_state(service)
}
This is an excerpt from server.rs ; it omits the generated handler and imports. The
handler extracts the credentials, query parameters, and headers, assembles a
ListPetsRequest , and calls your list_pets() method. Credential extraction is
described in Receiving Credentials .
The handler reads query parameters with
axum_extra::extract::Query , which collects a repeated
name, such as tags=calm&tags=shy , into an array. With --workspace , the generated
manifest enables axum-extra’s query feature. With module output, enable it in your own
axum-extra dependency.
The sections below use other petstore operations. To follow those examples, generate the
full module by running the command above without --only .
Receiving Credentials
For an operation that accepts API keys or bearer tokens, the generated request has a
credentials field. Axum extracts its value before calling your service. The
shared security rules explain how the document declares
schemes and combines requirements.
The petstore’s top-level security requires a header key named ApiKeyAuth , and its
staff key operations use the other combinations. To examine their credentials, generate
those operations into a separate module:
cargo run -- generate server-mod \
-i fixtures/petstore.json -o src/staff/ \
--only list_api_keys,create_api_key,get_api_key,revoke_api_key,replace_api_key,list_audit_events \
--enable-builders
The examples in this section use types from that module. The listApiKeys operation
inherits the top-level requirement. Its credentials and request structs include these
fields; the request excerpt omits its attributes:
/// Credentials an operation accepts, read from the request before the operation runs.
#[derive(Debug, Clone, oas3_gen_support::Default)]
pub struct ApiKeyAuthCredentials {
/// Key issued in the Petstore partner dashboard.
///
/// `ApiKeyAuth` API key, read from the `X-Api-Key` header.
pub api_key_auth: secrecy::SecretString,
}
pub struct ListApiKeysRequest {
pub query: ListApiKeysRequestQuery,
pub credentials: ApiKeyAuthCredentials,
}
A key required by every alternative uses SecretString . A key
that can be omitted uses Option<SecretString> . For example,
createApiKey accepts either a query key or a session cookie, so its credentials struct
has two optional fields:
/// Credentials an operation accepts, read from the request before the operation runs.
#[derive(Debug, Clone, oas3_gen_support::Default)]
pub struct QueryKeyAndSessionCookieCredentials {
/// `QueryKey` API key, read from the `api_key` query parameter.
pub query_key: Option<secrecy::SecretString>,
/// `SessionCookie` API key, read from the `session` cookie.
pub session_cookie: Option<secrecy::SecretString>,
}
The extractor requires at least one of these optional fields to be present. To allow a
request without credentials, the operation must list an empty alternative. The
listPets operation from the start of this chapter declares [{}, {"ApiKeyAuth": []}]
, so its credentials hold an Option<SecretString> , and a request without a key still
reaches your service.
Extracting Credentials
Each credentials struct implements Axum’s
FromRequestParts . An extractor reads header,
query, or cookie values before the handler reads the other parameters or the body.
Cookie keys are parsed from the Cookie header with axum-extra’s cookie support. With
module output, enable the cookie feature of your axum-extra dependency. A cookie key
is percent-decoded like a cookie parameter, so a value sent as abc%2Bdef reaches your
service as abc+def . A value that isn’t valid percent-encoded UTF-8, such as abc%ff
, counts as missing.
A bearer token comes from the Authorization header. The extractor accepts the Bearer
prefix in any case and stores the token that follows it. An Authorization header with
a different scheme, such as Basic , doesn’t supply a bearer token. An apiKey scheme
named Authorization reads the whole header value instead, so when an operation accepts
both kinds, one header can fill both fields. For listAuditEvents , a request with
Authorization: Bearer t1 sets bearer_auth to t1 and token_auth to Bearer t1 .
When a required key is missing, or the supplied keys don’t complete any accepted
alternative, Axum returns a plain-text 401 without reading the body or calling your
service. For the required header key of listApiKeys , the response message is:
missing API key in the `X-Api-Key` header
The revokeApiKey operation accepts a bearer token or the header key, so its
ApiKeyAuthAndBearerAuthCredentials struct has two optional fields. A request that
supplies neither receives this message:
missing credentials for `ApiKeyAuth` or `BearerAuth`
This response comes from the extractor, so it doesn’t use the JSON error schema that
your operation might declare for 401 . The
client chapter explains how
that affects response decoding.
If a requirement includes a scheme the generator doesn’t support, such as HTTP basic
authentication, it can’t perform the complete presence check. All credential fields for
that operation become optional. For example, replaceApiKey accepts BasicAuth or
ApiKeyAuth , so a request without the header key still reaches your service. Your
application must enforce those requirements; see Requirements
.
Reading and Checking Keys
The extractor checks for required credentials. Your service checks whether the supplied
values are valid and authorize the request. Inside the list_api_keys() implementation,
bring the secrecy::ExposeSecret trait into scope and read the
header key like this:
use secrecy::ExposeSecret;
let key: &str = request.credentials.api_key_auth.expose_secret();
Use key in your authorization check. If the key isn’t recognized, you can return the
operation’s declared 401 response from that method:
return Ok(ApiResponse::Unauthorized(Error {
code: 401,
message: "The API key is not recognized.".to_string(),
}));
Here, Error is the petstore’s generated error type. The handler serializes it as JSON,
so a generated client can decode it as ApiResponse::Unauthorized .
Credentials structs don’t derive
PartialEq because SecretString doesn’t implement it. Access a value through expose_secret() when you need to check it. The secret type’s debug output hides its value, but the exposed &str is ordinary text. See Keeping Keys Out of Logs
.
Shared Types and Field Names
Operations with the same security requirements share a credentials struct, including
when they list alternatives in a different order. Names come from the schemes, such as
ApiKeyAuthCredentials or QueryKeyAndSessionCookieCredentials . A name that’s already
in use gets a numeric suffix. In the staff module, replaceApiKey , whose header key is
optional, gets ApiKeyAuthCredentials2 .
The request’s credentials field also gets a suffix when that name conflicts with an
operation parameter. For example, it can become credentials_2 . Use the generated
field name when reading the keys in your implementation.
Constructing Requests in Tests
When builders are enabled, a server request builder accepts the whole credentials
struct. For example, a test can construct a ListApiKeysRequest without going through
HTTP extraction:
let credentials = ApiKeyAuthCredentials {
api_key_auth: secrecy::SecretString::from("example-test-key"),
};
let request = ListApiKeysRequest::builder()
.limit(10)
.credentials(credentials)
.build()?;
The builder assembles the query parameters and runs the request’s generated validation. It doesn’t check whether the key is authorized. Calling a service method with a constructed request also bypasses Axum’s credential extractor, so use HTTP requests when testing extraction and rejection behavior.
Receiving Cookie Parameters
An operation’s cookie parameters arrive in the request’s cookie field. The handler
parses them from the Cookie header and percent-decodes each value before calling your
service. A value that isn’t valid percent-encoded UTF-8 counts as missing. An array
cookie with no items, sent as name= , arrives as an empty list. The petstore’s
getPetCard operation declares two optional cookies, so your get_pet_card() method
reads request.cookie.card_theme and request.cookie.recently_viewed .
A missing required cookie or a value that doesn’t parse as its type produces status
400 with a plain-text reason. The handler doesn’t call your method. The conversation
cookie that listAssistantEvents requires produces the first message when it’s missing,
and a cardTheme outside its enum produces the second:
Bad request: missing required cookie `conversation`
Bad request: invalid value for cookie `cardTheme`
With module output, enable the cookie feature of your axum-extra dependency.
Receiving Request Bodies
When an operation declares a request body, the generated request struct has a body
field, and the handler fills it before calling your service. The field’s type follows
the media type the operation declares:
| Declared media type | body field |
|---|---|
application/json, or a +json type such as application/merge-patch+json | The generated body type, decoded from JSON |
application/x-www-form-urlencoded | The generated body type, decoded from the form |
text/*, such as text/plain | A String |
A binary type, such as image/png | A Vec<u8> |
multipart/form-data | The generated body type; see Receiving Multipart Bodies |
For example, setPetNotes declares a text/plain body, so the notes arrive as a
String in the body field of SetPetNotesRequest . replacePetPhoto declares an
image/png body, so its photo arrives as bytes. When a text or binary body is optional,
the field is an Option , and a request with an empty body gives you
None . An optional form body, such as the one unsubscribeNewsletter declares, is
None when the request has no Content-Type .
A JSON or form body that can’t be decoded never reaches your method. If the body isn’t
valid JSON, doesn’t match the body type, or arrives with another Content-Type , the
handler responds with status 400 and a plain-text reason that starts with
Bad request: , followed by Axum’s description of the problem. A form body that
doesn’t match its type is answered the same way.
Receiving Multipart Bodies
When an operation’s request body uses multipart/form-data , the generated handler
reads the parts back into the body struct before calling your service.
Multipart Form Bodies describes which part each
property becomes.
The generated server extracts the body with Axum’s
Multipart extractor, which needs Axum 0.8.5 or later with the multipart feature. With --workspace, the generated manifest declares both. With module output, add the feature to your own axum dependency. Each handler contains a function that reads the parts. Text parts are converted by the rules in Choosing the Part for Each Property
and collected into a JSON object. The function then deserializes the struct from that
object in one step, so renamed fields, defaults, and #[serde_as] adapters behave as
they do for JSON bodies. File parts skip that step: their bytes are assigned to the
struct afterward.
A missing required scalar part or a part whose text doesn’t match its type produces
status 400 before your service runs. For example, a count part in the petstore’s
submitIntakeForm body containing three produces Bad request: invalid multipart part `count` : expected ident at line 1 column 2. Arrays have different rules because
an empty array sends no parts. A missing required array becomes an empty array. An
optional array becomes None whether the sender omitted it or supplied an empty array.
Null array items are dropped.
When the multipart body itself is optional, as in the petstore’s uploadMedia
operation, the request’s body field is an Option . A request
without a Content-Type header has no body, and your service receives None . A
request that sends a body with another Content-Type , such as application/json ,
receives 400 with the reason Bad request: the body must be `multipart/form-data`.
Axum limits request bodies to 2 MB by default. A larger upload fails while the parts are
read, and the handler answers 400 . To accept larger files, add
DefaultBodyLimit as a layer on the router.
Returning Responses
Your method returns the same response shapes described in
Responses . The generated handler converts an ApiResponse
variant into an HTTP status, body, and any declared headers. A () or
None body produces a response without a body. Other(status, body) , when present,
carries a raw byte body and its status.
The handler sends each body in the media type that its status declares, so you return Rust values and the handler chooses the encoding:
| Declared media type | Sent as |
|---|---|
application/json | JSON, serialized with axum::Json |
A +json type, such as application/problem+json | JSON, labelled with the declared Content-Type |
text/*, such as text/plain | Text, labelled with the declared Content-Type, when the body is a string, byte vector, number, boolean, value enum, or named string schema. Any other body is sent as JSON, labelled with the declared text type when the status declares no JSON type |
A binary type, such as image/png or application/octet-stream | The raw bytes, labelled with the declared Content-Type and no charset, when the body is Vec<u8> or a string. Any other body is sent as JSON |
text/event-stream | A stream of events; see Streaming Server-Sent Events |
A wildcard such as image/* or text/* names a range of types, so the handler labels
the body with a concrete one instead: application/octet-stream for binary and
text/plain for text. A */* declares a JSON body, which is sent as application/json
. The handler sends Rust strings as UTF-8, so a charset other than UTF-8 is replaced
with utf-8 . A binary label carries no charset .
The petstore’s updatePet operation declares a Pet as application/json for 200 ,
and a Problem as application/problem+json for 404 and 422 . When the pet doesn’t
exist, your update_pet() method can return:
return Ok(ApiResponse::NotFound(Problem {
title: Some("Pet not found".to_string()),
status: Some(404),
..Problem::default()
}));
The handler serializes the Problem as JSON and sends it with
Content-Type: application/problem+json . Likewise, getPetCard returns a String
that’s sent as text/plain , and getPetPhoto returns a Vec<u8>
that’s sent as image/png .
You can also return a status that the operation doesn’t declare, such as
ApiResponse::Conflict from update_pet() . The handler then uses the first media type
the operation declares for the same payload: application/json for a Pet and
application/problem+json for a Problem . A payload that combines several body types
into a union, such as ErrorOrProblem for showPetById , sends each member under the
media type it was declared with: Error as application/json and Problem as
application/problem+json . A status that offers both JSON and text/event-stream
works the same way, sending its stream member as events.
Use a declared response variant when the result is part of your API’s contract, such as
an authorization failure. Returning Err from the service method causes the handler to
send status 500 . A response header value that can’t be represented as an HTTP header
also produces 500 .
Some requests never reach your method. The handler answers these itself, with a plain-text reason instead of a body from the document’s error schemas:
- A missing or malformed required request header, a JSON or form body that
doesn’t match its type, and a
multipart/form-databody that the handler can’t read all produce400. The reason starts withBad request:. See Receiving Request Bodies and Receiving Multipart Bodies. - A path or query value that doesn’t match its type also produces
400, from the Axum extractor that reads it. - A missing required cookie parameter, or a cookie value that doesn’t parse as
its type, produces
400. See Receiving Cookie Parameters. - Missing credentials produce
401, as described in Extracting Credentials.
Error responses returned by your service use the document’s declared bodies. Rejections from a Generated Server shows how a generated client reports the handler’s rejections.
Streaming Server-Sent Events
Some operations send a stream of events rather than one body. OpenAPI describes this
with a text/event-stream response, the media type of Server-Sent Events
(SSE). The petstore’s streamPetVitals operation uses one to send live smart-collar
readings:
"responses": {
"200": {
"description": "A stream of vitals readings",
"content": {
"text/event-stream": {
"schema": { "$ref": "#/components/schemas/VitalsReading" }
}
}
}
}
The schema describes one event. The generated trait method returns a
BoxStream of VitalsReading values:
/// Stream live smart-collar vitals as server-sent events
///
/// * Path: `GET /pets/{petId}/vitals`
fn stream_pet_vitals(
&self,
request: StreamPetVitalsRequest,
) -> impl std::future::Future<
Output = anyhow::Result<
ApiResponse<futures::stream::BoxStream<'static, VitalsReading>, ()>,
>,
> + Send;
BoxStream comes from the futures crate. When the generator writes a Cargo.toml ,
it declares futures , and your own crate needs it too so your service can build the
stream. When you already have every event, turn them into a stream with
stream::iter() and box it with
StreamExt::boxed() . This body of stream_pet_vitals() sends
one reading and then ends the stream:
use futures::StreamExt;
let reading = VitalsReading {
id: "reading-1".to_string(),
heart_rate_bpm: 92,
..VitalsReading::default()
};
Ok(ApiResponse::Ok(futures::stream::iter([reading]).boxed()))
When events arrive over time, box any Stream that’s
Send + 'static . For example, the receiving half of a
futures::channel::mpsc channel is a stream:
use futures::StreamExt;
let (sender, receiver) = futures::channel::mpsc::channel(16);
tokio::spawn(forward_readings(request.path.pet_id, sender));
Ok(ApiResponse::Ok(receiver.boxed()))
Here, forward_readings stands for your own async function that sends each new reading
through sender , and tokio::spawn runs it alongside the
response. The response stays open as long as the channel does. When forward_readings
returns and drops the sender, the stream ends, and so does the response.
The generated handler wraps the stream in an Axum Sse response.
Each item becomes the JSON data of one Event , in order:
Ok(ApiResponse::Ok(body)) => (
http::StatusCode::OK,
axum::response::sse::Sse::new(futures::StreamExt::map(body, |item| {
axum::response::sse::Event::default().json_data(item)
})),
)
.into_response(),
This is an excerpt from the stream_pet_vitals handler in server.rs . An item that
can’t be serialized ends the response.
Sending Whole Frames
When the text/event-stream schema is a frame type, as described in
Reading Whole Frames , your method
returns values of that type, and the handler sends each one as a whole event. The
petstore’s stream_assistant_message() returns AssistantEventFrame values in the
AssistantEventFrameStream member of its payload union.
For each frame type, the generator implements
TryFrom<AssistantEventFrame> for axum::response::sse::Event in types.rs . The
conversion writes the item’s data as JSON, or as text when the schema declares a plain
string , and sets the event’s name, ID, and retry time from the event , id , and
retry properties that hold a value. A union of frame types, such as
AssistantTokenFrame , converts whichever member it holds, and sends a member that has
no event property under its discriminator value. The handler maps each item through
that conversion:
axum::response::sse::Sse::new(futures::StreamExt::chain(
futures::StreamExt::map(body, axum::response::sse::Event::try_from),
futures::stream::iter([Ok(axum::response::sse::Event::default().data("[DONE]"))]),
))
This is an excerpt from the stream_assistant_message handler. Because the operation
declares the sentinel [DONE] , through x-speakeasy-sse-sentinel , the handler sends
data: [DONE] after your last item. x-sse-sentinel works the same way. An event or
id whose text has a line break, an id with a NUL character, or a retry that isn’t
a whole, non-negative number of milliseconds can’t be sent, so the conversion returns an
error and the response ends.
Choosing Operations and Names
Use --only or --exclude to select
which trait methods and routes to generate. Use
--fn-name to rename methods and
request types, or --api-name to rename
the service trait. The shared reference covers
the remaining options, and Builder Pattern explains request
construction.
Builder Pattern with bon
When you construct a generated type with a struct literal, you supply a value for every
field. This includes writing None for optional fields you don’t need. A builder lets
you set fields through methods and then construct the value with a call to build() .
Schema builders return a value directly. Request builders also validate the request and
return a Result . We’ll use both forms and see which checks happen during compilation
and which happen at runtime.
The examples use the petstore fixture, fixtures/petstore.json . Its NewPet schema
holds the fields a caller supplies when adding a pet: a required name and species ,
and five optional fields. Your generated types will reflect the fields in your own
specification. Code excerpts omit imports. Put construction examples inside a function;
examples that use ? need a compatible return type, such as
anyhow::Result<()> .
Let’s begin with a struct literal:
let pet = NewPet {
name: "Biscuit".to_string(),
species: Species::Dog,
nickname: None,
birth_date: None,
weight_kg: None,
microchip: None,
tags: None,
};
We want to supply a name and a species, leaving the optional fields unset. With builders enabled, we can write that as:
let pet = NewPet::builder()
.name("Biscuit".to_string())
.species(Species::Dog)
.build();
The builder supplies None for the optional fields and requires name and species
before build() is available.
Enabling Builders
Pass --enable-builders during code generation:
oas3-gen generate client-mod -i api.json -o src/api/ --enable-builders
You can use this flag with any generation mode: types , client , client-mod , or
server-mod . The generated code uses the bon
crate to create the builder methods.
Adding bon to Your Project
If you’re including generated source files in an existing crate, add bon to that
crate’s Cargo.toml :
[dependencies]
bon = { version = "3.10", features = ["implied-bounds"] }
The compiler needs this dependency to resolve the generated bon attributes. If your
crate already declares a compatible version, use that dependency. When you generate a
crate with --workspace , the generator adds the dependency for you. See
Workspace Crate Output .
What Changes in the Generated Code
The generator adds builders in two places. Schema structs receive a
bon::Builder derive. Request structs receive a constructor with
a #[builder] attribute. These produce similar method chains, but their build()
methods have different return types.
Schema Structs
Schema structs represent objects from your OpenAPI schemas. Without builders, our
NewPet definition looks like this:
/// The fields a caller supplies when adding a pet.
#[serde_with::skip_serializing_none]
#[derive(
Debug,
Clone,
PartialEq,
Serialize,
validator::Validate,
oas3_gen_support::Default
)]
pub struct NewPet {
#[validate(length(min = 1u64, max = 64u64))]
pub name: String,
/// Kinds of pets the store cares for
pub species: Species,
pub nickname: Option<String>,
#[serde(rename = "birthDate")]
pub birth_date: Option<chrono::NaiveDate>,
#[serde(rename = "weightKg")]
#[validate(range(max = 500.0, exclusive_min = 0.0))]
pub weight_kg: Option<f64>,
#[validate(regex(path = "REGEX_NEW_PET_MICROCHIP"))]
pub microchip: Option<String>,
#[validate(length(max = 10u64))]
pub tags: Option<indexmap::IndexSet<String>>,
}
With --enable-builders , the generator adds bon::Builder to
the end of the derive list. The rest of the definition doesn’t change, so this excerpt
shows only the new derive attribute:
#[derive(
Debug,
Clone,
PartialEq,
Serialize,
validator::Validate,
oas3_gen_support::Default,
bon::Builder
)]
The derive adds the NewPet::builder() associated function. We can use it to set an
optional weight as well as the required fields:
let pet = NewPet::builder()
.name("Biscuit".to_string())
.species(Species::Dog)
.weight_kg(12.5)
.build();
Notice that weight_kg() accepts an f64 . The builder wraps it in
Some for us. If we leave out that call, weight_kg is None , as in our first
example.
Here, build() returns a NewPet directly. The derive does not call
validator::Validate::validate() . The #[validate]
attributes above come from the schema’s constraints: name must be 1 to 64 characters
long, weight_kg must be greater than 0 and at most 500, microchip must match the
schema’s pattern, and tags can hold at most 10 entries. To check them, call
validate() on the constructed value with the validator::Validate
trait in scope:
use validator::Validate;
pet.validate()?;
Validation depends on the schema’s constraints. For example, a multipleOf constraint
becomes a custom check: the petstore’s CollarTelemetry schema declares multipleOf: 5
for batteryPercent , so that field validates with oas3_gen_support::multiple_of::<5>
.
Request Structs
Request structs group parameters into nested structs for paths, query strings, and
headers. For example, the petstore fixture’s ShowPetByIdRequest contains both a path
parameter and headers. It derives validator::Validate and marks
the headers for nested validation:
/// Get a pet
#[derive(Debug, Clone, validator::Validate, oas3_gen_support::Default)]
pub struct ShowPetByIdRequest {
pub path: ShowPetByIdRequestPath,
#[validate(nested)]
pub header: ShowPetByIdRequestHeader,
}
With builders enabled, the generator adds a constructor that accepts the individual parameters and assembles those nested structs:
#[bon::bon]
impl ShowPetByIdRequest {
/// Create a new request with the given parameters.
#[builder]
pub fn new(
pet_id: uuid::Uuid,
if_none_match: Option<String>,
x_api_version: String,
) -> anyhow::Result<Self> {
let request = Self {
path: ShowPetByIdRequestPath { pet_id },
header: ShowPetByIdRequestHeader {
if_none_match,
x_api_version,
},
};
request.validate()?;
Ok(request)
}
}
The #[bon::bon] macro processes the constructor’s #[builder]
attribute, creating setters for pet_id , if_none_match , and x_api_version .
Calling build() calls this constructor, including request.validate() . That’s why a
request builder returns a Result and the schema builder above
returns its value directly.
The same approach applies to requests with query parameters or request bodies: their
constructor parameters become builder setters. For an Option<T>
parameter, such as the optional If-None-Match header’s if_none_match , you can
supply a T or omit the setter to use None .
Server requests can also contain credentials for API key or bearer token authentication.
Their builders accept a complete credentials struct through the credentials() setter.
Building the request doesn’t check whether a credential is authorized; your service
performs that check. See
Constructing Requests in Tests
for an example and how it differs from HTTP extraction.
Setter Names
Setters usually have the same names as their fields or parameters. These names need an adjustment:
| Field or parameter | Setter | Reason |
|---|---|---|
build or builder | build_value(), builder_value() | bon uses these names itself |
A name that starts with a digit, such as _2fa | value_2fa() | bon drops a leading underscore, and 2fa isn’t a valid name |
For example, a schema with a 2fa property generates the field _2fa , and you set it
with .value_2fa(...) . The field itself keeps its name; only the setter changes. See
Generated Names for how property names become
fields.
A Side-by-Side Comparison
Let’s construct the request both ways. The following examples use the
ShowPetByIdRequest from the previous section, and a pet_id of type
uuid::Uuid , such as one parsed with Uuid::parse_str() .
Without Builders
A struct literal requires us to assemble the path and header values ourselves:
use validator::Validate;
let request = ShowPetByIdRequest {
path: ShowPetByIdRequestPath { pet_id },
header: ShowPetByIdRequestHeader {
if_none_match: None,
x_api_version: "2026-10".to_string(),
},
};
request.validate()?;
The call to validate() checks the constraints after construction. In this example,
x_api_version must be nonempty: the generator adds that check to a required string
whose schema doesn’t set its own length limits. Constructing the struct alone doesn’t
perform the check. The generated client also validates a request before sending it.
With Builders
The builder needs only the two required values:
let request = ShowPetByIdRequest::builder()
.pet_id(pet_id)
.x_api_version("2026-10".to_string())
.build()?;
This call constructs and validates the request. Omitting pet_id() or x_api_version()
makes the call to build() a compilation error. If you supply an empty string to
x_api_version() , the code compiles, but build() returns a validation error at
runtime.
bon tracks which required setters you’ve called in the builder’s type. This is called
the typestate pattern. It checks that required values are present; the constructor’s
validation checks the supported constraints on those values.
Using Builders in Tests
Builders can help when a test only needs to vary a few fields. These tests use the
NewPet from the start of the chapter:
#[test]
fn test_new_pet_with_weight() {
let pet = NewPet::builder()
.name("Biscuit".to_string())
.species(Species::Dog)
.weight_kg(12.5)
.build();
assert_eq!(pet.weight_kg, Some(12.5));
}
#[test]
fn test_new_pet_without_weight() {
let pet = NewPet::builder()
.name("Mittens".to_string())
.species(Species::Cat)
.build();
assert_eq!(pet.weight_kg, None);
}
The first test sets a weight, and the second leaves it unset. Neither test needs to
assign None to the other optional fields. For types with many optional fields, this
lets you keep the setup focused on the values the test uses.
Builders can also reduce changes to calling code when a schema changes. Adding an optional field usually lets you keep existing builder calls, while struct literals that list every field need an additional assignment.
Combining with Other Flags
You can enable builders alongside other generation options. For example, to keep generated items within your crate and accept enum values regardless of ASCII letter case, run:
oas3-gen generate client-mod -i api.json -o src/api/ \
--enable-builders \
--visibility crate \
--enum-mode relaxed
The visibility setting applies to the generated structs and constructor methods. With
--visibility crate , you can use them within your crate. See
Visibility and
Enum Mode for the other options.
Trade-offs
Enabling builders adds a procedural macro dependency to your project. bon expands the
builder API during compilation, so include that work when assessing your project’s build
time. The amount of generated code depends on your schemas and operations.
You can still use struct literals when builders are enabled. Choose a builder when
setting fields by name or constructing nested requests helps your code, and use a
literal when you want to show the whole value in one place. If you don’t need builders,
leave --enable-builders off to avoid the bon dependency.
Code Generation Reference
Choose a reference topic below for the generation flags, schema rules, or HTTP behavior you need. For a worked example, start with Client Generation , Server Generation , or Builder Pattern .
Generation Options
- Generation Modes
- Workspace Crate Output
- Operation Filtering
- Schema Filtering
- API Name Override
- Function Name Overrides
- Visibility
- Header Emission
- Builder Generation
- Documentation Formatting
- Known Limitations
- Flag Summary
Schemas and Types
- Generated Names
- Ordering and Collections
- Field Defaults
- Constant Properties
- Field Validation
- OData Support
- Type Customization
Enums and Unions
- Enum Mode
- Enum Layout
- Numeric-Backed Enums
- Boolean and Mixed-Type Enums
- Discriminated Unions
- Constant Unions
- Non-Exhaustive Enums
- Helper Methods
HTTP Requests and Responses
Generation Options
Use generation options to choose the output layout, select operations, and control the generated API’s names and visibility. The Flag Summary lists the defaults and accepted values.
Commands run from a checkout of this repository. If you installed the tool, replace
cargo run -- with oas3-gen . Use the path to your own document in place of
spec.json or petstore.json . Unless stated otherwise, examples refer to
fixtures/petstore.json ; Rust excerpts may omit imports, attributes, and unrelated
definitions.
- Generation Modes
- Workspace Crate Output
- Operation Filtering
- Schema Filtering
- API Name Override
- Function Name Overrides
- Visibility
- Header Emission
- Builder Generation
- Documentation Formatting
- Known Limitations
- Flag Summary
Generation Modes
The positional mode argument selects the kind of output. Choose types when you need
data types, client or client-mod when you want to call an API, and server-mod when
you want to implement one:
cargo run -- generate <MODE> -i spec.json -o <OUTPUT>
types
The types mode writes type definitions to a single file. Let’s generate the petstore’s
types:
cargo run -- generate types -i petstore.json -o types.rs
Among others, types.rs now contains the PetStatus enum and the Pet struct:
/// Where the pet is in the adoption process
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, oas3_gen_support::Default)]
pub enum PetStatus {
#[serde(rename = "available")]
#[default]
Available,
#[serde(rename = "pending")]
Pending,
#[serde(rename = "adopted")]
Adopted,
}
/// A pet in the store's care.
#[derive(Debug, Clone, PartialEq, Deserialize, oas3_gen_support::Default)]
pub struct Pet {
/// Server-assigned identifier
pub id: uuid::Uuid,
pub name: String,
/// Kinds of pets the store cares for
pub species: Species,
/// Where the pet is in the adoption process
pub status: Option<PetStatus>,
/* ... */
}
The required properties id , name , and species become plain fields, and an
optional property such as status becomes an Option . The derives
follow how the API uses each type. Pet appears only in responses, so it derives
Deserialize but not
Serialize . NewPet , which the API only accepts, derives
Serialize and Validate instead. Server output reverses those
directions.
client
The client mode writes types and a reqwest client to one file. See
Choosing the Output Layout for an
example.
client-mod
The client-mod mode writes types.rs , client.rs , and mod.rs to a directory. See
Client Generation for how to generate and use it.
server-mod
The server-mod mode writes types.rs , server.rs , and mod.rs to a directory. See
Server Generation for how to implement the trait and use the
generated Axum router.
Workspace Crate Output
Use --workspace when you want the output to have its own Cargo.toml and src/
directory. You can build that output as a crate or include it in a Cargo workspace. The
flag is available with client-mod and server-mod ; using it with types or client
produces a generation error.
-w, --workspace
For example, let’s generate a client crate in petstore-api :
cargo run -- generate client-mod -i petstore.json -o petstore-api --workspace
The generated directory has this structure:
petstore-api/
├── Cargo.toml
└── src/
├── lib.rs
├── types.rs
└── client.rs
The src/lib.rs file is the library root, so you can build petstore-api as a crate.
The type and client or server files contain the same code as the corresponding module
output.
Crate Name
The package takes its name from the final component of the output directory, so
-o petstore-api produces name = "petstore-api" and a petstore_api library target.
Names are transliterated to ASCII and converted to kebab-case: -o "output/Pet Store"
yields pet-store , -o café-api yields cafe-api , and -o petstore_v2 yields
petstore-v2 . The library target of petstore-v2 is still petstore_v2 , because
Cargo maps dashes to underscores.
Generation fails when no valid name can be derived, for example when the directory name is empty after sanitization or starts with a digit.
Generated Manifest
The manifest declares the package and the dependencies its generated code uses. The command above writes this manifest for the petstore client, shown here without its banner; the dependency list for your API may differ:
[package]
name = "petstore-api"
version = "0.0.0"
edition = "2024"
rust-version = "1.89"
description = "Rust client generated from the Petstore OpenAPI document"
[dependencies]
anyhow = "1.0"
chrono = { version = "0.4.42", default-features = false, features = ["std", "clock", "serde"] }
http = "1.5"
indexmap = { version = "2.14", features = ["serde"] }
oas3-gen-support = "0.29.0"
regex = "1.13"
reqwest = { version = "0.13", default-features = false, features = ["form", "json", "multipart", "http2", "native-tls", "query", "stream"] }
reqwest_cookie_store = "0.10"
secrecy = "0.10"
serde = { version = "1.0", features = ["derive"] }
serde_html_form = { version = "0.4", default-features = false, features = ["ser", "std"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_with = { version = "3.24", features = ["base64", "chrono"] }
uuid = { version = "1.26", features = ["serde"] }
validator = { version = "0.21", features = ["derive", "indexmap"] }
The description comes from the document’s info.title , Petstore . Adding
--enable-builders adds a bon entry. Generating the petstore server with server-mod
instead leaves out reqwest , reqwest_cookie_store , and serde_html_form , and adds
the Axum crates:
axum = { version = "0.8.5", features = ["multipart"] }
axum-extra = { version = "0.12", features = ["cookie", "query"] }
The generator rewrites Cargo.toml on every run and adds a banner identifying it as
generated output. Put code that needs its own dependencies in a separate crate so that
regenerating the API doesn’t overwrite your changes.
The package version starts at 0.0.0 . Pass --module-version to set a version for the
generated crate:
cargo run -- generate client-mod -i petstore.json -o petstore-api --workspace --module-version 1.0.0
The --module-version flag requires --workspace , because that’s the mode that
creates the manifest.
Dependencies
Each generator release contains the dependency version requirements it writes to the
manifest. It doesn’t read them from your project’s environment. The requirement for
oas3-gen-support uses the generator’s version. Cargo resolves these requirements when
you build the generated crate.
The manifest declares crates referenced by the generated source. For example, a
specification without date or time types doesn’t need a chrono entry, and server
output doesn’t need the client’s reqwest dependency. The following table describes the
dependencies you may see:
| Crate | Declared when the generated code |
|---|---|
anyhow | returns anyhow::Result from client or server methods |
axum | defines a server-mod router and extractors, with the multipart feature |
axum-extra | reads query parameters or cookie API keys on the server, with the cookie and query features |
bon | derives builders (--enable-builders) |
chrono | maps date, date-time, or time formats |
futures | returns or sends a server’s text/event-stream as a BoxStream |
http | emits header constants or HeaderMap conversions |
indexmap | emits IndexMap/IndexSet collection types |
oas3-gen-support | uses runtime derives, diagnostics, multipleOf checks, or reads event streams on the client |
regex | emits pattern validation constants |
reqwest | performs client-mod HTTP calls; the form feature sends application/x-www-form-urlencoded bodies |
reqwest_cookie_store | sends cookie API keys or encodes cookie parameters from the client |
secrecy | stores API keys and bearer tokens |
serde | derives Serialize/Deserialize |
serde_html_form | serializes query parameters on the client, with only its ser and std features |
serde_json | handles freeform serde_json::Value payloads |
serde_with | applies serde_as conversions |
uuid | maps the uuid format |
validator | derives Validate |
serde_json gains the preserve_order feature under the default collection policy so
that freeform JSON objects keep their key order. With
--no-ordered-collections the feature is
omitted. Likewise, validator gains its indexmap feature when the generated code also
uses indexmap . The feature checks the length of an IndexSet or IndexMap field,
such as the maxItems limit on the tags of NewPet .
Using the Crate
The manifest declares a plain [package] with no [workspace] table, so the crate
builds standalone and can also be added to an existing workspace:
[workspace]
members = ["crates/*", "petstore-api"]
To use the generated crate from another crate, add a path dependency to that crate’s
Cargo.toml :
[dependencies]
petstore-api = { path = "../petstore-api" }
Keep the default --visibility public for a crate other code depends on.
crate and file visibility restrict the re-exports in lib.rs , so other crates
can’t access those re-exported items.
Operation Filtering
You may only need a few operations from a large API. Use --only to select those
operations, or --exclude to leave specific operations out. You can use one of these
flags at a time.
--only <id1,id2,...>
--exclude <id1,id2,...>
--only
Pass the normalized snake_case operation IDs, separated by commas. Filtering uses
these IDs before any --fn-name overrides. For example, the petstore’s listPets and
createPet operations use list_pets and create_pet in the filter:
cargo run -- generate client-mod -i petstore.json -o output/ \
--only list_pets,create_pet
The generated client keeps list_pets() and create_pet() and omits the other
operations. With server-mod , the same selection keeps those two trait methods and
their routes. Types needed by the selected methods are still included, as we’ll see in
Schema Dependency Resolution .
--exclude
Use --exclude when you want most of an API but don’t need a particular operation. For
example, to leave out the image upload operation, run:
cargo run -- generate client-mod -i petstore.json -o output/ \
--exclude upload_pet_image
The client keeps the petstore’s other operations, including list_pets() and
create_pet() , and omits upload_pet_image() . In server-mod mode, the flag omits
that operation’s trait method and route.
A callback follows the operation that declares it. The petstore’s adopt_pet operation
declares the statusChanged callback, which server-mod generates as
adopt_pet_status_changed . In server-mod , --only adopt_pet keeps that callback’s
route and trait method, and --exclude adopt_pet removes them.
You can also name a callback by its own normalized snake_case ID.
--only adopt_pet_status_changed keeps only the callback, and
--exclude adopt_pet_status_changed removes only the callback while adopt_pet stays.
Schema Dependency Resolution
Filtering an operation also filters the types needed to use it. The generator follows dependencies through the schemas so that a selected operation has the types it needs:
- It collects schemas used by the selected operations’ parameters, request bodies, and responses.
- It follows references from those schemas to other schemas.
- It generates the resulting set of types.
For example, the petstore’s listPets returns Pets , an array of Pet values, and an
Error for its default response. Selecting only list_pets also generates the
schemas Pet refers to: Species , PetStatus , the Health union with its
Allergies and Diet branches, PetVaccinations , and VaccineRecord . The operation
doesn’t refer to any of them directly.
Schema Filtering
By default, the generator includes schemas used by the selected operations and the
schemas they depend on. Pass --all-schemas when you also need types that no operation
references.
--all-schemas
Default (Reachability Filtering)
This example uses a different document. Suppose a specification defines Pet ,
Category , Store , and Inventory , but its operations only use Pet and
Category . Generate types with the default options:
cargo run -- generate types -i spec.json -o types.rs
The output contains Pet and Category . The generator skips Store and Inventory
because no selected operation needs them. With --verbose , it reports how many of
these orphaned schemas it skipped.
With --all-schemas
To include those unused schemas too, add the flag:
cargo run -- generate types -i spec.json -o types.rs --all-schemas
The output now contains all four types: Pet , Category , Store , and Inventory .
Combining with Operation Filtering
You can’t combine --all-schemas with --only or --exclude in one command. If you
need both a complete set of types and a filtered client, generate two outputs:
cargo run -- generate types -i spec.json -o types.rs --all-schemas
cargo run -- generate client -i spec.json -o client.rs --only list_pets
The first file contains all schemas. The second contains the filtered client and its own required types; these commands don’t make the client import types from the first file.
API Name Override
Use --api-name to name the client struct in client or client-mod mode, or the
server trait in server-mod mode. The generator converts the supplied name to
PascalCase .
--api-name <NAME>
By default, the client name comes from the specification’s info.title . For example,
the petstore’s title, "Petstore" , becomes PetstoreClient ; an empty title produces
ApiClient . The default server trait name is ApiServer . To choose PetStoreClient
for the client, run:
cargo run -- generate client-mod -i petstore.json -o output/ --api-name PetStoreClient
The client struct and its implementation now use PetStoreClient . In server mode,
choose a trait name with the same flag:
cargo run -- generate server-mod -i petstore.json -o output/ --api-name PetStoreApi
The server trait is named PetStoreApi , and the generated router uses that name in its
trait bound. Changing the API name doesn’t rename the operations; use
--fn-name for those.
Function Name Overrides
Use --fn-name to choose the generated client or server method name for an operation.
The key is its operationId from the specification; the generator also accepts the
normalized operation ID shown by oas3-gen list operations .
--fn-name <ID=NAME>
The generator converts your method name to snake_case and uses it to derive the
request type’s name. For example, renaming listPets to fetch_all_pets also produces
FetchAllPetsRequest . Repeat the flag to rename more than one operation:
cargo run -- generate client-mod -i petstore.json -o output/ \
--fn-name listPets=fetch_all_pets \
--fn-name showPetById=get_pet
The override changes both names together:
| Operation ID | Method Name | Request Type |
|---|---|---|
listPets | fetch_all_pets | FetchAllPetsRequest |
showPetById | get_pet | GetPetRequest |
Use the new names when calling the client or implementing the server trait.
Visibility
Use --visibility to choose where generated items can be accessed. The default,
public , makes them available to other crates. Choose crate when the generated API
is an internal part of your application.
-C, --visibility <LEVEL>
| Value | Modifier | Use Case |
|---|---|---|
public (default) | pub | Library distribution |
crate | pub(crate) | Internal crate types |
file | pub(super) | Items used only inside the generated module |
Let’s compare the three values on two petstore types: the Pet struct and the
ChatModelChoice union, which has helper methods .
Example: --visibility public
With public visibility, the types, fields, and methods use pub :
pub struct Pet {
pub id: uuid::Uuid,
pub name: String,
/* ... */
}
pub enum ChatModelChoice {
ChatModel(ChatModel),
String(String),
}
impl ChatModelChoice {
pub fn chat10() -> Self {
Self::ChatModel(ChatModel::Chat10)
}
/* ... */
}
Example: --visibility crate
With crate visibility, those items use pub(crate) :
pub(crate) struct Pet {
pub(crate) id: uuid::Uuid,
pub(crate) name: String,
/* ... */
}
pub(crate) enum ChatModelChoice {
ChatModel(ChatModel),
String(String),
}
impl ChatModelChoice {
pub(crate) fn chat10() -> Self {
Self::ChatModel(ChatModel::Chat10)
}
/* ... */
}
Example: --visibility file
With file visibility, those items use pub(super) :
pub(super) struct Pet {
pub(super) id: uuid::Uuid,
pub(super) name: String,
/* ... */
}
pub(super) enum ChatModelChoice {
ChatModel(ChatModel),
String(String),
}
impl ChatModelChoice {
pub(super) fn chat10() -> Self {
Self::ChatModel(ChatModel::Chat10)
}
/* ... */
}
In module output, pub(super) lets the generated files use each other’s items:
client.rs or server.rs can reach the types in types.rs . The module’s mod.rs or
lib.rs doesn’t re-export anything, so the items stay inside the generated module.
Header Emission
Header name constants use
http::HeaderName, so you can refer to headers without repeating their string names. By default, the generator creates constants for request parameters and response headers
used by the selected operations. Use --all-headers to include header parameters from
components/parameters even when no operation references them.
Headers that the http crate already names are the exception. The
http::header module declares constants for standard
headers such as Content-Type , Location , and Authorization . The generator
doesn’t declare its own constant for any of them; generated code refers to http ’s
constant, such as http::header::LOCATION , instead.
OpenAPI says to ignore header parameters named Accept , Content-Type , or
Authorization , so the generator leaves them out of request types. The specification
describes those headers elsewhere: media types cover Accept and Content-Type , and
security schemes cover Authorization . For example, the petstore’s listAuditEvents
declares a required Authorization header parameter, yet its request struct has no
header field. To send credentials in Authorization , declare a
security scheme , as that operation also does.
--all-headers
Default (Operation-Referenced Headers Only)
The petstore’s showPetById operation uses an x-api-version header. The document also
declares an x-debug-trace header parameter in components/parameters that no
operation uses. Start with the default options:
cargo run -- generate types -i petstore.json -o types.rs
Among the other header constants, the output contains one for the header the operation uses:
pub const X_API_VERSION: http::HeaderName = http::HeaderName::from_static(
"x-api-version",
);
x-debug-trace isn’t emitted because no operation uses it.
With --all-headers
Now include the unused component header:
cargo run -- generate types -i petstore.json -o types.rs --all-headers
The output gains a constant for it:
pub const X_DEBUG_TRACE: http::HeaderName = http::HeaderName::from_static(
"x-debug-trace",
);
Builder Generation
Use --enable-builders when you want to construct generated values through setter
methods. The generator adds bon::Builder derives to schema
structs and #[builder] constructors to request structs.
--enable-builders
Builders are disabled by default. The Builder Pattern chapter walks through using them and explains how request builders run validation.
Default (Builders Disabled)
Without the flag, generated structs have no bon attributes. This excerpt from the
petstore client shows a schema type and a request type:
/// A pet in the store's care.
#[derive(Debug, Clone, PartialEq, Deserialize, oas3_gen_support::Default)]
pub struct Pet {
/// Server-assigned identifier
pub id: uuid::Uuid,
pub name: String,
/* ... */
}
/// Get a pet
#[derive(Debug, Clone, validator::Validate, oas3_gen_support::Default)]
pub struct ShowPetByIdRequest {
pub path: ShowPetByIdRequestPath,
#[validate(nested)]
pub header: ShowPetByIdRequestHeader,
}
With --enable-builders
With the flag, the schema gets a derive and the request gets a constructor annotated for
bon :
/// A pet in the store's care.
#[derive(Debug, Clone, PartialEq, Deserialize, oas3_gen_support::Default, bon::Builder)]
pub struct Pet {
/// Server-assigned identifier
pub id: uuid::Uuid,
pub name: String,
/* ... */
}
/// Get a pet
#[derive(Debug, Clone, validator::Validate, oas3_gen_support::Default)]
pub struct ShowPetByIdRequest {
pub path: ShowPetByIdRequestPath,
#[validate(nested)]
pub header: ShowPetByIdRequestHeader,
}
#[bon::bon]
impl ShowPetByIdRequest {
/// Create a new request with the given parameters.
#[builder]
pub fn new(
pet_id: uuid::Uuid,
if_none_match: Option<String>,
x_api_version: String,
) -> anyhow::Result<Self> {
/* ... */
}
}
The constructor accepts each parameter separately, so the builder has a setter for
pet_id , if_none_match , and x_api_version . It assembles the nested path and
header structs and validates the request before returning it.
Documentation Formatting
Descriptions and summaries in the OpenAPI document become documentation comments in the
generated code. Use --doc-format to format longer comments with the external
mdformat tool. The generator sends text
longer than 100 bytes to mdformat with a line-wrap setting of 100 characters.
--doc-format
Install mdformat and make sure it’s available on your PATH before using the flag:
pip install mdformat
Default (Formatting Disabled)
Without the flag, the generator preserves documentation text while converting escaped
newlines to line breaks. A long description can therefore remain on one line, as the
petstore’s CollarTelemetry description does:
/// A batch of smart-collar readings, written with JSON Schema 2020-12 keywords. Keywords without a Rust equivalent are accepted and ignored.
pub struct CollarTelemetry {
/* ... */
}
With --doc-format
cargo run -- generate types -i petstore.json -o types.rs --doc-format
The same description is wrapped into multiple lines:
/// A batch of smart-collar readings, written with JSON Schema 2020-12 keywords. Keywords without a Rust
/// equivalent are accepted and ignored.
pub struct CollarTelemetry {
/* ... */
}
Known Limitations
The following constraints and serialization options aren’t fully represented in the generated code. The petstore examples show where each limitation applies:
- With
--no-ordered-collections, an array withuniqueItems: true, such as thetagsofPet, is aVecand accepts repeated items. See Ordering and Collections. - An optional field that also allows
nullis anOption<T>, so JSONnulland a missing property both decode asNone, andNoneis left out when serializing. A client therefore can’t sendnullto clear a field with a JSON merge patch, such as thenicknamein thePetPatchbody ofupdatePet. Likewise, a field without a type, such asanyValueinCollarTelemetry, decodesnullasNone. - The generator ignores
allowReservedon query parameters. The client percent-encodes reserved characters, such as the/in aqvalue forsearchPets. - A query parameter’s value must be a scalar or an array of scalars. A JSON
object or an array of arrays in an untyped parameter, such as
filteronsearchPets, makes the client method return an error before it sends the request. - An
anyOfwhose branches each require a different property isn’t enforced. ThePetCarrierschema requires eitherwheelsorstraps, but both become optional fields, so a carrier with neither decodes successfully. - A multipart part name can’t contain
",\, a carriage return, a line feed, or another control character. The generator warns about quotes and line breaks, as described in Generation Warnings. - A
multipleOfthat the generator can’t check exactly only appears in the field’s documentation. See Multiples.
Flag Summary
This table collects the options used across the generation chapters. Defaults apply when
you omit the corresponding option. Run oas3-gen generate --help to see the
command-line help, including input, output, and terminal options.
| Flag | Default | Description |
|---|---|---|
mode | types | Generation mode: types, client, client-mod, server-mod |
-w, --workspace | false | Emit a workspace-compatible Cargo.toml with sources in src/; requires client-mod or server-mod |
--module-version | 0.0.0 | Version written to the generated Cargo.toml [package] table; requires --workspace |
-C, --visibility | public | Item visibility: public, crate, file |
--enum-mode | merge | Enum duplicate handling: merge, preserve, relaxed |
--enum-layout | spec | Variant ordering: spec, sorted |
--no-helpers | false | Disable enum constructor helpers |
--odata-support | false | Make @odata.* fields optional |
-c, --customize | (none) | Custom serde_with adapter; repeatable |
--fn-name | (none) | Custom function name per operation (ID=NAME); repeatable |
--api-name | (none) | Name for the generated client struct or server trait |
--all-headers | false | Emit header constants for all component-level headers |
--enable-builders | false | Enable bon builder derives and methods |
--no-ordered-collections | false | Emit HashMap/Vec instead of indexmap collection types |
--doc-format | false | Format documentation comments with mdformat |
--only | (none) | Include only specified operations |
--exclude | (none) | Exclude specified operations |
--all-schemas | false | Generate all schemas regardless of usage |
Schemas and Types
Schemas describe the values an API accepts and returns. This chapter explains how the generator names fields, chooses collection types, applies defaults, and validates constraints. For enum variants and unions, see Enums and Unions .
The examples use fixtures/petstore.json unless stated otherwise. Rust excerpts may
omit imports, attributes, and unrelated fields. Commands run from the repository
checkout; replace cargo run -- with oas3-gen when using an installed executable, and
substitute your document’s path for spec.json .
- Generated Names
- Ordering and Collections
- Field Defaults
- Constant Properties
- Field Validation
- OData Support
- Type Customization
Generated Names
Schema and property names become Rust identifiers. When a name isn’t a valid identifier, or would collide with something Rust or the generated code already uses, the generator changes it as shown in the following table:
| Name in the specification | Generated name | Reason |
|---|---|---|
A property named after a keyword, such as type or match | r#type | Keywords need the raw identifier prefix |
A property named crate, self, or super | crate_, self_, super_ | These keywords can’t be raw identifiers |
A property that starts with a digit, such as 2fa | _2fa | Identifiers can’t start with a digit |
A schema named after a prelude item, such as Option or Vec | OptionType | The name would shadow the prelude |
A schema named Self | SelfType | Self can’t be a raw identifier |
A schema named S | SType | Generated server handlers use S for your service type |
A schema that starts with a digit, such as 123Response | T123Response | Identifiers can’t start with a digit |
A renamed property keeps its original name on the wire through #[serde(rename)] , so
the JSON stays the same. Enum variants follow the same rules as schema names. For
example, the enum value self becomes the variant SelfType .
Ordering and Collections
By default, the generator preserves declaration order from the OpenAPI document for
schemas, fields, variants, operations, and header constants. You can change enum
ordering separately with --enum-layout .
The generator also uses collection types that preserve insertion order at runtime. Maps
described by additionalProperties use
indexmap::IndexMap<String, T> , and arrays with
uniqueItems: true use indexmap::IndexSet<T> . For example,
the tags of a petstore Pet are uniqueItems , so the field is
Option<indexmap::IndexSet<String>> , and its vaccinations map is
Option<indexmap::IndexMap<String, Vec<VaccineRecord>>> . If you include these types in
an existing crate, add indexmap with its serde feature to your Cargo.toml . A
generated workspace manifest includes that dependency when needed.
A uniqueItems field also carries a serde_with::SetPreventDuplicates adapter, so a
JSON array with a repeated item fails to deserialize:
#[serde_as(as = "Option<serde_with::SetPreventDuplicates<_>>")]
pub tags: Option<indexmap::IndexSet<String>>,
Use this flag to choose standard library collections instead:
--no-ordered-collections
Set --no-ordered-collections to opt out of the indexmap runtime types. Map schemas
then resolve to std::collections::HashMap<String, T> , and
uniqueItems arrays resolve to Vec<T> , the same type as a normal
array, and accept repeated items. Use this flag when your code cannot depend on
indexmap , or when you do not need JSON key and element order to survive a
deserialize-then-serialize round trip.
This flag does not change the declaration order of items in the generated source, such as struct fields, enum variants, and operation methods.
Field Defaults
The generator uses a property’s default for construction and for missing JSON fields.
A #[default(...)] attribute sets the field’s value in the type’s
Default implementation, and a struct-level #[serde(default)]
tells Serde to take every missing field from that implementation during deserialization.
Let’s look at the petstore’s Order schema, which requires petId and gives quantity
and complete defaults. Here it is without its shipDate and status properties:
"Order": {
"type": "object",
"required": ["petId"],
"properties": {
"id": { "type": "integer", "format": "int64", "readOnly": true },
"petId": { "type": "string", "format": "uuid" },
"quantity": { "type": "integer", "format": "int32", "minimum": 1, "maximum": 10, "default": 1 },
"complete": { "type": "boolean", "default": false }
}
}
The generated struct carries the defaults on its fields and a single Serde attribute on the struct:
#[serde(default)]
pub struct Order {
pub id: Option<i64>,
#[serde(rename = "petId")]
pub pet_id: uuid::Uuid,
#[validate(range(min = 1i32, max = 10i32))]
#[default(Some(1i32))]
pub quantity: Option<i32>,
/* ... */
#[default(Some(false))]
pub complete: Option<bool>,
}
Deserializing {"petId": "..."} gives quantity the value Some(1) and complete the
value Some(false) , the same values Order::default() holds.
The attribute applies to the whole struct, so it fills required fields too. JSON without
petId deserializes with the nil UUID from Order::default() instead of failing. A
struct only gets #[serde(default)] when at least one of its fields has a default or
a const , so required fields in other structs are still rejected when they’re missing.
Client output also reads null in these structs’ required fields as a default. See
Missing and Null Fields .
Union Defaults
When an untagged union can represent the schema’s default , the generator writes an
impl Default that constructs it. It checks variants in the same order as
deserialization. For example, ContentRequest.image_size defaults to auto_2K ,
selecting Self::Preset(ImageSizePreset::Auto2k) . A string default can produce
Self::String("cheesecake".to_string()) .
Without a usable schema default, a union that derives Default selects the first
variant whose construction won’t recurse back into the union. Eligible variants include
constants, unit variants, arrays, nullable types, and payloads that don’t refer back to
the union.
The generator chooses this fallback in specification order, or sorted order with
--enum-layout sorted . Moving array or string variants for deserialization doesn’t
change it. Nullable unions record the fallback too, in case a containing struct’s
derived Default needs the union to implement Default .
A nullable union includes a null branch. Without a usable schema default, it starts
without a Default implementation. Properties with that union type use
Option<T> , including properties listed as required. If another
generated type needs the union to implement Default , the generator can derive it
using the recorded fallback variant.
Defaults Beside References
A property can also set a default next to its $ref , as OpenAPI 3.1 allows. When the
reference names an enum, a const , or a union, the field’s default uses that value
instead of the referenced schema’s own default . The petstore’s ChatPreference
schema does this:
"fallback": { "$ref": "#/components/schemas/ChatModel", "default": "chat-1.1" }
This property becomes
#[default(Some(ChatModel::Chat11))] pub fallback: Option<ChatModel> , along with the
deserialization default described in Field Defaults . A union’s
default selects its matching variant, so "default": "slow" on a oneOf of const
values becomes #[default(Some(Mode::Slow))] . The generator ignores a default next
to any other reference, because it can’t build that value for the referenced type. A
default that matches no variant of an enum or union, such as an object default on a
referenced or inline oneOf , gets no #[default(...)] attribute, so the field keeps
its plain Default instead of a placeholder that picks the first variant.
Constant Properties
A property with const , or with a single-value enum , uses that value as its
default. Deserialization supplies it when the property is absent. The optional object
property of the petstore’s ChatPreference becomes:
#[default(Some("chat.preference".to_string()))]
pub object: Option<String>,
The client keeps a different value it receives rather than replacing it, as Constant Properties describes.
Field Validation
Schemas often limit the values they accept: a string’s length, a number’s range, or an
array’s size. The generator turns the limits it supports into
validator attributes on the generated fields, and validate()
checks a value against them. The following table shows which attribute each schema
keyword produces:
| Schema keyword | Validation attribute |
|---|---|
minLength, maxLength on a string | length(min = ..., max = ...) |
minItems, maxItems on an array | length(min = ..., max = ...) |
minimum, maximum, exclusiveMinimum, exclusiveMaximum | range(...) |
pattern | regex(path = ...), naming a generated static that holds the compiled expression |
format: email | email |
format: uri or format: url | url |
multipleOf | custom(function = ...), calling a check in oas3-gen-support |
| A property whose own type has validation | nested |
A required string without minLength also gets length(min = 1) , so an empty string
fails validation. Strings with a date , date-time , time , uuid , byte , or
binary format don’t get length or pattern checks.
The petstore’s CollarTelemetry schema uses several of these keywords. Here’s an
excerpt of the generated struct:
pub struct CollarTelemetry {
#[serde(rename = "collarId")]
#[validate(length(min = 1u64), regex(path = "REGEX_COLLAR_TELEMETRY_COLLAR_ID"))]
pub collar_id: String,
/* ... */
/// Latitude and longitude
#[validate(length(min = 2u64, max = 2u64))]
pub location: Vec<f64>,
/* ... */
/// Validation: Must be a multiple of 5
#[serde(rename = "batteryPercent")]
#[validate(
range(min = 0i32, max = 100i32),
custom(function = oas3_gen_support::multiple_of::<5>)
)]
pub battery_percent: Option<i32>,
/* ... */
#[serde(rename = "ownerEmail")]
#[validate(email)]
pub owner_email: Option<String>,
/* ... */
}
The collarId property is required and has a pattern , so collar_id gets both a
minimum length and a regular expression check. batteryPercent declares minimum: 0 ,
maximum: 100 , and multipleOf: 5 . The same rules apply to a request body that isn’t
an object. For example, the text/plain body of setPetNotes declares
maxLength: 2000 , so the body field of SetPetNotesRequest carries
length(max = 2_000u64) .
Multiples
A multipleOf check calls a function from oas3-gen-support . An integer field calls
multiple_of::<N> , which accepts a value that divides evenly by N . With
multipleOf: 5 , batteryPercent accepts 85 and rejects 87 . A number field calls
multiple_of_ratio::<NUMERATOR, DENOMINATOR> , with the multiple written as a fraction:
multipleOf: 0.25 becomes multiple_of_ratio::<1, 4> . That check applies only to
f32 and f64 fields. It accepts a value when the nearest multiple of the fraction,
rounded to the field’s type, is the value itself. It also accepts a value whose quotient
by the fraction is within 1e-9 of a whole number, which absorbs rounding that
accumulates in arithmetic. So 0.3 counts as a multiple of 0.1 in either type, as do
0.1 + 0.2 and a sum of one hundred 0.1 values, while 0.35 doesn’t. Where the
field’s type is coarser than the multiple, every value rounds to a multiple, so an f32
field with multipleOf: 0.1 accepts 1_100_000.125 . Either check reports a failure as
a validation error with the code multiple_of .
Every multipleOf adds a line such as Validation: Must be a multiple of 5 to the
field’s documentation. These multiples get only that line:
- A fractional multiple on an integer field, such as
multipleOf: 0.5. - A multiple that can’t be written as a fraction whose numerator and
denominator fit in a
u32, such as1e-20.
When Validation Runs
Generated types derive
Validate only when the API accepts them, such as request structs and request bodies. A schema used only in responses, such as Pet, keeps no validation attributes. Request builders validate when you call build(), as the Builder Pattern
chapter explains. Making a Request and
Implementing the Service Trait
cover when the client and server check a request.
OData Support
An OData API may omit metadata fields that its schema lists as required. If you
encounter this with fields such as @odata.type , use --odata-support to make
eligible fields optional in the generated types.
--odata-support
This rule applies to names starting with @odata. when the parent schema has no
discriminator and isn’t an intersection using allOf .
Input Schema
Let’s use a schema that requires an ID and two metadata fields:
{
"type": "object",
"properties": {
"id": { "type": "string" },
"@odata.type": { "type": "string" },
"@odata.id": { "type": "string" }
},
"required": ["id", "@odata.type", "@odata.id"]
}
Default (OData Support Disabled)
Without the flag, all three fields are required strings:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub id: String,
#[serde(rename = "@odata.type")]
pub odata_type: String,
#[serde(rename = "@odata.id")]
pub odata_id: String,
}
Deserialization fails if @odata.type or @odata.id are missing from the response.
With --odata-support
With the flag, the metadata fields become optional:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entity {
pub id: String,
#[serde(rename = "@odata.type")]
pub odata_type: Option<String>,
#[serde(rename = "@odata.id")]
pub odata_id: Option<String>,
}
Now deserialization succeeds when either metadata field is absent, and the corresponding
Rust field is None . The id field is still required because its name doesn’t start
with @odata. .
Type Customization
Use --customize to choose a serde_with adapter for a generated field. An adapter
controls how Serde reads or writes a value. The generated field keeps its Rust type, and
the adapter appears in its #[serde_as] attribute.
-c, --customize <TYPE=PATH>
The following keys select commonly customized formats:
| Key | OpenAPI Format | Rust Field Type |
|---|---|---|
date_time | date-time | chrono::DateTime<chrono::Utc> |
date | date | chrono::NaiveDate |
time | time | chrono::NaiveTime |
duration | duration | std::time::Duration |
uuid | uuid | uuid::Uuid |
The date-time type uses chrono::Utc as its time zone. For these
examples, suppose your crate defines adapters named DateTimeAdapter and DateAdapter
. Each adapter must implement serde_with::SerializeAs or
serde_with::DeserializeAs as needed by the generated field.
To use both adapters, repeat the flag:
cargo run -- generate types -i petstore.json -o types.rs \
-c date_time=crate::DateTimeAdapter \
-c date=crate::DateAdapter
Default (No Customization)
The petstore’s CollarTelemetry schema has a required recordedAt date-time and an
optional recordedOn date. Without an override, the fields use their normal Serde
implementations:
pub struct CollarTelemetry {
/* ... */
#[serde(rename = "recordedAt")]
pub recorded_at: chrono::DateTime<chrono::Utc>,
#[serde(rename = "recordedOn")]
pub recorded_on: Option<chrono::NaiveDate>,
/* ... */
}
With a Date-Time Adapter
If we pass only -c date_time=crate::DateTimeAdapter , the generator adds an adapter to
recorded_at :
pub struct CollarTelemetry {
/* ... */
#[serde_as(as = "crate::DateTimeAdapter")]
#[serde(rename = "recordedAt")]
pub recorded_at: chrono::DateTime<chrono::Utc>,
#[serde(rename = "recordedOn")]
pub recorded_on: Option<chrono::NaiveDate>,
/* ... */
}
Notice that recorded_at is still a
chrono::DateTime<chrono::Utc> . The DateTimeAdapter
controls its conversion during serialization and deserialization. The recorded_on
field is unchanged because we haven’t supplied a date override in this example. The
generator also adds #[serde_with::serde_as] to any struct that uses an adapter.
Handling Optional and Array Fields
The generator wraps the adapter in Option and Vec
to match optional and array fields. For example, the same date-time override gives the
optional homeVisit property of AdoptionRequest this attribute:
pub struct AdoptionRequest {
/* ... */
#[serde_as(as = "Option<crate::DateTimeAdapter>")]
#[serde(rename = "homeVisit")]
pub home_visit: Option<chrono::DateTime<chrono::Utc>>,
}
An array of date-times would get Vec<crate::DateTimeAdapter> , and an optional array
Option<Vec<crate::DateTimeAdapter>> . Each attribute follows the shape of its field.
You supply the adapter for one date-time value; the generator adds the surrounding
container adapters.
Enums and Unions
OpenAPI can restrict a value to a list or allow it to match several schemas. The generator represents these choices with Rust enums. This chapter covers variant names, ordering, serialization, discriminators, and values outside a known list.
Most examples come from fixtures/petstore.json ; smaller standalone schemas are shown
where useful. Code excerpts omit imports and some definitions. For field defaults and
validation, see Schemas and Types .
- Enum Mode
- Enum Layout
- Numeric-Backed Enums
- Boolean and Mixed-Type Enums
- Discriminated Unions
- Constant Unions
- Non-Exhaustive Enums
- Helper Methods
Enum Mode
Different schema values can become the same Rust variant name. For example, "ACTIVE"
and "active" both become Active . Use --enum-mode to choose whether to merge those
values, keep them separate, or accept differences in ASCII letter case during
deserialization.
--enum-mode <MODE>
| Value | Behavior |
|---|---|
merge (default) | Merge duplicates; first occurrence is canonical, others become aliases |
preserve | Keep all variants; append numeric suffix to collisions |
relaxed | Merge duplicates; enable case-insensitive deserialization |
Input Schema
Let’s use the same schema to compare the three modes:
{
"type": "string",
"enum": ["ACTIVE", "active", "Active", "PENDING"]
}
Example: --enum-mode merge
The default mode merges values that produce the same Rust identifier. It uses the first value as the serialization name and adds the others as Serde aliases for deserialization:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
#[serde(rename = "ACTIVE", alias = "active", alias = "Active")]
Active,
#[serde(rename = "PENDING")]
Pending,
}
All three spellings of "ACTIVE" in the schema deserialize to Status::Active . The
lowercase string "pending" produces an error because it isn’t declared as a value or
alias. Merging preserves the declared spellings; it doesn’t make every spelling
case-insensitive.
Example: --enum-mode preserve
Use preserve when your application needs to distinguish the declared spellings. Each
value gets its own variant, and colliding names get numeric suffixes:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
#[serde(rename = "ACTIVE")]
Active,
#[serde(rename = "active")]
Active1,
#[serde(rename = "Active")]
Active2,
#[serde(rename = "PENDING")]
Pending,
}
Now "ACTIVE" , "active" , and "Active" deserialize to Status::Active ,
Status::Active1 , and Status::Active2 , respectively. Your code can match those
variants separately.
Example: --enum-mode relaxed
Use relaxed when you want to accept values regardless of ASCII letter case. The
generator merges the variants and writes a Deserialize
implementation that converts input to ASCII lowercase before matching:
#[derive(Debug, Clone, Serialize)]
pub enum Status {
#[serde(rename = "ACTIVE", alias = "active", alias = "Active")]
Active,
#[serde(rename = "PENDING")]
Pending,
}
impl<'de> serde::Deserialize<'de> for Status {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
match s.to_ascii_lowercase().as_str() {
"active" => Ok(Self::Active),
"pending" => Ok(Self::Pending),
_ => Err(serde::de::Error::unknown_variant(&s, &["ACTIVE", "PENDING"])),
}
}
}
This version accepts both "PENDING" and "pending" as Status::Pending , as well as
the different spellings of "ACTIVE" . Serialization still uses the declared name shown
in the #[serde(rename)] attribute.
Enum Layout
Once you’ve chosen how to handle enum values, you can choose their order in the
generated source. The --enum-layout flag preserves the specification’s order by
default or sorts variants by their Rust names.
--enum-layout <LAYOUT>
| Value | Behavior |
|---|---|
spec (default) | Preserve variant order from the OpenAPI document |
sorted | Sort variants alphabetically by Rust variant name |
sorted applies to value enums (string enum ), oneOf /anyOf union variants, and
discriminated enum variants. HTTP response (status-code) enums are unaffected and
continue to use status-code order.
Use sorted when you want the declarations to stay in the same order after the
specification reorders enum values. This can make changes to the generated source easier
to review.
Ordering can also affect the default value. For value enums that derive
Default , the generator marks the variant matching the schema’s
default . If no variant matches, it uses the first declared variant. Switching to
sorted can therefore change the fallback default.
Untagged unions, described with oneOf or anyOf , always place a variant that wraps a
plain String last, after any sorting. Deserialization tries an untagged union’s
variants in order, and a String accepts every JSON string. The petstore’s
ChatModelChoice lists { "type": "string" } before a $ref to the ChatModel enum,
but its generated ChatModel(ChatModel) variant comes first, so "chat-1.0" decodes as
ChatModel(ChatModel::Chat10) .
Variants that wrap an array come first. The petstore’s ArrayOrSingle lists a
TextBlock before an array of them, and the generated enum tries the array first:
#[serde(untagged)]
pub enum ArrayOrSingle {
Array(Vec<TextBlock>),
#[default]
TextBlock(TextBlock),
}
The array’s move doesn’t change the default: TextBlock keeps #[default] .
For default selection in unions and fields that refer to them, see Union Defaults .
Input Schema
The petstore’s Species schema lists its values in an order that isn’t alphabetical,
and it has no default . Its x-enum-descriptions extension documents each value:
"Species": {
"type": "string",
"description": "Kinds of pets the store cares for",
"enum": ["dog", "cat", "bird", "fish", "reptile", "small_mammal"],
"x-enum-descriptions": [
"a dog",
"a cat",
"a bird",
"a fish",
"a reptile",
"a rabbit, guinea pig, or other small mammal"
]
}
Example: --enum-layout spec
The default layout keeps Dog first, followed by the remaining values in schema order.
Because Dog is the first declared variant, it’s also the default:
/// Kinds of pets the store cares for
pub enum Species {
/// a dog
#[serde(rename = "dog")]
#[default]
Dog,
/// a cat
#[serde(rename = "cat")]
Cat,
/// a bird
#[serde(rename = "bird")]
Bird,
/* ... */
/// a rabbit, guinea pig, or other small mammal
#[serde(rename = "small_mammal")]
SmallMammal,
}
Example: --enum-layout sorted
The sorted layout puts Bird first. The serialization names and descriptions remain
attached to the same variants, but the fallback default moves to Bird :
/// Kinds of pets the store cares for
pub enum Species {
/// a bird
#[serde(rename = "bird")]
#[default]
Bird,
/// a cat
#[serde(rename = "cat")]
Cat,
/// a dog
#[serde(rename = "dog")]
Dog,
/* ... */
/// a rabbit, guinea pig, or other small mammal
#[serde(rename = "small_mammal")]
SmallMammal,
}
Numeric-Backed Enums
An enum can represent numbers as well as strings. When a schema combines an enum array
with type: integer or type: number , the generated type reads and writes JSON
numbers. The generator does the same when a schema lists only numbers and declares no
type , choosing i64 for whole numbers and f64 when any value has a fractional
part. The generator supplies Serialize and
Deserialize implementations so that a value such as
8000 stays a number during serialization. The petstore’s Priority schema,
{ "enum": [1, 2, 3] } , is an example without a type .
Consider the petstore’s SampleRate schema, which lists the sample rates the assistant
accepts:
"SampleRate": {
"description": "Audio sample rate in Hz (integer-backed enum)",
"type": "integer",
"enum": [8000, 16000, 24000, 44100, 48000],
"default": 24000
}
The generator emits one variant per value, a serializer that turns each variant into its
number, and a deserializer that maps numbers back to variants. The schema’s default
selects Value24000 for Default . Serialization uses
serde::Serializer , and deserialization uses
serde::Deserializer with
serde::de::Error for invalid values:
/// Audio sample rate in Hz (integer-backed enum)
#[derive(Debug, Clone, PartialEq, Eq, Hash, oas3_gen_support::Default)]
pub enum SampleRate {
Value8000,
Value16000,
#[default]
Value24000,
Value44100,
Value48000,
}
impl serde::Serialize for SampleRate {
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let value: i64 = match self {
Self::Value8000 => 8000i64,
/* ... */
Self::Value48000 => 48000i64,
};
serializer.serialize_i64(value)
}
}
impl<'de> serde::Deserialize<'de> for SampleRate {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = i64::deserialize(deserializer)?;
match value {
8000i64 => Ok(Self::Value8000),
/* ... */
_ => Err(serde::de::Error::custom(/* ... */)),
}
}
}
SampleRate::Value8000 now serializes to 8000 , and 8000 deserializes back to
SampleRate::Value8000 . A number outside the listed values produces an error that
names the accepted values.
The schema’s type and format determine which Rust number type the generated
implementation uses. Signed integers use i64 , unsigned integers use
u64 , and floating-point values use f64 . In each
case, the serialized value is a JSON number.
Floating-Point Values
For a number enum, the generated deserializer reads an f64 and
compares its bit pattern with each allowed value. Here’s an excerpt for the petstore’s
PlaybackRate , which accepts 0.5 , 1.0 , 1.5 , and 2.0 :
impl<'de> serde::Deserialize<'de> for PlaybackRate {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = f64::deserialize(deserializer)?;
match value.to_bits() {
bits if bits == (0.5f64).to_bits() => Ok(Self::Value0_5),
/* ... */
_ => Err(serde::de::Error::custom(/* ... */)),
}
}
}
The Rust variants don’t store floating-point values: each variant identifies one allowed
number. These enums can therefore derive Eq and Hash ,
and you can use them as map keys or set members.
Boolean and Mixed-Type Enums
An enum array can also list booleans, or mix strings, numbers, and booleans. Each
listed value keeps its JSON type on the wire, so a true in the schema is written and
read as the JSON boolean true , never as the string "true" .
The petstore’s Toggle schema, { "type": "boolean", "enum": [true, false] } ,
produces True and False variants with a serializer that writes a JSON boolean and a
deserializer that reads one:
impl<'de> serde::Deserialize<'de> for Toggle {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = bool::deserialize(deserializer)?;
if value { Ok(Self::True) } else { Ok(Self::False) }
}
}
When the listed values mix JSON types, as in the petstore’s ResponseQuality schema,
"enum": ["auto", 1, 0.5, true, 1e-6, 1e21, 18446744073709552000] , each variant writes
its own type, and the deserializer reads a
serde_json::Value and matches it against every listed
value:
impl serde::Serialize for ResponseQuality {
fn serialize<S>(&self, serializer: S) -> core::result::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Auto => serializer.serialize_str("auto"),
Self::Value1 => serializer.serialize_i64(1i64),
Self::Value0_5 => serializer.serialize_f64(0.5f64),
Self::True => serializer.serialize_bool(true),
}
}
}
ResponseQuality::Value1 reads the JSON number 1 . Query parameters and path segments
contain text. Numeric and boolean variants therefore also accept strings that parse to
their values: 1 selects Value1 , 0.5 selects Value0_5 , and 1e-6 selects the
variant for 0.000001 . Boolean text matches exactly, or case-insensitively under
--enum-mode relaxed , where TRUE selects True .
Listed strings are tried first, so in ["1", 1] the string "1" selects the string
variant while the number 1 still selects the number variant. This applies to JSON too:
when the enum doesn’t list the string "1" , the JSON string "1" decodes to the
number variant.
A declared type takes precedence over the listed values. With "type": "string" ,
every value is read and written as a string.
Discriminated Unions
A oneOf lists alternative schemas for one value. When it declares a discriminator ,
one property of each object names its branch, and the generator produces an enum that
reads that property to choose a variant. The petstore’s ContentBlock schema uses its
type property this way, mapping text to TextBlock , image to ImageBlock , and
so on. Here’s an excerpt of the generated enum:
/// A union of content block types with discriminator
#[derive(Debug, Clone, PartialEq)]
pub enum ContentBlock {
Text(TextBlock),
Image(ImageBlock),
Code(CodeBlock),
ToolUse(ToolUseBlock),
ToolResult(ToolResultBlock),
}
impl<'de> serde::Deserialize<'de> for ContentBlock {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value.get("type").and_then(|v| v.as_str()) {
Some("text") => {
serde_json::from_value(value)
.map(Self::Text)
.map_err(serde::de::Error::custom)
}
/* ... */
None => Err(serde::de::Error::missing_field("type")),
Some(other) => { /* ... */ }
}
}
}
The deserializer reads the whole object, looks up type , and decodes the object as the
matching branch. An object without type produces a missing-field error, and an
unlisted value produces an error that names it. To serialize, the enum writes the
wrapped struct, whose constant type field supplies the tag, as
Constant Properties describes.
Inferred Discriminators
Some documents leave out the discriminator even though each branch fixes a property to
its own constant. The petstore’s UsageCounterA and UsageCounterB schemas differ only
in their type constants. Here’s UsageCounterA , trimmed of its title and default
keywords:
"UsageCounterA": {
"type": "object",
"required": ["output_tokens", "type"],
"properties": {
"output_tokens": { "type": "integer", "minimum": 0 },
"type": { "type": "string", "const": "counter_a" }
}
}
UsageCounterB is the same apart from "const": "counter_b" . The items of
UsageCounters are an anyOf of these two schemas with no discriminator .
The generator infers a discriminator when a oneOf or anyOf meets all of these
conditions:
- It declares no
discriminator. - It has at least two branches other than
null, and each is a$refto an object schema. - Every such branch requires the same property and sets it to a string
const. - No two branches use the same constant.
The generated enum uses that property to select a variant:
#[derive(Debug, Clone, PartialEq)]
pub enum UsageCounterUsageCounterKind {
A(UsageCounterA),
B(UsageCounterB),
}
impl<'de> serde::Deserialize<'de> for UsageCounterUsageCounterKind {
fn deserialize<D>(deserializer: D) -> core::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value.get("type").and_then(|v| v.as_str()) {
Some("counter_a") => {
serde_json::from_value(value)
.map(Self::A)
.map_err(serde::de::Error::custom)
}
Some("counter_b") => {
serde_json::from_value(value)
.map(Self::B)
.map_err(serde::de::Error::custom)
}
/* ... */
}
}
}
The object {"type": "counter_b", "output_tokens": 3} decodes as
UsageCounterUsageCounterKind::B . If any condition isn’t met, such as when a branch is
inline, the union stays untagged.
Nullable Arrays of Unions
UsageCounters itself pairs an inline array with null :
"UsageCounters": {
"anyOf": [
{
"type": "array",
"items": {
"anyOf": [
{ "$ref": "#/components/schemas/UsageCounterA" },
{ "$ref": "#/components/schemas/UsageCounterB" }
]
}
},
{ "type": "null" }
],
"default": null
}
The generator keeps the array shape. UsageCounters becomes an alias for a
Vec of the item union, and a field that uses it carries the nullability
as an Option , as the iterations field of ToolResultBlock shows:
pub type UsageCounters = Vec<UsageCounterUsageCounterKind>;
pub struct ToolResultBlock {
/* ... */
pub iterations: Option<UsageCounters>,
/* ... */
}
Any anyOf or oneOf that pairs a single inline array with null converts this way,
whatever the array’s items are.
Constant Unions
A oneOf or anyOf whose every branch is a const describes the same set of values as
an enum array, so the generator produces the same value enum. Each const becomes a
unit variant documented with its branch’s description , and the schema’s default
marks the matching variant. The petstore’s ResponseKind schema is one of these:
"ResponseKind": {
"description": "A union of constants, generated as a value enum",
"anyOf": [
{ "type": "string", "const": "text", "description": "Plain text" },
{ "type": "string", "const": "json" }
],
"default": "json"
}
/// A union of constants, generated as a value enum
pub enum ResponseKind {
/// Plain text
#[serde(rename = "text")]
Text,
#[serde(rename = "json")]
#[default]
Json,
}
Constants of other JSON types keep their types, as described in
Boolean and Mixed-Type Enums , and null branches make
the property optional instead of adding a variant.
A union can also mix const branches with branches that carry a payload, such as an
object or a string. Its const variants stay tagged so that each matches its wire
value, and they come first. Only the payload variants are marked #[serde(untagged)] .
The petstore’s UsageChoice lists a string, a $ref to the Usage object, and the
constant "auto" , which is also its default:
pub enum UsageChoice {
#[serde(rename = "auto")]
#[default]
Auto,
#[serde(untagged)]
Usage(Usage),
#[serde(untagged)]
String(String),
}
Here "auto" deserializes to UsageChoice::Auto and serializes back to "auto" . An
object becomes UsageChoice::Usage , and any other string becomes UsageChoice::String
. A schema default that names a constant, like "auto" , selects its variant.
Non-Exhaustive Enums
An API can accept strings beyond a list of known values. An anyOf combining a string
enum with a freeform string describes this pattern. The generator creates one enum
containing the known variants and a variant for other strings.
Consider the petstore’s schema for a model name. It pairs a freeform string with the
ChatModel enum, which lists chat-1.0 and chat-1.1 :
"ChatModelId": {
"description": "A listed chat model or any other model name",
"anyOf": [{ "type": "string" }, { "$ref": "#/components/schemas/ChatModel" }]
}
The generator emits one variant per listed value, followed by an Other variant that
holds any other string. It marks the enum #[non_exhaustive] :
/// A listed chat model or any other model name
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, oas3_gen_support::Default)]
#[non_exhaustive]
pub enum ChatModelId {
#[serde(rename = "chat-1.0")]
#[default]
Chat10,
#[serde(rename = "chat-1.1")]
Chat11,
#[serde(untagged)]
Other(String),
}
The generator copies the referenced values into the open enum. ChatModel itself stays
exhaustive, so a field typed ChatModel still rejects other values.
Deserialization checks the listed values first, so "chat-1.0" becomes
ChatModelId::Chat10 . A value outside the list, such as "chat-2.0" , becomes
ChatModelId::Other("chat-2.0".to_string()) instead of producing an error. Because
Other is #[serde(untagged)] , it serializes as the bare string, so an unlisted value
you received is sent back unchanged. The Display implementation
writes the same string. You construct a listed value through its variant, such as
ChatModelId::Chat11 , and the enum still derives Eq and
Hash .
ChatModelId::Other("chat-1.0".to_string()) serializes like ChatModelId::Chat10 but
doesn’t compare equal to it, so reserve Other for values outside the list. When a
model name comes from your own code, such as a configuration file, convert it with the
generated From<String> implementation instead of building Other yourself:
impl From<String> for ChatModelId {
fn from(value: String) -> Self {
match value.as_str() {
"chat-1.0" => Self::Chat10,
"chat-1.1" => Self::Chat11,
_ => Self::Other(value),
}
}
}
ChatModelId::from(name) selects the named variant for each known value and Other for
values outside the list. For example, "chat-1.0" becomes ChatModelId::Chat10 , so
comparing the result with that variant succeeds.
The
#[non_exhaustive] attribute matters when the generated types live in their own crate, as with workspace crate output
. A match in another crate must then include a wildcard arm. When a later version of
the specification lists another value, regenerating adds a variant without breaking that
match . Inside the crate that defines the enum, the attribute has no effect.
The generator applies these rules to open string enums:
- The listed values can also be written inline, as in
{ "type": "string", "enum": ["chat-1.0", "chat-1.1"] }. - If a listed value such as
"other"already produces anOthervariant, the catch-all becomesOther2(String)so the two stay distinct. Thetierproperty ofChatPreferencelistsstandard,priority, andother, so itsChatPreferenceTierenum ends withOtherandOther2(String). - A schema
defaultoutside the list becomes the enum’sDefault. The sametierproperty declares"default": "flex", soChatPreferenceTier::default()returnsChatPreferenceTier::Other2("flex".to_string()). - With
--enum-mode relaxed, listed values and their aliases match regardless of ASCII letter case, in deserialization and inFrom<String>. Anything else lands inOtherwith its original spelling. - Where the generator writes a
FromStrimplementation, as it does for server output and response headers, parsing delegates toFrom<String>, so a value outside the list returnsOtherinstead of an error. - The listed values must all be strings. When the list includes numbers or booleans, the schema becomes an untagged union instead.
- The generator handles only
anyOfthis way. AoneOfthat mixes a freeform string with listed values remains an untagged union, which tries itsStringvariant last, as described in Enum Layout.
Helper Methods
By default, the generator adds constructor helpers for supported enum variants. For a
variant that wraps a struct with a Default implementation, a helper
can accept the required values and fill in the remaining fields. Pass --no-helpers if
you’d prefer to construct the variants yourself.
--no-helpers
Default (Helpers Enabled)
For the petstore’s ContentBlock enum, the helpers construct the nested structs and
wrap them in the matching variants:
/// A union of content block types with discriminator
#[derive(Debug, Clone, PartialEq)]
pub enum ContentBlock {
Text(TextBlock),
Image(ImageBlock),
Code(CodeBlock),
ToolUse(ToolUseBlock),
ToolResult(ToolResultBlock),
}
impl ContentBlock {
pub fn text(text: String) -> Self {
Self::Text(TextBlock {
text,
..Default::default()
})
}
pub fn image(source: Box<ImageSource>) -> Self {
Self::Image(ImageBlock {
source,
..Default::default()
})
}
pub fn code(code: String) -> Self {
Self::Code(CodeBlock {
code,
..Default::default()
})
}
}
Each helper takes the one field its struct requires, and ..Default::default() fills in
the rest, including the constant type field. ToolUseBlock and ToolResultBlock each
require more than one field, so their variants get no helper.
The text() helper lets us supply the text directly:
let block = ContentBlock::text("Hello, world!".to_string());
With --no-helpers
The enum still has the same variants, but the constructor helpers are omitted:
/// A union of content block types with discriminator
#[derive(Debug, Clone, PartialEq)]
pub enum ContentBlock {
Text(TextBlock),
Image(ImageBlock),
Code(CodeBlock),
ToolUse(ToolUseBlock),
ToolResult(ToolResultBlock),
}
We now construct the TextBlock and pass it to the Text variant ourselves:
let block = ContentBlock::Text(TextBlock {
text: "Hello, world!".to_string(),
..Default::default()
});
HTTP Requests and Responses
An operation connects an HTTP method and path to parameters, a request body, and possible responses. This chapter explains how those declarations become Rust types, including credentials, headers, and multipart fields.
The examples use fixtures/petstore.json . Rust excerpts omit unrelated fields and
attributes. For application code that uses these types, see
Client Generation or
Server Generation .
Operations and Parameters
Each operation under paths becomes a client method, a server trait method, and a
server route, named after its operationId in snake_case . The operation’s parameters
and request body travel together in a request struct named after it. For example,
showPetById becomes show_pet_by_id() , which takes a ShowPetByIdRequest whose
path and header fields hold its path and header parameters.
Only server output includes the operations under webhooks . The petstore’s
petAdopted webhook becomes the trait method pet_adopted() and the route
/webhooks/petAdopted . The client doesn’t get a method for it.
Path Item References
OpenAPI 3.1 lets an entry in paths or webhooks reference a reusable Path Item in
components.pathItems instead of declaring its operations in place. The petstore
declares the vaccination endpoints this way:
"/pets/{petId}/vaccinations": { "$ref": "#/components/pathItems/PetVaccinations" }
The PetVaccinations component declares the petId parameter, a get operation named
listVaccinations , and a post operation named recordVaccination . The generator
follows the reference and generates those operations as though they were written under
paths . The client gets list_vaccinations() and record_vaccination() methods, and
the server gets both trait methods and their route. The inventoryLow webhook
references the InventoryLowWebhook component in the same way and becomes the server’s
inventory_low() method.
A Path Item component can itself reference another component. The generator follows the
chain, and fields written next to a $ref , such as a summary , take precedence over
the fields of the component it names. The generator follows only
#/components/pathItems/ references within the same document. A missing component or a
reference cycle produces no operations for that entry.
Query Parameters
OpenAPI’s default serialization for query parameters is the form style with
explode: true . For an array, that means one name=value pair per item, with the name
repeated. The petstore’s searchPets operation declares its tags filter without a
style , so it uses that default:
{
"name": "tags",
"in": "query",
"description": "Pets carrying any of these tags, one `tags=` pair per tag",
"schema": { "type": "array", "items": { "type": "string" } }
}
The generated query struct holds the array as a Vec :
pub struct SearchPetsRequestQuery {
/* ... */
/// Pets carrying any of these tags, one `tags=` pair per tag
pub tags: Option<Vec<String>>,
/// Pets in any of these statuses, one `statuses=` pair per status
pub statuses: Option<Vec<PetStatus>>,
/* ... */
}
When tags holds calm and shy , the query carries tags=calm&tags=shy .
Query Arrays shows how the client sends these
values, and Connecting the Router shows
how the server reads them.
With explode: false , the items share a single pair. They’re joined by a comma for
form , a space for spaceDelimited , or | for pipeDelimited , and the generator
adds a serde_with adapter that joins and splits them. The petstore’s listPets
declares its tags filter with style: form and explode: false :
pub struct ListPetsRequestQuery {
/* ... */
/// Only return pets that carry every listed tag
#[serde_as(as = "Option<oas3_gen_support::StringWithCommaSeparator>")]
pub tags: Option<Vec<String>>,
}
The same two tags now travel as one tags pair whose value is calm,shy . A tag that
contains a comma arrives as two tags.
Cookie Parameters
A parameter declared with in: cookie travels in the Cookie header. The request
struct holds an operation’s cookie parameters in a nested struct in its cookie field.
The petstore’s getPetCard operation declares two optional cookies, cardTheme and
recentlyViewed . This excerpt omits the structs’ attributes:
pub struct GetPetCardRequest {
pub path: GetPetCardRequestPath,
pub cookie: GetPetCardRequestCookie,
}
pub struct GetPetCardRequestCookie {
/// The visitor's preferred card theme
pub card_theme: Option<GetPetCardRequestCookieCardTheme>,
/// Pets the visitor viewed recently, most recent first
pub recently_viewed: Option<Vec<uuid::Uuid>>,
}
Cookie parameters use OpenAPI’s form style. An array’s items are joined with commas
into one value, and each name=value pair is percent-encoded with the
cookie crate. Cookie Parameters
shows how the client sends them, and
Receiving Cookie Parameters shows
how the server reads them.
A cookie that carries a credential belongs in an apiKey security scheme with
in: cookie , as the petstore’s SessionCookie does. See
Security Schemes .
Deprecated Operations
An operation marked deprecated: true is still generated. Its client method carries the
#[deprecated] attribute, as Deprecated Operations
shows. A deprecated schema property, such as the tag property of Pet , gets
#[deprecated] on its field in both outputs.
Security Schemes
OpenAPI describes authentication in two places. A security scheme under
components.securitySchemes describes a credential and how it travels in a request. A
security requirement in security names the schemes an operation accepts. The
generator supports apiKey schemes in headers, query parameters, and cookies, and
http schemes that use bearer tokens.
Let’s start with a header key. This excerpt from the petstore declares a scheme named
ApiKeyAuth and uses it as the default requirement:
{
"security": [{ "ApiKeyAuth": [] }],
"components": {
"securitySchemes": {
"ApiKeyAuth": {
"type": "apiKey",
"in": "header",
"name": "X-Api-Key",
"description": "Key issued in the Petstore partner dashboard."
}
}
}
}
The scheme name, ApiKeyAuth , identifies the credential in generated Rust names. The
name value, X-Api-Key , identifies the HTTP header. Operations inherit the top-level
security unless they declare their own requirements.
A bearer scheme is an http scheme whose scheme value is bearer . It has no name
, because the token always travels in the Authorization header as Bearer <token> .
The petstore declares one named BearerAuth :
{
"components": {
"securitySchemes": {
"BearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "JWT" }
}
}
}
The generator matches bearer in any case, as HTTP does. A bearerFormat value, such
as JWT , only documents the token, so it doesn’t change the generated code. A header
parameter named Authorization doesn’t produce credentials; OpenAPI says to ignore it,
as Header Emission explains.
Requirements
The entries in the security array are alternatives: a request needs to satisfy one
entry. Within an entry, all the named schemes are required. These petstore operations,
most of them under /staff/keys , show how the grouping changes the requirement:
Operation’s security | Requirement | Petstore operation |
|---|---|---|
[{"ApiKeyAuth": []}], inherited from the top level | Supply the header key. | listApiKeys |
[{"QueryKey": []}, {"SessionCookie": []}] | Supply a query key or a session cookie. | createApiKey |
[{"ApiKeyAuth": [], "SessionCookie": []}] | Supply both the header key and the session cookie. | getApiKey |
[{"BearerAuth": []}, {"ApiKeyAuth": []}] | Supply a bearer token or the header key. | revokeApiKey |
[{}, {"ApiKeyAuth": []}] | Allow anonymous access, or accept the header key. | listPets |
[] or [{}] | Allow anonymous access, replacing any top-level requirement. | getHealth |
QueryKey is an apiKey scheme read from the api_key query parameter, and
SessionCookie is one read from the session cookie. BearerAuth is the bearer scheme
shown earlier.
The server represents a credential as required when every alternative needs it. Otherwise, the field is optional, and the extractor checks that the supplied credentials complete at least one alternative. These checks establish that credentials are present. Your service still needs to decide whether their values authorize the request.
Other schemes, such as HTTP basic authentication and OAuth 2.0, don’t produce generated
credentials. The generator reports a warning for each referenced scheme it can’t handle.
If an operation has an alternative containing one of these schemes, all its credential
fields become optional, including those in that same alternative. The generated
extractor can’t enforce the full requirement, so your application must handle
authorization for that operation. In the petstore, replaceApiKey accepts BasicAuth
or ApiKeyAuth , and adoptPet accepts OAuth 2.0, OpenID Connect, or ApiKeyAuth .
Both operations receive the header key as an optional field.
Keeping Keys Out of Logs
The server stores API keys and bearer tokens as
secrecy::SecretString . The client uses the same type for
query keys and bearer tokens. SecretString prints [REDACTED] in debug output and
clears its stored value when dropped. It implements neither Display
, Serialize , nor PartialEq .
Client header keys use sensitive http::HeaderValue
values. They print as Sensitive , but their values aren’t cleared when dropped.
To read a SecretString , call expose_secret() with
secrecy::ExposeSecret in scope; the server example
shows how. Once exposed, the returned string is ordinary text, so keep it out of log
output.
Sending Credentials shows how the client stores and sends each scheme, and Receiving Credentials shows the server’s credential types and rejections.
Responses
Generated operations share a response enum named ApiResponse . Its variants represent
the status codes declared by the selected operations. When both success and failure
payloads are needed, the enum has the form ApiResponse<Value, Failure> . In module
output, you’ll find it in types.rs .
The generator also adds an Other variant for responses that the document doesn’t
declare. Its shape depends on the output. In client output, Other holds a response the
client won’t decode, which it returns unread. In server output, it lets a service send a
status that the operation doesn’t declare. The following table shows the possible
variant shapes:
| Variant shape | When |
|---|---|
Ok(Value), Created(Value), … | A 2xx status that carries a body in at least one operation |
NotFound(Failure), Conflict(Failure), … | Any other declared status that carries a body |
NoContent, NotModified, … | A status that has no body, and no headers in its class, in any operation |
ClientError(http::StatusCode, Failure) | A range such as 4XX; the concrete status travels with the body |
Unknown(http::StatusCode, Failure) | The spec’s default response, when it declares a body |
Other(Box<reqwest::Response>) | Client output: a response with a status the operation doesn’t declare, or a declared status under a media type it doesn’t declare |
Other(http::StatusCode, Vec<u8>) | Server output: a status the operation doesn’t declare, when it has no default response |
The variants that carry a status use http::StatusCode . The
client’s Other holds the unread reqwest::Response , so
you can still read its status, headers, and body. Because that type isn’t
Clone , the client’s response enum derives only
Debug . The server’s Other holds its raw body in a
Vec<u8> .
Each operation supplies its own types for Value and Failure . We’ll call these the
two response classes: Value covers 2xx statuses, and Failure covers the remaining
statuses. Before accounting for headers, the body types determine each parameter as
follows:
| Bodies declared in the class | Parameter |
|---|---|
| None | () |
| One type | That type |
| One type plus a status that carries a payload but no body | Option<Type> |
| Several types | An untagged payload union such as ErrorOrProblem |
| Several types plus a status that carries a payload but no body | Option<Union> |
A payload union is an enum that can hold one of several body types. For example,
showPetById answers 404 with a Problem and its 4XX and default responses with
an Error , so its Failure is ErrorOrProblem . Operations that declare the same set
of body types share that union definition. If no status uses one of the response enum’s
type parameters, the generator omits that parameter. If a schema already uses the name
ApiResponse , the generator names the response enum ApiResponseType .
Response Headers
Some operations return headers as well as a body. When any status in a response class
declares headers, the class uses WithHeaders<Headers, Body> as its parameter.
Headers is a generated struct with one field for each header declared across that
class. Body is the type chosen using the table above.
The wrapper keeps the headers and body together. The generator adds it beside the
response enum only when at least one response declares headers. If a schema already uses
the name WithHeaders , the generated name gets a numeric suffix:
/// A response body together with the headers its response declares.
#[derive(Debug, Clone)]
pub struct WithHeaders<Headers, Body> {
pub headers: Headers,
pub body: Body,
}
A header field is optional unless every status in its class marks it as required. If an
optional header is missing or can’t be parsed, its value is None . For a list of
values, one invalid element makes the whole list None . A missing or invalid required
response header causes parse_response to return an error.
Operations with the same header fields share a header struct. The generator names that
struct after its headers, as in XNextHeaders or ETagAndXCacheHeaders , and adds a
numeric suffix if the name is already in use. A status without a body can still carry
headers: its variant then holds a WithHeaders value whose body is ()
or an Option .
The generator ignores Content-Type entries in response header definitions. A header
without a schema uses String . Enums used in headers implement
FromStr for both client and server output, and response headers
receive name constants in the same way as request headers.
For example, generating the whole petstore client produces this response enum and, for
the pagination headers of listPets , this header struct:
/// Response enum shared by every operation.
///
/// `Value` is the operation's success body type and `Failure` its error body type.
/// A status code is a unit variant when no operation gives it a body.
#[derive(Debug)]
pub enum ApiResponse<Value, Failure> {
///200
Ok(Value),
///201
Created(Value),
///202
Accepted(Value),
///204
NoContent(Value),
///304
NotModified,
///400
BadRequest(Failure),
///401
Unauthorized(Failure),
///404
NotFound(Failure),
///409
Conflict(Failure),
///422
UnprocessableEntity(Failure),
///4XX
ClientError(http::StatusCode, Failure),
///default
Unknown(http::StatusCode, Failure),
///Response the operation does not declare, by status or media type; carries it unread.
Other(Box<reqwest::Response>),
}
/// Response headers that share one status class in an operation.
#[derive(Debug, Clone, PartialEq, oas3_gen_support::Default)]
pub struct XNextAndXTotalCountAndXRequestIdHeaders {
/// A link to the next page of results
pub x_next: Option<String>,
/// Number of pets that match the filters
pub x_total_count: Option<i64>,
/// Echo of the request's correlation identifier
pub x_request_id: Option<uuid::Uuid>,
}
A status is a unit variant only when no operation declares a body or headers for it. No
petstore operation returns a 204 body, but describePets declares an Allow header
on its 204 response, so NoContent carries a Value . NotModified has neither, so
it’s a unit variant.
The listPets operation returns
ApiResponse<WithHeaders<XNextAndXTotalCountAndXRequestIdHeaders, Pets>, Error> . The
client decodes that value from an HTTP response, and the server constructs it to send a
response. See Reading Responses and
Returning Responses for examples.
Response Media Types
Each status declares its body under a media type, and that media type decides the body’s Rust type:
| Media type | Body type |
|---|---|
JSON, including +json types such as application/problem+json | The type generated from the schema |
text/*, such as text/plain | String |
A binary type, such as image/png or application/octet-stream | Vec<u8> |
text/event-stream | A stream of events, described below |
For a text response, the client keeps string and byte bodies unchanged. It parses other
types through FromStr when they have a text representation; the server uses Display
to write those values. Objects and other types without a text representation are encoded
and decoded as JSON.
For example, getPetCard returns its kennel card as text/plain , so it returns
ApiResponse<String, Problem> . getPetPhoto returns image/png and produces
ApiResponse<Vec<u8>, Problem> . Both declare their 404 body as
application/problem+json , which decodes into the Problem struct.
The client uses the status and Content-Type to choose a decoder. It ignores media type
parameters such as charset when matching. These rules determine whether a response
matches the declaration:
- A JSON or XML media type accepts any type of that syntax, so a status that
declares
application/jsonalso readsapplication/problem+json. - Any other media type accepts itself, and a range such as
image/*accepts each of its subtypes. - Wildcards also accept the concrete media type a generated server chooses:
a JSON type for JSON data,
application/octet-streamfor bytes, ortext/plainfor text. - A binary declaration can accept JSON when its body requires JSON decoding,
as with an error object declared as
application/octet-stream. This fallback applies only if the status has no JSON declaration and doesn’t declare the same body under a text type. Those declarations take precedence. - A status that declares no body reads any response.
When a status declares a body, a missing or unmatched Content-Type returns Other .
An exact status takes precedence over a range such as 4XX , which in turn takes
precedence over default . The declaration order doesn’t affect that choice. For
example, an HTML 404 page returns Other when the 404 declaration requires a JSON
Problem ; the client doesn’t try the 4XX body as a fallback.
Reading Responses shows how the client decodes these bodies, and Returning Responses shows how the server sends them.
A text/event-stream response delivers Server-Sent Events. The client reads it as
oas3_gen_support::EventStream<oas3_gen_support::SseFrame<T>> and the server returns
futures::stream::BoxStream<'static, T> , where T is the type of one event’s data.
The petstore’s streamPetVitals operation streams VitalsReading values this way. When
the schema describes the whole event, with properties drawn from data , event , id
, and retry , it’s a frame type: the client reads it as an alias of SseFrame , the
server sends it as one complete Axum event, and the stream carries that type itself. In
a payload union, a stream member is named after its item type with a Stream suffix,
such as ContentResponseOrAssistantEventFrameStream . See
Reading Server-Sent Events for the
client side and
Streaming Server-Sent Events for
the server side.
Multipart Form Bodies
A multipart/form-data body uses a struct in types.rs . The client sends its
properties as separate parts, and the server reads those parts back into the struct.
Each part has the property’s name and an encoding chosen from its schema and the
operation’s encoding map.
Let’s start with a body that uploads files next to a few text fields. The petstore’s
submitIntakeForm operation sends the IntakeForm schema as multipart/form-data .
Here it is, trimmed to four of its properties and the requirements on them:
"IntakeForm": {
"type": "object",
"description": "A shelter intake form",
"required": ["name", "attachments"],
"properties": {
"name": { "type": "string" },
"file": {
"anyOf": [{ "type": "string", "format": "binary" }, { "type": "null" }],
"description": "The previous owner's records"
},
"attachments": { "type": "array", "items": { "type": "string", "format": "binary" } },
"quality": { "$ref": "#/components/schemas/CoatQuality" }
}
}
The generator produces a struct with name: String , file: Option<Vec<u8>> ,
attachments: Vec<Vec<u8>> , and quality: Option<CoatQuality> . The full schema also
has base64 text, numbers, dates, nested objects, and maps, and the operation’s
encoding map changes how several of them are sent. The tables below describe those
encodings. See
Sending Multipart Bodies and
Receiving Multipart Bodies for the
code that writes and reads them.
Choosing the Part for Each Property
The following table shows how the generator encodes each kind of property when the media
type has no encoding entry for it:
| Property schema | Part sent |
|---|---|
type: string with format: binary | A file part labelled application/octet-stream, carrying the raw bytes |
No type at all, such as {} | A file part, because OpenAPI 3.1 reads an untyped multipart property as raw binary |
type: string, or a string enum | A text part holding the string |
type: string with format: byte | A text part holding the base64 text |
type: integer, number, boolean, or an integer enum | A text part holding the value, such as 3 or true |
format: date, date-time, time, or uuid | A text part holding the serialized value, such as 2026-10-07T16:42:52Z |
type: object, a $ref to one, or a discriminated oneOf | An application/json part |
An untagged anyOf or oneOf, or a schema such as additionalProperties: true | A text part when the value is a string, and an application/json part otherwise |
type: array | One part per item, each encoded by the rules above |
additionalProperties | One part per entry, named by its key |
An untyped property is generated as Vec<u8> in a multipart body, and
items: {} produces Vec<Vec<u8>> . This lets an array carry several files under one
part name. The petstore’s uploadMedia operation uses both forms: its untyped cover
property becomes Option<Vec<u8>> , and its file property, an array with items: {}
, becomes Option<Vec<Vec<u8>>> . This mapping applies only to schemas used exclusively
as multipart bodies. If the same schema is also a JSON response or another schema’s
property, the field stays serde_json::Value , so its JSON
form doesn’t change, and the generator warns.
A value whose type depends on the data, such as an untagged union, goes as JSON unless
it’s a string. That way a server can tell the string "5" from the number 5 .
Encoding Objects
Each entry in a media type’s encoding map is an OpenAPI Encoding Object. It changes
how one property is sent. Its contentType field sets the media type of that property’s
parts:
- When the field lists several media types, such as
image/png, image/jpeg, the generator uses the first one, because a part can carry only one. It warns when it labels every file with the first type of a list. - A wildcard such as
image/*describes a range rather than a type a client can send, so the generator skips it, falls back to the default, and warns.IntakeFormlabels itsthumbnailfile this way, so the file is sent asapplication/octet-stream. - A JSON media type serializes the value as JSON. A string property with
contentType: application/jsonis therefore sent as"hello", quotes included. - Objects are always serialized as JSON. When
contentTypenames a non-JSON type for an object, such asapplication/xml, the part keepsapplication/json, and the generator warns. - Rust strings are UTF-8, so a
charsetother than UTF-8 on a text part becomescharset=utf-8, with a warning.
The style , explode , and allowReserved fields switch a property to the
serialization used for query parameters. When any of them is present, contentType is
ignored for text values, and values aren’t percent-encoded. File properties keep their
file parts. The following table applies to struct and map properties:
| Property | Encoding | Parts sent |
|---|---|---|
| Array | explode: true (the default for form) | One part per item |
| Array | explode: false | One part, with items joined by ,, a space, or | for form, spaceDelimited, or pipeDelimited |
| Object | style: deepObject | One part per member, named property[member] |
| Object | style: form, explode: true | One part per member, named by the member |
| Object | style: form, explode: false | One part of alternating names and values, such as min,1,max,5 |
IntakeForm uses several of these rows. Its ids array is pipeDelimited with
explode: false , filter uses deepObject , range uses form with
explode: false , and extras uses form with explode: true .
This serialization has no way to escape its delimiters. An item that contains the
delimiter, such as a,b in a comma-joined array, arrives as two items. A style value
that multipart/form-data doesn’t support, such as matrix , falls back to form with
a warning.
An Encoding Object can also declare headers for a part. The generated client doesn’t
send part headers, and the generated server doesn’t expose them. Part headers are
optional unless a header sets required: true , so the generator warns about each
required one.
Generation Warnings
The generator also warns about specifications it can’t follow exactly:
- An
encodingentry names a property the body schema doesn’t have. - The body schema isn’t an object with properties, such as a top-level
oneOf. Its parts come from its JSON form, so binary values aren’t sent as files. - The media type is another
multipartsubtype, such asmultipart/mixed. The client sendsmultipart/form-data. - A property name contains a double quote or a line break, which a part header can’t carry.
- An array holds nested arrays, whose inner arrays are sent as JSON text.
The parser the generator uses doesn’t keep a schema’s contentEncoding , so a
type: string property with contentEncoding: base64 is sent as a plain text part
rather than labelled application/octet-stream .