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

A Rust code generator for OpenAPI v3.1.x specifications.

Overview

oas3-gen parses OpenAPI 3.1 specifications and generates comprehensive Rust type definitions with validation. It produces idiomatic Rust code with full serde support, making it easy to integrate with HTTP clients and servers.

Features

  • Type Generation: Structs, enums, and type aliases from OpenAPI schemas
  • HTTP Client: Generated async client using reqwest
  • Axum Server: Generated server trait and router for axum
  • Validation: Field validation using the validator crate
  • Serde Support: Full serialization/deserialization with serde
  • Discriminated Unions: Proper handling of oneOf/anyOf with discriminators
  • Builder Pattern: Optional bon integration for ergonomic struct construction via --enable-builders

Installation

cargo install oas3-gen

Or build from source:

git clone https://github.com/eklipse2k8/oas3-gen
cd oas3-gen
cargo build --release

Quick Start

Generate types from an OpenAPI specification:

oas3-gen generate types -i api.json -o types.rs

Generate a complete HTTP client:

oas3-gen generate client -i api.json -o client.rs

Generate a modular client library:

oas3-gen generate client-mod -i api.json -o src/api/

Generate an Axum server trait:

oas3-gen generate server-mod -i api.json -o src/server/

Supported Formats

  • JSON specifications (.json)
  • YAML specifications (.yaml, .yml)

Requirements

  • Rust 1.89 or later
  • OpenAPI 3.1.x specification

License

MIT

Code Generation Reference

This chapter describes the command-line flags that control code generation in oas3-gen. Each flag affects the structure, visibility, or content of the generated Rust code.

Table of Contents


Generation Modes

The positional mode argument selects what you generate.

cargo run -- generate <MODE> -i spec.json -o <OUTPUT>

types

Generates type definitions only. You get a single output file.

Output: types.rs

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pet {
    pub id: i64,
    pub name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
    #[serde(rename = "available")]
    Available,
    #[serde(rename = "pending")]
    Pending,
}

client

Generates types and an HTTP client in a single file. The generated client requires reqwest.

Output: client.rs

// Types section
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pet { /* ... */ }

// Client section
#[derive(Debug, Clone)]
pub struct PetStoreClient {
    pub client: Client,
    pub base_url: Url,
}

impl PetStoreClient {
    pub fn new() -> Self { /* ... */ }

    pub async fn list_pets(&self, request: ListPetsRequest) -> anyhow::Result<ListPetsResponse> {
        /* ... */
    }
}

client-mod

Generates a module directory with separate files for types and client.

Output directory:

output/
├── mod.rs
├── types.rs
└── client.rs

mod.rs:

mod types;
mod client;

pub use types::*;
pub use client::*;

server-mod

Generates a module directory with types and an Axum server trait.

Output directory:

output/
├── mod.rs
├── types.rs
└── server.rs

server.rs:

pub trait ApiServer: Send + Sync {
    fn list_pets(
        &self,
        request: ListPetsRequest,
    ) -> impl std::future::Future<Output = anyhow::Result<ListPetsResponse>> + Send;
}

pub fn router<S>(service: S) -> Router
where
    S: ApiServer + Clone + Send + Sync + 'static,
{
    Router::new()
        .route("/pets", get(list_pets::<S>))
        .with_state(service)
}

Workspace Crate Output

-w, --workspace

Turns the output directory into a complete, compilable crate instead of a bare module directory. You can only use this flag with client-mod and server-mod; combining it with types or client is an error.

cargo run -- generate client-mod -i petstore.json -o petstore-api --workspace

Output directory:

petstore-api/
├── Cargo.toml
└── src/
    ├── lib.rs
    ├── types.rs
    └── client.rs

Sources move into an inner src/ directory, and the module file is written as src/lib.rs instead of mod.rs. The directory becomes a crate root rather than a submodule of a surrounding crate. The generated Rust files are otherwise identical to the default client-mod and server-mod 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 event_stream yields event-stream. The library target of event-stream is still event_stream, 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

# AUTO-GENERATED CODE - DO NOT EDIT!
# Generated by `oas3-gen v0.26.3`

[package]
name = "petstore-api"
version = "0.0.0"
edition = "2024"
rust-version = "1.89"
description = "Rust client generated from the Swagger Petstore OpenAPI document"

[dependencies]
anyhow = "1.0"
bon = { version = "3.9", features = ["implied-bounds"] }
chrono = { version = "0.4.42", default-features = false, features = ["std", "clock", "serde"] }
http = "1.4"
indexmap = { version = "2.14", features = ["serde"] }
oas3-gen-support = "0.26.3"
reqwest = { version = "0.13", default-features = false, features = ["json", "multipart", "http2", "native-tls", "query", "stream"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_with = { version = "3.21", features = ["base64", "chrono"] }
validator = { version = "0.21", features = ["derive"] }

Cargo.toml is rewritten on every run and carries an auto-generated banner. Keep project-specific dependencies and settings in the parent workspace rather than editing the generated manifest.

The [package] version defaults to 0.0.0. Pass --module-version to stamp a real release version into the manifest:

cargo run -- generate client-mod -i petstore.json -o petstore-api --workspace --module-version 1.0.0

The flag requires --workspace; without it there is no manifest to version.

Dependencies

The generator hardcodes dependency versions instead of resolving them from the environment, so a given release always emits the dependency set it was built and tested against. oas3-gen-support is pinned to the generator’s own version, so the runtime support library can never drift from the code that calls it.

The manifest only declares the crates that the generated sources reference. A spec without date formats produces no chrono entry, a server-mod crate has no reqwest entry, and so on.

CrateDeclared when the generated code
anyhowreturns anyhow::Result from client or server methods
axumdefines a server-mod router and extractors
bonderives builders (--enable-builders)
chronomaps date, date-time, or time formats
httpemits header constants or HeaderMap conversions
indexmapemits IndexMap/IndexSet collection types
oas3-gen-supportuses runtime derives, diagnostics, or event streams
regexemits pattern validation constants
reqwestperforms client-mod HTTP calls
serdederives Serialize/Deserialize
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.

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"]

Consumers then depend on it by path:

[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 the crate compiles but exposes nothing to its consumers.


Visibility

-C, --visibility <LEVEL>

Controls the visibility modifier applied to all generated items.

ValueModifierUse Case
public (default)pubLibrary distribution
cratepub(crate)Internal crate types
file(none)Private implementation

Example: --visibility public

pub struct Pet {
    pub id: i64,
    pub name: String,
}

pub enum Status {
    Available,
    Pending,
}

impl Status {
    pub fn available() -> Self { Self::Available }
}

Example: --visibility crate

pub(crate) struct Pet {
    pub(crate) id: i64,
    pub(crate) name: String,
}

pub(crate) enum Status {
    Available,
    Pending,
}

impl Status {
    pub(crate) fn available() -> Self { Self::Available }
}

Example: --visibility file

struct Pet {
    id: i64,
    name: String,
}

enum Status {
    Available,
    Pending,
}

impl Status {
    fn available() -> Self { Self::Available }
}

Enum Mode

--enum-mode <MODE>

Controls how the generator treats enum variants that differ only in case.

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

{
  "type": "string",
  "enum": ["ACTIVE", "active", "Active", "PENDING"]
}

Example: --enum-mode merge

Variants normalizing to the same identifier are merged. Additional values become serde aliases.

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
    #[serde(rename = "ACTIVE", alias = "active", alias = "Active")]
    Active,
    #[serde(rename = "PENDING")]
    Pending,
}

Deserialization:

  • "ACTIVE"Status::Active
  • "active"Status::Active
  • "Active"Status::Active
  • "pending" → Error (case-sensitive)

Example: --enum-mode preserve

Each JSON value becomes a distinct variant. Colliding names receive 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,
}

Deserialization:

  • "ACTIVE"Status::Active
  • "active"Status::Active1
  • "Active"Status::Active2

Example: --enum-mode relaxed

Generates a custom Deserialize implementation that normalizes input to lowercase before matching.

#[derive(Debug, Clone, Serialize)]
pub enum Status {
    #[serde(rename = "ACTIVE")]
    Active,
    #[serde(rename = "PENDING")]
    Pending,
}

impl<'de> serde::Deserialize<'de> for Status {
    fn deserialize<D>(deserializer: D) -> 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"])),
        }
    }
}

Deserialization:

  • "ACTIVE"Status::Active
  • "active"Status::Active
  • "Active"Status::Active
  • "PENDING"Status::Pending
  • "pending"Status::Pending

Enum Layout

--enum-layout <LAYOUT>

Controls the order in which enum variants are emitted in generated Rust code.

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 to stabilize generated source against spec re-orderings so that [A, B] and [B, A] produce identical Rust code.

The #[default] attribute on Default-deriving enums tracks the variant matching the schema’s default value when one is declared; otherwise it falls back to the first variant in declaration order. Switching from spec to sorted therefore shifts the fallback default variant to whichever name sorts first alphabetically.

Untagged union enums (oneOf/anyOf) handle defaults differently. When the schema declares a default that a variant can represent, the enum gets a hand-written impl Default constructing that value — for a variant wrapping a value enum, down to the selected variant (e.g. Self::Preset(Preset::Auto2k)), otherwise the coerced literal (e.g. Self::String("cheesecake".to_string())). Nullable unions (a null branch) without a usable default get no Default implementation at all, and properties of that type are generated as Option<T> even when required.

Input Schema

{
  "type": "string",
  "enum": ["zeta", "alpha", "gamma", "beta"]
}

Example: --enum-layout spec

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
    #[serde(rename = "zeta")]
    Zeta,
    #[serde(rename = "alpha")]
    Alpha,
    #[serde(rename = "gamma")]
    Gamma,
    #[serde(rename = "beta")]
    Beta,
}

Example: --enum-layout sorted

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Status {
    #[serde(rename = "alpha")]
    Alpha,
    #[serde(rename = "beta")]
    Beta,
    #[serde(rename = "gamma")]
    Gamma,
    #[serde(rename = "zeta")]
    Zeta,
}

Numeric-Backed Enums

When a schema restricts its values with an enum array and a type of integer or number, the generated enum must read and write JSON numbers. serde represents a unit variant by its name, so the derived implementation would write the string "8000" rather than the number 8000. To produce numbers, the generator writes the Serialize and Deserialize implementations itself.

Consider a schema that lists the sample rates an audio API accepts:

{
  "type": "integer",
  "enum": [8000, 16000, 24000, 44100, 48000]
}

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:

#[derive(Debug, Clone, PartialEq, Eq, Hash, oas3_gen_support::Default)]
pub enum SampleRate {
    #[default]
    Value8000,
    /* ... */
    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 JSON number you read and write depends on the schema’s type and format. Signed integers go through i64, unsigned integers through u64, and number schemas through f64. Because JSON has a single number type, the choice only affects the Rust type used internally, not the bytes on the wire.

Floating-Point Values

A number enum is backed by f64. Rust does not let you match on floating-point literals, so the deserializer compares bit patterns instead:

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(/* ... */)),
        }
    }
}

Because every variant is a unit variant, these enums derive Eq and Hash, so you can use them as keys in a HashMap or members of a HashSet, including the floating-point ones.


Helper Methods

--no-helpers

Disables generation of constructor helper methods for enum variants.

By default, the generator emits a helper for each variant that wraps a struct with a Default implementation, so you can construct variants with minimal boilerplate.

Default (helpers enabled)

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContentBlock {
    Text(TextBlock),
    Image(ImageBlock),
    Code(CodeBlock),
}

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()
        })
    }
}

Usage:

let block = ContentBlock::text("Hello, world!".to_string());

With --no-helpers

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContentBlock {
    Text(TextBlock),
    Image(ImageBlock),
    Code(CodeBlock),
}

// No impl block generated

Usage:

let block = ContentBlock::Text(TextBlock {
    text: "Hello, world!".to_string(),
    ..Default::default()
});

OData Support

--odata-support

Enables OData-specific field optionality rules. Fields starting with @odata. become optional even when listed in the schema’s required array.

Enable this for Microsoft Graph and other OData APIs, which declare metadata fields as required but frequently omit them in responses.

Input Schema

{
  "type": "object",
  "properties": {
    "id": { "type": "string" },
    "@odata.type": { "type": "string" },
    "@odata.id": { "type": "string" }
  },
  "required": ["id", "@odata.type", "@odata.id"]
}

Default (OData support disabled)

#[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

#[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>,
}

Deserialization succeeds when OData metadata fields are absent.

Constraints: OData optionality only applies when:

  • Field name starts with @odata.
  • Parent schema has no discriminator
  • Parent schema is not an intersection type

Type Customization

-c, --customize <TYPE=PATH>

Overrides the default type mapping for specific primitive types. The generated code applies the replacement through the serde_with crate’s serde_as attribute.

KeyOpenAPI FormatDefault Type
date_timedate-timechrono::DateTime<Utc>
datedatechrono::NaiveDate
timetimechrono::NaiveTime
durationdurationstd::time::Duration
uuiduuiduuid::Uuid

Repeat the flag to customize several types:

cargo run -- generate types -i spec.json -o types.rs \
  -c date_time=time::OffsetDateTime \
  -c date=time::Date \
  -c uuid=my_crate::CustomUuid

Default (no customization)

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub scheduled_date: chrono::NaiveDate,
}

With -c date_time=time::OffsetDateTime

#[serde_with::serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
    #[serde_as(as = "time::OffsetDateTime")]
    pub created_at: time::OffsetDateTime,
    pub scheduled_date: chrono::NaiveDate,
}

Handling Optional and Array Fields

Customizations automatically wrap in Option<> and Vec<> as needed:

#[serde_with::serde_as]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Schedule {
    #[serde_as(as = "time::OffsetDateTime")]
    pub start: time::OffsetDateTime,

    #[serde_as(as = "Option<time::OffsetDateTime>")]
    pub end: Option<time::OffsetDateTime>,

    #[serde_as(as = "Vec<time::OffsetDateTime>")]
    pub milestones: Vec<time::OffsetDateTime>,

    #[serde_as(as = "Option<Vec<time::OffsetDateTime>>")]
    pub optional_dates: Option<Vec<time::OffsetDateTime>>,
}

Operation Filtering

--only <id1,id2,...>
--exclude <id1,id2,...>

Filters which operations are included in generated client or server code. These flags are mutually exclusive.

--only

Generates code only for the specified operation IDs. All other operations are excluded.

cargo run -- generate client-mod -i petstore.json -o output/ \
  --only listPets,createPet

Generated client:

impl PetStoreClient {
    pub async fn list_pets(&self, request: ListPetsRequest) -> anyhow::Result<ListPetsResponse> {
        /* ... */
    }

    pub async fn create_pet(&self, request: CreatePetRequest) -> anyhow::Result<CreatePetResponse> {
        /* ... */
    }

    // No other methods generated
}

--exclude

Generates code for all operations except the specified IDs.

cargo run -- generate client-mod -i petstore.json -o output/ \
  --exclude deletePet

Generated client:

impl PetStoreClient {
    pub async fn list_pets(&self, ...) -> ... { /* ... */ }
    pub async fn create_pet(&self, ...) -> ... { /* ... */ }
    pub async fn get_pet(&self, ...) -> ... { /* ... */ }
    pub async fn update_pet(&self, ...) -> ... { /* ... */ }
    // delete_pet NOT generated
}

Schema Dependency Resolution

When you filter operations, the generator still includes every schema your selection depends on, resolved transitively:

  1. Collect all schemas referenced by selected operations (parameters, request bodies, responses)
  2. Expand to include all schemas those schemas depend on
  3. Generate only the resulting set of types

Example: If listPets returns Pet[] and Pet contains a Category field, you get both Pet and Category even though you only selected listPets.


Function Name Overrides

--fn-name <ID=NAME>

Overrides the generated client and server method name for a specific operation. The key is the operation’s operationId as written in the spec (falling back to the snake_case operation ID shown by oas3-gen list operations). The custom name is normalized to snake_case. Derived request and response type names follow the override, so renaming listPets to fetch_all_pets also produces FetchAllPetsRequest and FetchAllPetsResponse.

Repeat the flag to rename several operations:

cargo run -- generate client-mod -i petstore.json -o output/ \
  --fn-name listPets=fetch_all_pets \
  --fn-name showPetById=get_pet

Generated client:

impl PetStoreClient {
    pub async fn fetch_all_pets(&self, request: FetchAllPetsRequest) -> anyhow::Result<FetchAllPetsResponse> {
        /* ... */
    }

    pub async fn get_pet(&self, request: GetPetRequest) -> anyhow::Result<GetPetResponse> {
        /* ... */
    }
}

API Name Override

--api-name <NAME>

Overrides the name of the top-level generated API item: the client struct in client/client-mod modes and the server trait in server-mod mode. By default the client struct name is derived from the spec’s info.title (e.g., “Swagger Petstore” becomes SwaggerPetstoreClient, falling back to ApiClient when the title is empty), and the server trait is always named ApiServer. The value is normalized to PascalCase.

cargo run -- generate client-mod -i petstore.json -o output/ --api-name PetStoreClient

Generated client:

#[derive(Debug, Clone)]
pub struct PetStoreClient {
    pub client: Client,
    pub base_url: Url,
}

impl PetStoreClient {
    pub fn new() -> Self { /* ... */ }
}

Generated server (with --api-name PetStoreApi):

pub trait PetStoreApi: Send + Sync {
    /* ... */
}

pub fn router<S>(service: S) -> Router
where
    S: PetStoreApi + Clone + Send + Sync + 'static,
{
    /* ... */
}

Schema Filtering

--all-schemas

By default, you only get the schemas reachable from your selected operations. This flag generates every schema defined in the specification.

Default (reachability filtering)

Given a spec with schemas Pet, Category, Store, Inventory where only Pet and Category are used by any operation:

cargo run -- generate types -i spec.json -o types.rs

Generated: Pet, Category Skipped: Store, Inventory

The generator reports the skipped schemas as orphaned.

With --all-schemas

cargo run -- generate types -i spec.json -o types.rs --all-schemas

Generated: Pet, Category, Store, Inventory

Combining with Operation Filtering

The --all-schemas flag is in the same argument group as --only and --exclude. You cannot combine them directly.

To generate all schemas while filtering operations, generate types separately:

# Generate all types
cargo run -- generate types -i spec.json -o types.rs --all-schemas

# Generate filtered client with only operation-referenced types
cargo run -- generate client -i spec.json -o client.rs --only listPets

Header Emission

--all-headers

By default, you only get header constants for headers that appear as parameters in your selected operations. Set --all-headers to also emit constants for every header parameter defined in components/parameters, even ones that no operation references.

Default (operation-referenced headers only)

Given a spec with x-api-version used in an operation and x-api-key defined in components/parameters but not referenced by any operation:

cargo run -- generate types -i spec.json -o types.rs

Generated:

pub const X_API_VERSION: http::HeaderName = http::HeaderName::from_static("x-api-version");

x-api-key is not emitted because no operation uses it.

With --all-headers

cargo run -- generate types -i spec.json -o types.rs --all-headers

Generated:

pub const X_API_VERSION: http::HeaderName = http::HeaderName::from_static("x-api-version");
pub const X_API_KEY: http::HeaderName = http::HeaderName::from_static("x-api-key");

Builder Generation

--enable-builders

Enables bon::Builder derives on schema structs and #[builder] constructor methods on request structs. Builders are disabled by default; without this flag the generated code contains no bon attributes.

See Builder Pattern for a walkthrough of the generated builder API.

Default (builders disabled)

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pet {
    pub id: i64,
    pub name: String,
}

pub struct CreatePetRequest {
    pub path: CreatePetRequestPath,
}

With --enable-builders

#[derive(Debug, Clone, Serialize, Deserialize, bon::Builder)]
pub struct Pet {
    pub id: i64,
    pub name: String,
}

pub struct CreatePetRequest {
    pub path: CreatePetRequestPath,
}

#[bon::bon]
impl CreatePetRequest {
    #[builder]
    pub fn new(/* params */) -> anyhow::Result<Self> {
        /* ... */
    }
}

Ordering and Collections

--no-ordered-collections

Generated schemas, fields, enum variants, union variants, operations, and header constants follow the order written in the OpenAPI document. Map types from additionalProperties resolve to indexmap::IndexMap<String, T>, and arrays with uniqueItems: true resolve to indexmap::IndexSet<T>, so your runtime collections preserve insertion order. If you use the generated map or unique-array types, add indexmap to your Cargo.toml.

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, so uniqueness is no longer expressed at the type level. 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.


Documentation Formatting

--doc-format

Enables formatting of generated documentation comments using the external mdformat CLI tool. When enabled, documentation text from OpenAPI description and summary fields is piped through mdformat with line wrapping at 100 characters.

This requires mdformat to be installed and available on your PATH:

pip install mdformat

Default (formatting disabled)

Documentation text is passed through as-is from the OpenAPI specification, with only escaped newline normalization applied.

/// A long description that may contain very long lines that extend well beyond typical line widths because the OpenAPI spec author did not wrap them.
pub struct Widget {
    pub id: i64,
}

With --doc-format

cargo run -- generate types -i spec.json -o types.rs --doc-format

Documentation text is reformatted with consistent line wrapping:

/// A long description that may contain very long lines that extend well beyond
/// typical line widths because the OpenAPI spec author did not wrap them.
pub struct Widget {
    pub id: i64,
}

Flag Summary

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 type mapping; 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 doc comments with mdformat
--only(none)Include only specified operations
--exclude(none)Exclude specified operations
--all-schemasfalseGenerate all schemas regardless of usage

Builder Pattern with bon

Generated Rust types from OpenAPI schemas tend to have many fields. Some are required, some are optional, and some have defaults that only matter during deserialization. When constructing these types by hand in application code, struct literal syntax can become verbose and error-prone fast.

Consider a Pet with four fields:

let pet = Pet {
    id: 42,
    name: "Whiskers".to_string(),
    tag: None,
    allergies: None,
};

That’s manageable. Now consider a request struct with path parameters, query parameters, and headers that all need to be slotted into nested sub-structs:

let request = ListPetsRequest {
    path: ListPetsRequestPath {
        api_version: "v2".to_string(),
    },
    query: ListPetsRequestQuery {
        limit: Some(25),
    },
    header: ListPetsRequestHeader {
        x_sort_order: None,
        x_only: None,
    },
};

That is six lines of ceremony to express two meaningful values. The nested struct names are long, the optional fields are noise, and the compiler will not catch a missing field until the developer adds it. As schemas grow, this pattern scales poorly.

The --enable-builders flag solves this by integrating the bon crate into the generated code. bon is a compile-time builder generator that uses the typestate pattern to ensure all required fields are set before construction, with zero runtime cost. It turns the example above into:

let request = ListPetsRequest::builder()
    .api_version("v2".to_string())
    .limit(25)
    .build()?;

Three lines. No nested structs. No None assignments. Required fields are enforced at compile time, and optional fields can simply be omitted.


Enabling Builders

Pass the --enable-builders flag during code generation:

oas3-gen generate client-mod -i api.json -o src/api/ --enable-builders

This works with all generation modes: types, client, client-mod, and server-mod.

Adding bon to Your Project

The generated code references bon macros and derives, so the crate must be present in the consuming project’s Cargo.toml:

[dependencies]
bon = "3.8"

Without this dependency, the generated code will fail to compile with unresolved import errors. The bon crate is lightweight and has no runtime dependencies beyond proc-macro2 and syn, which most Rust projects already pull in transitively.

Tip: If the project already uses bon for its own types, there is nothing extra to add. The generated code uses the same bon::Builder derive and #[builder] attribute that any hand-written bon usage would.


What Changes in the Generated Code

Enabling builders affects two categories of generated types: schema structs and request structs. Each gets a different integration point with bon.

Schema Structs

Schema structs (types generated from components/schemas) receive a bon::Builder derive. This adds a ::builder() associated function that returns a type-safe builder.

Without --enable-builders:

#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct Pet {
    pub id: i64,
    pub name: String,
    pub tag: Option<String>,
    pub allergies: Option<Box<Health>>,
}

With --enable-builders:

#[derive(Debug, Clone, PartialEq, Deserialize, bon::Builder)]
pub struct Pet {
    pub id: i64,
    pub name: String,
    pub tag: Option<String>,
    pub allergies: Option<Box<Health>>,
}

The struct definition itself is identical except for the extra derive. The difference shows up at the call site:

// Struct literal (always available)
let pet = Pet {
    id: 42,
    name: "Whiskers".to_string(),
    tag: Some("indoor".to_string()),
    allergies: None,
};

// Builder (available with --enable-builders)
let pet = Pet::builder()
    .id(42)
    .name("Whiskers".to_string())
    .tag("indoor".to_string())
    .build();

Notice that tag accepts a plain String rather than Option<String>. The builder treats Option fields as optional setters: calling .tag(...) wraps the value in Some automatically, and omitting the call leaves it as None. The same applies to allergies, which can simply be left off when not needed.

Request Structs

Request structs benefit from builders in a more dramatic way. These types contain nested sub-structs for path parameters, query parameters, and headers. Without builders, constructing a request means manually assembling each nested struct.

When --enable-builders is active, the generator produces a #[builder] constructor method that flattens all parameters into a single builder interface. The nested structs are assembled internally.

Without --enable-builders:

pub struct ListPetsRequest {
    pub path: ListPetsRequestPath,
    pub query: ListPetsRequestQuery,
    pub header: ListPetsRequestHeader,
}

// Construction requires knowledge of internal structure
let request = ListPetsRequest {
    path: ListPetsRequestPath {
        api_version: "v2".to_string(),
    },
    query: ListPetsRequestQuery {
        limit: Some(25),
    },
    header: ListPetsRequestHeader {
        x_sort_order: Some(ListCatsRequestHeaderXSortOrder::Asc),
        x_only: None,
    },
};

With --enable-builders:

pub struct ListPetsRequest {
    pub path: ListPetsRequestPath,
    pub query: ListPetsRequestQuery,
    pub header: ListPetsRequestHeader,
}

#[bon::bon]
impl ListPetsRequest {
    #[builder]
    pub fn new(
        api_version: String,
        limit: Option<i32>,
        x_sort_order: Option<ListCatsRequestHeaderXSortOrder>,
        x_only: Option<Vec<ListPetsRequestHeaderXonly>>,
    ) -> anyhow::Result<Self> {
        let request = Self {
            path: ListPetsRequestPath { api_version },
            query: ListPetsRequestQuery { limit },
            header: ListPetsRequestHeader {
                x_sort_order,
                x_only,
            },
        };
        request.validate()?;
        Ok(request)
    }
}

// Construction is flat and ergonomic
let request = ListPetsRequest::builder()
    .api_version("v2".to_string())
    .limit(25)
    .x_sort_order(ListCatsRequestHeaderXSortOrder::Asc)
    .build()?;

Several things are worth noting here:

  • Flat parameter list. Path, query, and header parameters are all promoted to top-level builder setters. There is no need to know which sub-struct a parameter belongs to.
  • Optional fields omitted. x_only is not set, so it defaults to None. No explicit None assignment required.
  • Validation included. The builder’s build() call runs the same validator::Validate checks that would normally need to be invoked manually. If a required field violates a constraint (for example, a string shorter than its minimum length), the builder returns an error.

A Side-by-Side Comparison

To see the full impact, consider a ShowPetByIdRequest that takes a path parameter and a required header:

Without Builders

let request = ShowPetByIdRequest {
    path: ShowPetByIdRequestPath {
        pet_id: "pet-123".to_string(),
    },
    header: ShowPetByIdRequestHeader {
        x_api_version: "2024-01-01".to_string(),
    },
};
request.validate()?;

The developer must remember to call .validate() separately. Forgetting it means constraints like minimum string length go unchecked at runtime.

With Builders

let request = ShowPetByIdRequest::builder()
    .pet_id("pet-123".to_string())
    .x_api_version("2024-01-01".to_string())
    .build()?;

Validation is automatic. The required fields pet_id and x_api_version are enforced at compile time by the builder’s typestate. Calling .build() without setting them is a compilation error.


When Builders Shine

Builders are particularly valuable in a few common scenarios:

Testing. Test code often constructs many variations of the same struct. Builders reduce the noise so the meaningful differences stand out:

#[test]
fn test_pet_with_tag() {
    let pet = Pet::builder()
        .id(1)
        .name("Buddy".to_string())
        .tag("dog".to_string())
        .build();

    assert_eq!(pet.tag, Some("dog".to_string()));
}

#[test]
fn test_pet_without_tag() {
    let pet = Pet::builder()
        .id(2)
        .name("Mittens".to_string())
        .build();

    assert_eq!(pet.tag, None);
}

Large schemas. Some OpenAPI specifications define schemas with dozens of fields. Struct literals for these types become walls of field: None lines. Builders let the developer set only what matters and leave the rest at their defaults.

Prototyping and iteration. When a schema is still evolving, adding a new optional field does not break existing builder call sites. Struct literals, on the other hand, require updating every construction site to include the new field.


Combining with Other Flags

The --enable-builders flag composes freely with other code generation options:

oas3-gen generate client-mod -i api.json -o src/api/ \
    --enable-builders \
    --visibility crate \
    --enum-mode relaxed \
    -c date_time=time::OffsetDateTime

Builders respect the chosen visibility level. With --visibility crate, the generated builder methods and derives remain accessible within the crate but are not part of the public API.


Trade-offs

Every dependency is a trade-off, and bon is no exception. Here is what to consider:

ConsiderationDetails
Compile timebon is a proc-macro crate that adds to compilation. For most projects this is negligible, but very large generated files with hundreds of structs may see a measurable increase.
IDE supportBuilder methods are generated by macros, so some IDEs may not auto-complete them until the project is built once. After that, rust-analyzer picks them up normally.
Struct literals still workEnabling builders does not remove the ability to construct types with struct literal syntax. Both approaches coexist, and developers can mix them freely.

For projects that want the lightest possible generated output with zero extra dependencies, leaving --enable-builders off is the right call. For projects that prioritize developer ergonomics and are already comfortable with proc-macro dependencies, builders pay for themselves quickly in reduced boilerplate and fewer construction errors.