Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

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 reqwest client.
  • 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-stream responses: 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 apiKey and http bearer security schemes.
  • Check supported field constraints, including multipleOf, with validator::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

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 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 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 typeHow the client sends the body
application/jsonAs JSON, with Content-Type: application/json
A +json type, such as application/merge-patch+jsonAs JSON, with the declared Content-Type
application/x-www-form-urlencodedAs form fields
text/*, such as text/plainAs text, with the declared Content-Type
A binary type, such as image/pngAs 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-dataAs 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:

  • data decodes as its own schema. A plain string keeps the frame’s text as it is. A string with contentMediaType: application/json and a contentSchema decodes as that schema. Anything else, such as the AssistantEvent reference here, decodes from JSON.
  • event decodes into its declared type, such as AssistantEventFrameEvent. An unrecognized name produces a DeserializationError with path: event. If the frame omits event:, its name is message. That name becomes None when the property is optional. An event declared as a string or const uses String, the default event type.
  • id() and retry() come from the stream, whatever types the id and retry properties declare. A frame without an id: 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

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.

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 typebody field
application/json, or a +json type such as application/merge-patch+jsonThe generated body type, decoded from JSON
application/x-www-form-urlencodedThe generated body type, decoded from the form
text/*, such as text/plainA String
A binary type, such as image/pngA Vec<u8>
multipart/form-dataThe 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 typeSent as
application/jsonJSON, serialized with axum::Json
A +json type, such as application/problem+jsonJSON, labelled with the declared Content-Type
text/*, such as text/plainText, 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-streamThe 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-streamA 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-data body that the handler can’t read all produce 400. The reason starts with Bad 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 parameterSetterReason
build or builderbuild_value(), builder_value()bon uses these names itself
A name that starts with a digit, such as _2favalue_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

Schemas and Types

Enums and Unions

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

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:

CrateDeclared when the generated code
anyhowreturns anyhow::Result from client or server methods
axumdefines a server-mod router and extractors, with the multipart feature
axum-extrareads query parameters or cookie API keys on the server, with the cookie and query features
bonderives builders (--enable-builders)
chronomaps date, date-time, or time formats
futuresreturns or sends a server’s text/event-stream as a BoxStream
httpemits header constants or HeaderMap conversions
indexmapemits IndexMap/IndexSet collection types
oas3-gen-supportuses runtime derives, diagnostics, multipleOf checks, or reads event streams on the client
regexemits pattern validation constants
reqwestperforms client-mod HTTP calls; the form feature sends application/x-www-form-urlencoded bodies
reqwest_cookie_storesends cookie API keys or encodes cookie parameters from the client
secrecystores API keys and bearer tokens
serdederives Serialize/Deserialize
serde_html_formserializes query parameters on the client, with only its ser and std features
serde_jsonhandles freeform serde_json::Value payloads
serde_withapplies serde_as conversions
uuidmaps the uuid format
validatorderives 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:

  1. It collects schemas used by the selected operations’ parameters, request bodies, and responses.
  2. It follows references from those schemas to other schemas.
  3. 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 IDMethod NameRequest Type
listPetsfetch_all_petsFetchAllPetsRequest
showPetByIdget_petGetPetRequest

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>
ValueModifierUse Case
public (default)pubLibrary distribution
cratepub(crate)Internal crate types
filepub(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 with uniqueItems: true, such as the tags of Pet, is a Vec and accepts repeated items. See Ordering and Collections.
  • An optional field that also allows null is an Option<T>, so JSON null and a missing property both decode as None, and None is left out when serializing. A client therefore can’t send null to clear a field with a JSON merge patch, such as the nickname in the PetPatch body of updatePet. Likewise, a field without a type, such as anyValue in CollarTelemetry, decodes null as None.
  • The generator ignores allowReserved on query parameters. The client percent-encodes reserved characters, such as the / in a q value for searchPets.
  • 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 filter on searchPets, makes the client method return an error before it sends the request.
  • An anyOf whose branches each require a different property isn’t enforced. The PetCarrier schema requires either wheels or straps, 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 multipleOf that 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.

FlagDefaultDescription
modetypesGeneration mode: types, client, client-mod, server-mod
-w, --workspacefalseEmit a workspace-compatible Cargo.toml with sources in src/; requires client-mod or server-mod
--module-version0.0.0Version written to the generated Cargo.toml [package] table; requires --workspace
-C, --visibilitypublicItem visibility: public, crate, file
--enum-modemergeEnum duplicate handling: merge, preserve, relaxed
--enum-layoutspecVariant ordering: spec, sorted
--no-helpersfalseDisable enum constructor helpers
--odata-supportfalseMake @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-headersfalseEmit header constants for all component-level headers
--enable-buildersfalseEnable bon builder derives and methods
--no-ordered-collectionsfalseEmit HashMap/Vec instead of indexmap collection types
--doc-formatfalseFormat documentation comments with mdformat
--only(none)Include only specified operations
--exclude(none)Exclude specified operations
--all-schemasfalseGenerate 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

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 specificationGenerated nameReason
A property named after a keyword, such as type or matchr#typeKeywords need the raw identifier prefix
A property named crate, self, or supercrate_, self_, super_These keywords can’t be raw identifiers
A property that starts with a digit, such as 2fa_2faIdentifiers can’t start with a digit
A schema named after a prelude item, such as Option or VecOptionTypeThe name would shadow the prelude
A schema named SelfSelfTypeSelf can’t be a raw identifier
A schema named SSTypeGenerated server handlers use S for your service type
A schema that starts with a digit, such as 123ResponseT123ResponseIdentifiers 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 keywordValidation attribute
minLength, maxLength on a stringlength(min = ..., max = ...)
minItems, maxItems on an arraylength(min = ..., max = ...)
minimum, maximum, exclusiveMinimum, exclusiveMaximumrange(...)
patternregex(path = ...), naming a generated static that holds the compiled expression
format: emailemail
format: uri or format: urlurl
multipleOfcustom(function = ...), calling a check in oas3-gen-support
A property whose own type has validationnested

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 as 1e-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:

KeyOpenAPI FormatRust Field Type
date_timedate-timechrono::DateTime<chrono::Utc>
datedatechrono::NaiveDate
timetimechrono::NaiveTime
durationdurationstd::time::Duration
uuiduuiduuid::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

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>
ValueBehavior
merge (default)Merge duplicates; first occurrence is canonical, others become aliases
preserveKeep all variants; append numeric suffix to collisions
relaxedMerge 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>
ValueBehavior
spec (default)Preserve variant order from the OpenAPI document
sortedSort 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 $ref to 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 an Other variant, the catch-all becomes Other2(String) so the two stay distinct. The tier property of ChatPreference lists standard, priority, and other, so its ChatPreferenceTier enum ends with Other and Other2(String).
  • A schema default outside the list becomes the enum’s Default. The same tier property declares "default": "flex", so ChatPreferenceTier::default() returns ChatPreferenceTier::Other2("flex".to_string()).
  • With --enum-mode relaxed, listed values and their aliases match regardless of ASCII letter case, in deserialization and in From<String>. Anything else lands in Other with its original spelling.
  • Where the generator writes a FromStr implementation, as it does for server output and response headers, parsing delegates to From<String>, so a value outside the list returns Other instead 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 anyOf this way. A oneOf that mixes a freeform string with listed values remains an untagged union, which tries its String variant 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.

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 securityRequirementPetstore operation
[{"ApiKeyAuth": []}], inherited from the top levelSupply 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 shapeWhen
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 classParameter
None()
One typeThat type
One type plus a status that carries a payload but no bodyOption<Type>
Several typesAn untagged payload union such as ErrorOrProblem
Several types plus a status that carries a payload but no bodyOption<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 typeBody type
JSON, including +json types such as application/problem+jsonThe type generated from the schema
text/*, such as text/plainString
A binary type, such as image/png or application/octet-streamVec<u8>
text/event-streamA 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/json also reads application/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-stream for bytes, or text/plain for 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 schemaPart sent
type: string with format: binaryA 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 enumA text part holding the string
type: string with format: byteA text part holding the base64 text
type: integer, number, boolean, or an integer enumA text part holding the value, such as 3 or true
format: date, date-time, time, or uuidA text part holding the serialized value, such as 2026-10-07T16:42:52Z
type: object, a $ref to one, or a discriminated oneOfAn application/json part
An untagged anyOf or oneOf, or a schema such as additionalProperties: trueA text part when the value is a string, and an application/json part otherwise
type: arrayOne part per item, each encoded by the rules above
additionalPropertiesOne 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. IntakeForm labels its thumbnail file this way, so the file is sent as application/octet-stream.
  • A JSON media type serializes the value as JSON. A string property with contentType: application/json is therefore sent as "hello", quotes included.
  • Objects are always serialized as JSON. When contentType names a non-JSON type for an object, such as application/xml, the part keeps application/json, and the generator warns.
  • Rust strings are UTF-8, so a charset other than UTF-8 on a text part becomes charset=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:

PropertyEncodingParts sent
Arrayexplode: true (the default for form)One part per item
Arrayexplode: falseOne part, with items joined by ,, a space, or | for form, spaceDelimited, or pipeDelimited
Objectstyle: deepObjectOne part per member, named property[member]
Objectstyle: form, explode: trueOne part per member, named by the member
Objectstyle: form, explode: falseOne 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 encoding entry 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 multipart subtype, such as multipart/mixed. The client sends multipart/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 .