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

Introduction

openapi-trait reads an OpenAPI specification at compile time and generates Rust traits from it. You write the business logic by implementing the trait; the macro handles the types, routing, (de)serialization and HTTP plumbing.

#[openapi_trait::axum("openapi/pets.yaml")]
pub mod pets {}   // ← filled with models, request/response types and `PetsApi`

#[openapi_trait::client("openapi/pets.yaml")]
pub mod pets_client {}   // ← models plus a `PetsClientClient` trait

Why traits?

Most OpenAPI generators emit a pile of source files you check in and regenerate by hand. openapi-trait takes a different approach:

  • The spec is the source of truth. Code is generated on every build, so it can never drift from the YAML. Edit the spec and the compiler tells you exactly which handlers need updating.
  • The compiler checks the contract. Every operation becomes a trait method with typed request and response values. You cannot return a status code or body that the spec does not declare.
  • Nothing to check in. No build.rs, no generated directory. The macro is a single attribute on an empty mod.
  • Clients are swappable. The client side is a plain trait too, so the same calling code works against the generated reqwest implementation, a test double, or any transport you write yourself.

Where to go next

All code in this guide is taken from the openapi-trait/examples directory, which is compiled in CI, so it matches the current release.

Getting started

Install

[dependencies]
openapi-trait = "1"

# For servers:
axum = "0.8"
tokio = { version = "1", features = ["full"] }

The reqwest-client feature is on by default. See Crates and features for the full list.

The example spec

The chapters in this guide use a small spec with three operations:

OperationMethod and pathAuth
listPetsGET /pets?limit=none
createPetPOST /petsbearer
getPetGET /pets/{petId}bearer
Show pets.openapi.yaml
openapi: 3.0.3
info:
  title: Pets
  version: 1.0.0
  description: Minimal spec used by the examples and the guide.
security:
  - bearerAuth: []
paths:
  /pets:
    get:
      operationId: listPets
      security: []
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            format: int32
      responses:
        "200":
          description: All pets, up to `limit`.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Pet'
    post:
      operationId: createPet
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NewPet'
      responses:
        "201":
          description: The created pet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        "401":
          description: Missing or invalid token.
  /pets/{petId}:
    get:
      operationId: getPet
      parameters:
        - name: petId
          in: path
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "200":
          description: The pet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pet'
        "404":
          description: No pet with that id.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
  schemas:
    PetStatus:
      type: string
      enum: [available, pending, sold]
    NewPet:
      type: object
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 64
        status:
          $ref: '#/components/schemas/PetStatus'
    Pet:
      type: object
      required: [id, name]
      properties:
        id:
          type: integer
          format: int64
          minimum: 1
        name:
          type: string
          minLength: 1
          maxLength: 64
        status:
          $ref: '#/components/schemas/PetStatus'

Apply the macro

Put the attribute on an empty module:

#[openapi_trait::axum("examples/pets.openapi.yaml")]
pub mod pets {}
  • The path is relative to your crate root (CARGO_MANIFEST_DIR), not to the source file.
  • The spec is tracked by Cargo. Editing the YAML recompiles the crate.
  • YAML and JSON specs are both accepted.
  • Every operation needs an operationId. It becomes the method name (getPet → get_pet).
  • Want F12 to open the generated code? Add source_file: #[openapi_trait::axum("pets.openapi.yaml", source_file)]. See Go-to-definition in your IDE.

Names come from the module

Trait names come from the module name in PascalCase:

ModuleServer traitClient traitClient auth state
mod pets {}pets::PetsApipets::PetsClientpets::PetsAuthState
mod petstore {}petstore::PetstoreApipetstore::PetstoreClientpetstore::PetstoreAuthState

Types inside the module are named after the spec: components/schemas/Pet becomes pets::Pet, and operation getPet becomes pets::get_pet::Request and pets::get_pet::Response.

Next steps

Implementing a server

#[openapi_trait::axum(...)] generates a {Mod}Api trait with one async method per operation, plus a router() method that wires it to an axum::Router.

#[openapi_trait::axum("examples/pets.openapi.yaml")]
pub mod pets {}

Implement the trait

impl pets::PetsApi for PetServer {
    type Error = pets::NotImplemented;

    async fn list_pets(
        &self,
        ctx: pets::list_pets::Context,
    ) -> Result<pets::list_pets::Response, Self::Error> {
        let pets::list_pets::Context { req, .. } = ctx;
        let store = self.pets.lock().unwrap();
        let limit = req
            .limit
            .map_or(usize::MAX, |n| usize::try_from(n).unwrap_or(0));
        Ok(pets::list_pets::Response::Status200(
            store.iter().take(limit).cloned().collect(),
        ))
    }

    async fn create_pet(
        &self,
        ctx: pets::create_pet::Context,
    ) -> Result<pets::create_pet::Response, Self::Error> {
        let pets::create_pet::Context { req, auth, .. } = ctx;
        // The macro only extracts the token; checking it is up to you.
        if auth.0 != "secret" {
            return Ok(pets::create_pet::Response::Status401);
        }
        let mut store = self.pets.lock().unwrap();
        let pet = pets::Pet {
            id: i64::try_from(store.len()).unwrap_or(i64::MAX) + 1,
            name: req.body.name,
            status: req.body.status,
        };
        store.push(pet.clone());
        drop(store);
        Ok(pets::create_pet::Response::Status201(pet))
    }

    async fn get_pet(
        &self,
        ctx: pets::get_pet::Context,
    ) -> Result<pets::get_pet::Response, Self::Error> {
        let pets::get_pet::Context { req, .. } = ctx;
        let store = self.pets.lock().unwrap();
        Ok(store
            .iter()
            .find(|pet| pet.id == req.pet_id)
            .cloned()
            .map_or(
                pets::get_pet::Response::Status404,
                pets::get_pet::Response::Status200,
            ))
    }
}

Each method receives one argument, the operation’s {operation}::Context (see below), and returns Result<{operation}::Response, Self::Error>. The response enum has one variant per declared status code (Status200(Pet), Status404, …), and each variant implements IntoResponse with the matching status and body.

A default response becomes a Default variant that carries its own status, so one response can describe every error. If getPet declared a default response with an Error schema as its body:

Ok(get_pet::Response::Default {
    status: openapi_trait::http::StatusCode::UNPROCESSABLE_ENTITY,
    body: pets::Error { code: 422, message: "bad id".into() },
})

openapi_trait::http::StatusCode is the same type as axum::http::StatusCode. Response headers declared on default are fields of the variant, next to body.

The handler’s context

{operation}::Context holds everything the router extracted for one request:

FieldTypePresent
req{operation}::Request: path, query and header parameters as typed fields, and the request body as bodyalways
auththe credentials, see Securityonly if the operation is secured
headersaxum::http::HeaderMap, for anything the spec doesn’t modelalways

Spec parameters always live in req, never on the context itself, so a parameter called auth or headers can’t clash with these fields.

There are several ways to get at the fields. They all compile to the same thing, so pick whichever reads best for the handler.

Field access

Read and move fields straight off ctx. Moving one field out, such as the body, leaves the others usable:

async fn create_pet(
    &self,
    ctx: pets::create_pet::Context,
) -> Result<pets::create_pet::Response, Self::Error> {
    let body = ctx.req.body;                     // moves the body out
    let trace = ctx.headers.get("x-trace-id");   // other fields still work
    if ctx.auth.0 != "secret" { /* … */ }
    // …
}

Destructuring in the body

Name only the fields you need and end the pattern with ..:

let pets::create_pet::Context { req, auth, .. } = ctx;

Destructuring in the signature

A pattern can replace the argument name. This keeps the body free of unpacking, at the cost of a longer signature:

async fn get_pet(
    &self,
    pets::get_pet::Context { req, .. }: pets::get_pet::Context,
) -> Result<pets::get_pet::Response, Self::Error> {
    // `req` is in scope here
}

Nested destructuring

Patterns nest, so you can unpack the request’s parameters in the same step:

let pets::list_pets::Context {
    req: pets::list_pets::Request { limit, .. },
    headers,
    ..
} = ctx;

Request has no private field, so .. is optional inside it. Leave it in anyway: without it, the pattern breaks when the spec gains a parameter.

Why .. is required

Context has a private field. Outside the generated module, a pattern without .. is rejected (pattern requires .. due to inaccessible fields), and so is a struct literal. That’s deliberate: it lets new fields, such as the request URI, be added to Context without breaking your handlers.

No Deref to the request

Context doesn’t implement Deref<Target = Request>, so you write ctx.req.pet_id, not ctx.pet_id. Deref would save typing .req when reading a field, but nothing else:

With DerefResult
ctx.pet_id (read a Copy field)works
&ctx.body (borrow)works
let body = ctx.body; (move)error: cannot move out of a dereference
let Context { pet_id, .. } = ctx; (pattern)error: patterns never look through Deref
ctx.headers when the spec has a headers parametercompiles, but silently reads the context’s field, not the parameter

The last row is the reason it’s left out: Deref would let a spec parameter be shadowed without any warning. With ctx.req.… it’s always clear which one you mean.

Calling a handler directly

To call a handler without going through HTTP, for example in a unit test, build a context with Context::new. It takes the request, then the credentials (only for secured operations), then the headers:

let ctx = pets::get_pet::Context::new(
    pets::get_pet::Request { pet_id: 1 },
    pets::BearerAuth("secret".into()),
    axum::http::HeaderMap::new(),
);
let response = PetServer::default().get_pet(ctx).await?;

Call it through the trait (use pets::PetsApi as _;). The method has no inherent version.

Application state

Keep application state in the type that implements the trait, and reach it through &self. The router wraps the implementation in an Arc, so it is shared, not cloned, per request.

/// The type that implements the generated API trait. Application state lives
/// in its fields; every handler reaches it through `&self`.
#[derive(Clone, Default)]
struct PetServer {
    /// In-memory pet storage.
    pets: Arc<Mutex<Vec<pets::Pet>>>,
}

Start the router

#[tokio::main]
async fn main() {
    use pets::PetsApi as _;

    let app: axum::Router = PetServer::default().router();
    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();
    println!("listening on http://{}", listener.local_addr().unwrap());
    axum::serve(listener, app).await.unwrap();
}

router() returns a plain axum::Router, so you can .merge() it, .nest() it, or add tower layers like any other router. To combine it with routes that use axum state, convert it with .with_state(()), which yields a Router<S> for any S:

let app = axum::Router::new()
    .route("/health", get(health))
    .merge(PetServer::default().router().with_state(()))
    .with_state(app_state);

Unimplemented operations and errors

Every trait method has a default body that returns Err(NotImplemented.into()). You can implement operations one at a time; routes you haven’t implemented yet answer 500 not implemented.

Self::Error must implement axum::response::IntoResponse and From<pets::NotImplemented>. Using pets::NotImplemented itself is the simplest choice. For real error handling, use your own type:

enum AppError {
    NotImplemented,
    Database(sqlx::Error),
}

impl From<pets::NotImplemented> for AppError {
    fn from(_: pets::NotImplemented) -> Self { Self::NotImplemented }
}

impl axum::response::IntoResponse for AppError {
    fn into_response(self) -> axum::response::Response {
        // map to a status code and body of your choice
        todo!()
    }
}

An Err is for failures outside the contract. Responses the spec declares, such as 404, belong in the Ok variant (Ok(get_pet::Response::Status404)).

Binary bodies

When an operation takes or returns application/octet-stream (or another binary media type), the body is a streamed axum::body::Body rather than a Vec<u8>, so large uploads and downloads don’t have to fit in memory. See OpenAPI support.

Writing a client

#[openapi_trait::client(...)] generates the same models as the server macro, plus a transport-agnostic {Mod}Client trait:

#[openapi_trait::client("examples/pets.openapi.yaml")]
pub mod pets {}

You can implement that trait yourself, or use the generated reqwest implementation.

Implementing the trait yourself

Every method takes the operation’s request plus an Option<openapi_trait::RequestOptions> and returns the operation’s response enum:

/// An in-memory stand-in for the real API.
struct FakePets;

impl pets::PetsClient for FakePets {
    type Error = std::convert::Infallible;

    async fn list_pets(
        &self,
        _req: pets::list_pets::Request,
        _options: Option<openapi_trait::RequestOptions>,
    ) -> Result<pets::list_pets::Response, Self::Error> {
        Ok(pets::list_pets::Response::Status200(vec![]))
    }

    async fn create_pet(
        &self,
        req: pets::create_pet::Request,
        _options: Option<openapi_trait::RequestOptions>,
    ) -> Result<pets::create_pet::Response, Self::Error> {
        Ok(pets::create_pet::Response::Status201(pets::Pet {
            id: 1,
            name: req.body.name,
            status: req.body.status,
        }))
    }

    async fn get_pet(
        &self,
        _req: pets::get_pet::Request,
        _options: Option<openapi_trait::RequestOptions>,
    ) -> Result<pets::get_pet::Response, Self::Error> {
        Ok(pets::get_pet::Response::Status404)
    }
}

Code that depends on the trait doesn’t care which implementation it gets, so fakes like this one make good test doubles:

/// Code written against the trait works with any implementation.
async fn pet_name<C: pets::PetsClient>(client: &C, pet_id: i64) -> Option<String> {
    match client
        .get_pet(pets::get_pet::Request { pet_id }, None)
        .await
    {
        Ok(pets::get_pet::Response::Status200(pet)) => Some(pet.name),
        _ => None,
    }
}

Unlike the server trait, client methods have no default bodies. You implement every operation.

The reqwest client

With the default reqwest-client feature, the macro also generates a blanket {Mod}Client implementation. Any type can use it by deriving openapi_trait::ReqwestClient:

/// Holds everything the generated client implementation needs.
#[derive(Clone, openapi_trait::ReqwestClient)]
struct PetsHttpClient {
    /// The HTTP client used for every request.
    #[openapi_trait(client)]
    http: openapi_trait::reqwest::Client,
    /// Base URL that operation paths are appended to.
    #[openapi_trait(base_url)]
    base_url: String,
    /// Credentials for the spec's security schemes.
    #[openapi_trait(auth)]
    auth: pets::PetsAuthState,
}
Field attributeField typeRequired
#[openapi_trait(client)]openapi_trait::reqwest::Clientyes
#[openapi_trait(base_url)]Stringyes
#[openapi_trait(auth)]{Mod}AuthStatewhen the spec declares security schemes

A field named auth is picked up without the attribute.

Then call operations through the trait:

    use pets::PetsClient as _;
    use pets::PetsClientAuth as _;

    let client = PetsHttpClient {
        http: openapi_trait::reqwest::Client::new(),
        base_url: "http://127.0.0.1:3000".into(),
        auth: pets::PetsAuthState::default(),
    }
    .with_bearer_auth("secret");

    let created = client
        .create_pet(
            pets::create_pet::Request {
                body: pets::NewPet {
                    name: "doggie".into(),
                    status: Some(pets::PetStatus::Available),
                },
            },
            None,
        )
        .await?;
    println!("created: {created:?}");

    let all = client
        .list_pets(pets::list_pets::Request { limit: Some(10) }, None)
        .await?;
    println!("listed: {all:?}");

The generated implementation returns Reqwest{Mod}ClientError, an enum covering transport errors (Transport), response bodies that don’t parse as the declared type (Decode, with the serde_json error as its source), missing credentials, malformed response headers, and status codes the spec doesn’t declare (UnexpectedStatus, which includes the body). When the operation has a default response, an undeclared status isn’t an error: it’s decoded into Response::Default { status, body, … }.

The enum is #[non_exhaustive], so variants can be added without a breaking change. That only matters for crates that depend on the one containing the generated module: inside it, a match can still list every variant.

Per-request headers and auth

Pass Some(RequestOptions) to add headers or override auth for a single call without changing the client:

    let pet = client
        .get_pet(
            pets::get_pet::Request { pet_id: 1 },
            Some(
                openapi_trait::RequestOptions::new()
                    .bearer_auth("another-token")
                    .header("X-Request-Id", "abc-789"),
            ),
        )
        .await?;

RequestOptions provides header(name, value), bearer_auth(token) and basic_auth(username, Option<password>). Options are applied after the operation’s declared headers and credentials:

  • bearer_auth and basic_auth replace any Authorization header set by the client’s security scheme. They also satisfy the credential check, so a call can succeed even if the client has no stored credentials.
  • header appends to the request.

Binary downloads

Operations that return a binary body give you an openapi_trait::ByteStream, a futures_core::Stream of bytes::Bytes chunks. Read it incrementally instead of buffering the whole response.

Without reqwest

To generate only the transport-agnostic trait and drop the reqwest dependency:

[dependencies]
openapi-trait = { version = "1", default-features = false }

Security

Every scheme in components.securitySchemes gets a typed credential struct, named after the scheme key in PascalCase:

SchemeGenerated type
apiKey (in header, query or cookie)pub struct ApiKey(pub String)
http with scheme: bearerpub struct BearerAuth(pub String)
http with scheme: basicpub struct BasicAuth { pub username: String, pub password: String }
oauth2, openIdConnectpub struct OAuth(pub String), the access token sent as Authorization: Bearer <token>

These rules decide which operations require credentials:

  • Document-level security is the default for every operation.
  • An operation’s own security replaces that default.
  • security: [] turns auth off for an operation (listPets in the example spec).
  • If an operation lists several alternatives (an OR), the macro generates an {operation}::Auth enum with one variant per scheme.
  • An empty requirement (security: [{}, {bearerAuth: []}]) makes auth optional: the server passes Option<…> to the handler, with None for an anonymous request, instead of returning 401, and the client sends a credential if one is configured but doesn’t fail without one.
  • An operation that requires an unsupported scheme (such as http with scheme: digest, or mutualTLS) or a scheme that isn’t declared in components.securitySchemes is a compile error, so it is never generated without auth. Unsupported schemes that no operation requires are ignored.

Server side

Secured handlers find the credentials in ctx.auth. The macro extracts the raw credential and returns 401 Unauthorized if it is missing, before your handler runs. Checking that the credential is valid is your job. For oauth2 and openIdConnect that includes checking the token’s scopes against the ones the operation lists:

impl pets::PetsApi for PetServer {
    type Error = pets::NotImplemented;

    async fn list_pets(
        &self,
        ctx: pets::list_pets::Context,
    ) -> Result<pets::list_pets::Response, Self::Error> {
        let pets::list_pets::Context { req, .. } = ctx;
        let store = self.pets.lock().unwrap();
        let limit = req
            .limit
            .map_or(usize::MAX, |n| usize::try_from(n).unwrap_or(0));
        Ok(pets::list_pets::Response::Status200(
            store.iter().take(limit).cloned().collect(),
        ))
    }

    async fn create_pet(
        &self,
        ctx: pets::create_pet::Context,
    ) -> Result<pets::create_pet::Response, Self::Error> {
        let pets::create_pet::Context { req, auth, .. } = ctx;
        // The macro only extracts the token; checking it is up to you.
        if auth.0 != "secret" {
            return Ok(pets::create_pet::Response::Status401);
        }
        let mut store = self.pets.lock().unwrap();
        let pet = pets::Pet {
            id: i64::try_from(store.len()).unwrap_or(i64::MAX) + 1,
            name: req.body.name,
            status: req.body.status,
        };
        store.push(pet.clone());
        drop(store);
        Ok(pets::create_pet::Response::Status201(pet))
    }

    async fn get_pet(
        &self,
        ctx: pets::get_pet::Context,
    ) -> Result<pets::get_pet::Response, Self::Error> {
        let pets::get_pet::Context { req, .. } = ctx;
        let store = self.pets.lock().unwrap();
        Ok(store
            .iter()
            .find(|pet| pet.id == req.pet_id)
            .cloned()
            .map_or(
                pets::get_pet::Response::Status404,
                pets::get_pet::Response::Status200,
            ))
    }
}

With alternatives, match on the generated enum:

async fn get_flex(
    &self,
    ctx: api::get_flex::Context,
) -> Result<api::get_flex::Response, Self::Error> {
    let who = match ctx.auth {
        api::get_flex::Auth::BearerAuth(token) => token.0,
        api::get_flex::Auth::BasicAuth(basic) => basic.username,
    };
    // ...
}

Client side

Clients keep credentials for the whole session. The reqwest carrier holds a {Mod}AuthState, and the {Mod}ClientAuth extension trait adds a with_<scheme>(…) setter for each scheme:

/// Holds everything the generated client implementation needs.
#[derive(Clone, openapi_trait::ReqwestClient)]
struct PetsHttpClient {
    /// The HTTP client used for every request.
    #[openapi_trait(client)]
    http: openapi_trait::reqwest::Client,
    /// Base URL that operation paths are appended to.
    #[openapi_trait(base_url)]
    base_url: String,
    /// Credentials for the spec's security schemes.
    #[openapi_trait(auth)]
    auth: pets::PetsAuthState,
}
use pets::PetsClientAuth as _;

let client = PetsHttpClient { /* … */ }.with_bearer_auth("secret");

The client attaches the right credential to every secured call. If none of an operation’s schemes has a credential, the call fails with MissingCredential before anything is sent. To use different credentials for one call, see per-request headers and auth.

Why the server and client differ

On the server, credentials arrive with each request, so authorization can depend on the caller. On the client, credentials normally belong to the session, so they are set once on the client.

Validation

The optional validation feature makes generated models derive serde_valid::Validate, with field attributes taken from the schema’s constraints.

[dependencies]
openapi-trait = { version = "1", features = ["validation"] }
serde_valid = "2"
use openapi_trait::serde_valid::Validate as _;

fn main() {
    let ok = pets::NewPet {
        name: "doggie".into(),
        status: None,
    };
    assert!(ok.validate().is_ok());

    // `name` has `minLength: 1` in the spec.
    let empty = pets::NewPet {
        name: String::new(),
        status: None,
    };
    let err = empty.validate().unwrap_err();
    println!("{err}");
}

Unlike serde and serde_json, serde_valid must be a direct dependency of your crate. Its Validate derive expands to paths that start with ::serde_valid, and it has no option to point them elsewhere. Without the dependency, the build fails with “cannot find serde_valid in the list of imported crates”. Use major version 2, the same as openapi-trait, so cargo resolves both to one copy. openapi_trait::serde_valid is then the same crate, so either path works for importing Validate.

Constraint mapping

OpenAPI keywordserde_valid attribute
minLength / maxLength / patternmin_length / max_length / pattern
minimum / maximum (+ exclusiveMinimum / exclusiveMaximum)minimum / maximum (or exclusive_minimum / exclusive_maximum)
multipleOfmultiple_of
minItems / maxItems / uniqueItemsmin_items / max_items / unique_items
$ref / inline object / oneOf·anyOf·allOf fieldbare #[validate] (recurses into the nested model)

Opt-in and non-enforcing

  • Nothing calls .validate() for you. Neither the server nor the client rejects invalid values automatically. Call it in your handlers or before sending.
  • Feature off, no change. Without the feature, the generated code is exactly the same as before.
  • Optional recursive fields aren’t checked. serde_valid can’t recurse into Option<Box<T>>, so the optional field that closes a cycle in a recursive schema gets no #[validate].
  • Type aliases can’t be validated directly. A top-level schema that becomes an alias (for example pub type Name = String;) is only checked where it appears as a struct field.

Generated code

For a module mod pets {}, each macro fills the module with the following items:

ItemGenerated fromaxumclient
Structs and enums with serde derivescomponents/schemas✅✅
A module per operation, named like its method (get_pet)operations✅✅
{operation}::Request structpath, query and header parameters, plus body✅✅
{operation}::Response enum, one Status{code} variant per response, plus Default { status, … } for defaultresponses✅✅
impl IntoResponse for each response enum✅
Credential structs (BearerAuth, ApiKey, …)securitySchemes✅✅
{operation}::Auth enum, for operations that accept several schemessecurity✅✅
PetsApi trait with a router() methodoperations✅
NotImplemented marker error✅
PetsClient traitoperations✅
PetsAuthState, PetsClientAuth, ReqwestPetsClientError, and the blanket reqwest implsecurity schemes, operations✅ (with reqwest-client)

Each operation’s types live in its own module, so they can’t clash with schemas or with each other: operation createPet gets pets::create_pet::Request even if the spec also has a CreatePetRequest schema. The module holds:

ItemWhen
Request, Responsealways
Context (the handler’s argument: req, auth, headers)always, server only
Auththe operation accepts more than one security scheme
Status{code} payload struct (body plus headers)the response declares headers
Body, Status{code}Body, DefaultBodythe request or response body schema is inline
{Param}Path, {Param}Query, {Param}Header enumsa path, query or header parameter (or the items of an array query or header parameter) is an inline enum. A parameter that $refs a component enum uses the component’s type instead

Schema types derive Debug, Clone, PartialEq, Serialize and Deserialize. String enums (component schemas and parameter enums) also derive Copy, Eq and Hash, so they can be used as map keys, and implement Display, which writes the wire value ("rounded-square"). Structs don’t derive Eq, because a field may be a float.

Naming rules

  • Types use PascalCase of the schema key (pet_status → PetStatus).

  • Methods and fields use snake_case (getPet → get_pet, petId → pet_id). The original name is kept with #[serde(rename = "…")].

  • Reserved words and invalid identifiers are escaped, so type becomes r#type. Type names that can’t be Rust identifiers get a _ prefix (2FAConfig → _2faConfig, Self → _Self).

  • Enum variants are the PascalCase of the value, with a V prefix when the value starts with a digit or is a keyword ("1.0" → V1_0, "self" → VSelf) and Empty for "". Values that map to the same name get a numeric suffix (created_at, createdAt → CreatedAt, CreatedAt2). The wire value is always kept with #[serde(rename = "…")].

  • Inline schemas get a type named after where they appear. A nested object or composition in a property becomes {ParentStruct}{Property}, at the top level. Inline bodies and parameter enums go into the operation’s module (see above), and their nested types are named the same way (create_pet::BodyOwner).

    If that name is already taken in the same module, a numeric suffix is added: with a PersonAddress schema, Person.address becomes PersonAddress2.

The best way to see what a spec produces is to dump the generated code, or to add source_file and jump into the code from your IDE.

OpenAPI support

openapi-trait reads OpenAPI 3.0 and 3.1 documents in YAML or JSON. Swagger (OpenAPI 2.0) documents are rejected with a compile error; convert them to OpenAPI 3 first.

Warnings and errors

When the macro can’t represent part of a spec, it tells you at compile time, pointing at the spec path in the attribute:

  • Errors stop the build. They cover anything that would make the generated code wrong, such as a missing operationId or an operation that requires an unsupported security scheme.
  • Warnings cover things that are skipped while the rest still works, such as a cookie parameter or a 2XX response range. They are ordinary compiler warnings, so cargo, rust-analyzer and cached builds all show them.

Stable Rust has no way for a macro to emit its own warnings, so each one is reported through the deprecated lint and reads like this:

warning: use of deprecated constant `_::openapi_trait_warning`: openapi-trait: operation `listPets`: cookie parameter `session` is not supported and was skipped
 --> src/lib.rs:1:23
  |
1 | #[openapi_trait::axum("openapi.yaml")]
  |                       ^^^^^^^^^^^^^^

To silence them, put #[allow(deprecated)] (or #[expect(deprecated)]) on the module or crate that contains the annotated module. An attribute on the annotated module itself doesn’t reach them.

Schemas

FeatureStatus
components/schemas → structs✅
Required vs. optional properties✅ optional properties become Option<T> and are left out of the JSON when None
nullable: true (3.0), type: [T, "null"] (3.1)✅ Option<T>, also for array items and map values; ignored for parameters and headers
oneOf / anyOf with a {type: "null"} branch✅ Option<T> of the other branch; with several other branches, an Option of the enum
String enums✅ Rust enums, with #[serde(rename)] per variant
const (3.1) on a string✅ a one-variant enum
additionalProperties✅ HashMap<String, T>
String format✅ date-time → chrono::DateTime<Utc>, date → chrono::NaiveDate, uuid → uuid::Uuid, binary → Vec<u8>; other formats, including email, stay String (chrono and uuid are re-exported)
oneOf✅ a tagged enum if a discriminator is present, otherwise #[serde(untagged)]
allOf✅ one merged struct: $ref branches via #[serde(flatten)], inline objects inlined
anyOf✅ #[serde(untagged)] enum
Own properties next to oneOf / anyOf / allOf, or several of these keywords in one schema✅ one struct: the own properties as fields, allOf branches merged as above, and a #[serde(flatten)] field one_of / any_of holding a {Name}OneOf / {Name}AnyOf enum. A oneOf discriminator’s tag property stays with the enum. A property that also appears in a branch is only filled in the struct field
oneOf / anyOf next to additionalPropertiesfalls back to serde_json::Value
Inline objects, enums and compositions in properties✅ lifted to {ParentStruct}{Property}
Inline request and response body schemas✅ Body and Status{code}Body in the operation’s module
Recursive schemas✅ the field that closes a cycle becomes Box<T> (Option<Box<T>> when optional); references inside arrays and maps aren’t boxed
not, unconstrained schemas, several types (type: [string, integer])fall back to serde_json::Value
Constraints (minLength, minimum, pattern, …)✅ with the validation feature; exclusiveMinimum / exclusiveMaximum in both their 3.0 (boolean) and 3.1 (numeric) form

Operations

FeatureStatus
Path parameters, including enums✅
Query parameters, including enums and array style/explode✅
Header parameters, including enums✅ typed like query parameters; arrays use style: simple (a,b,c). The server returns 400 for a value that doesn’t parse
JSON request bodies✅ an optional body (required: false) is None when no JSON Content-Type is sent
JSON media types (application/*+json, charset parameters)✅ typed like application/json; the declared media type is sent as the Content-Type
Other request body media types (text/plain, forms, multipart)not supported: the body is skipped with a warning
Binary request bodies✅ streamed: axum::body::Body on the server, reqwest::Body on the client
One response enum per operation✅
default responses✅ a Default { status, body, … } variant, typed like a numbered response (body and headers); status is any status the operation doesn’t declare
Response headers (Content-Disposition, ETag, Cache-Control, …)✅ a payload struct holding the body and headers
Binary downloads✅ streamed: axum::body::Body on the server, openapi_trait::ByteStream on the client

Security

SchemeStatus
apiKey in header, query or cookie✅
http bearer✅
http basic✅
oauth2, openIdConnect✅ handled like http bearer; scopes are not checked
http with another scheme (digest, …), mutualTLS, apiKey with another in, $ref schemes❌ an operation that requires one fails to compile

See Security for details.

OpenAPI 3.1

A 3.0 document is converted to 3.1 when it’s read, so both versions produce the same code. These 3.1 features are ignored:

  • prefixItems, if / then / else, dependentSchemas, patternProperties, unevaluatedProperties and $dynamicRef
  • keywords next to a $ref
  • contentEncoding and contentMediaType; use format: binary or a binary media type for binary bodies
  • webhooks and components/pathItems

Not supported

If something you need isn’t in these tables, please open an issue and attach a minimal spec.

Go-to-definition in your IDE

By default, the generated code exists only inside the compiler. Go to definition (F12 in VS Code) on pets::Pet or PetsApi::list_pets therefore takes you to the #[openapi_trait::axum(...)] attribute, not to the struct or method.

Add source_file to the attribute to fix this:

#[openapi_trait::axum("openapi/pets.yaml", source_file)]
pub mod pets {}

#[openapi_trait::client("openapi/pets.yaml", source_file)]
pub mod pets_client {}

The macro writes the generated module body to a pretty-printed .rs file and include!s it. rust-analyzer then treats that file as the source of every generated item, so go-to-definition, find-references and hover open readable code.

Where the files go

ArgumentDirectory
source_file.openapi-trait/ in your crate root (next to Cargo.toml)
source_file = "dir"dir, relative to your crate root

The default directory gets a .gitignore containing * the first time it is created, so Git ignores it without any setup. A directory you choose with source_file = "dir" gets no .gitignore; add it to your own .gitignore.

Files are named <module>.<backend>.<hash>.rs, for example pets.axum.c1bb924c.rs. The hash comes from the spec’s path relative to the output directory, so modules with the same name but different specs don’t overwrite each other, and the name stays the same when the project is moved or checked out somewhere else. Each file starts with an // @generated header. Editing the file has no effect because it is rewritten on every build. Change the spec instead.

Files are never deleted. After you rename a module or move its spec, the old file stays behind, unused. You can delete it, or the whole directory, at any time: the next build writes what it needs.

The file is rewritten only when its content changes. Builds don’t touch it otherwise, so neither Cargo nor rust-analyzer re-checks without reason.

Why not target/? rust-analyzer does not load files under target/ (except a build script’s OUT_DIR). An include! pointing there resolves to nothing in the IDE, and the whole module looks empty. The default directory is inside the crate so the IDE can always see it.

rust-analyzer notes

  • First use. rust-analyzer creates the file while it expands the macro, so it may not see the file on that first pass. It normally picks the file up a moment later. If definitions still don’t resolve, run rust-analyzer: Restart server once.
  • Proc-macro expansion must be on, which is the default (rust-analyzer.procMacro.enable).
  • Excluded folders. If you exclude dot-folders in your settings (rust-analyzer.files.exclude), keep .openapi-trait visible, or use source_file = "dir" to pick a directory that isn’t excluded.

Things to know

  • Behaviour is unchanged otherwise. The generated code is identical, and without source_file nothing is written to disk.
  • Write failures don’t stop the build. If the file can’t be written, for example on a read-only file system, the macro emits a compiler warning and falls back to inline code. Go-to-definition then lands on the attribute again.
  • Compared with OPENAPI_TRAIT_DEBUG: the debug dump is a one-off copy for reading. The compiler never uses it. source_file produces the code that is actually compiled, and it stays up to date.

Debugging generated code

To see what the macro produces, set OPENAPI_TRAIT_DEBUG when you build. Each annotated module is written as a pretty-printed <module_name>.rs file:

OPENAPI_TRAIT_DEBUG=1 cargo build
OPENAPI_TRAIT_DEBUGBehaviour
unset, empty, 0, falsedisabled (default)
1, truewrite to $OUT_DIR/openapi-trait-debug, or to <temp dir>/openapi-trait-debug if the crate has no build script
any other valueused as the output directory, e.g. OPENAPI_TRAIT_DEBUG=./gen

The build prints the path of each file to stderr. Write failures are reported, but they never stop the build.

Things to know

  • Only this macro’s output is expanded. Unlike cargo expand, nested derives such as Serialize stay as #[derive(...)], so the output shows only what openapi-trait generated.
  • Dumps are written when the crate compiles. Run cargo clean -p <crate> or touch the spec to get a fresh dump.
  • Files are named after the module. Two mod petstore {} blocks writing to the same directory overwrite each other. Give each build its own directory to keep both.
  • To navigate the code in your IDE, use source_file instead. It writes the code that is actually compiled, so go-to-definition opens it.

Crates and features

Which crate to depend on

Depend on openapi-trait only. The other crates are internal and don’t promise a stable API.

CrateRole
openapi-traitPublic entry point: re-exports the macros and holds the runtime support types (RequestOptions, ByteStream, …)
openapi-trait-axumProc-macro for the axum attribute
openapi-trait-clientProc-macros for the client attribute and ReqwestClient derive
openapi-trait-sharedFramework-agnostic code generation used by the macros above

Cargo features

FeatureDefaultEffect
reqwest-client✅Blanket reqwest client implementation, #[derive(ReqwestClient)], ByteStream, and the reqwest re-export
validationserde_valid::Validate on generated models; see Validation

Re-exports

Generated code refers to these through openapi_trait::…, so you don’t have to add them yourself: serde, serde_json, chrono, uuid, and, with the matching feature, reqwest. Generated types carry #[serde(crate = "::openapi_trait::serde")], so their derives work without a serde dependency.

Servers still need axum as a dependency, so you pick its version. With the validation feature, you also need serde_valid = "2": it is re-exported as openapi_trait::serde_valid, but its derive only works when the crate is a direct dependency (see Validation).

Minimum Rust version

Rust 1.89.

Changelog

Release notes are in openapi-trait/CHANGELOG.md and on the GitHub releases page.