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 emptymod. - Clients are swappable. The client side is a plain trait too, so the same
calling code works against the generated
reqwestimplementation, a test double, or any transport you write yourself.
Where to go next
- Getting started walks through adding the crate and generating your first module.
- Implementing a server and Writing a client cover the two sides in detail.
- The API reference on docs.rs documents the
runtime types such as
RequestOptionsandByteStream.
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:
| Operation | Method and path | Auth |
|---|---|---|
listPets | GET /pets?limit= | none |
createPet | POST /pets | bearer |
getPet | GET /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:
| Module | Server trait | Client trait | Client auth state |
|---|---|---|---|
mod pets {} | pets::PetsApi | pets::PetsClient | pets::PetsAuthState |
mod petstore {} | petstore::PetstoreApi | petstore::PetstoreClient | petstore::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
- Implement a server with axum.
- Write a client, either by hand or with the generated reqwest implementation.
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:
| Field | Type | Present |
|---|---|---|
req | {operation}::Request: path, query and header parameters as typed fields, and the request body as body | always |
auth | the credentials, see Security | only if the operation is secured |
headers | axum::http::HeaderMap, for anything the spec doesn’t model | always |
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 Deref | Result |
|---|---|
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 parameter | compiles, 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 attribute | Field type | Required |
|---|---|---|
#[openapi_trait(client)] | openapi_trait::reqwest::Client | yes |
#[openapi_trait(base_url)] | String | yes |
#[openapi_trait(auth)] | {Mod}AuthState | when 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_authandbasic_authreplace anyAuthorizationheader 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.headerappends 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:
| Scheme | Generated type |
|---|---|
apiKey (in header, query or cookie) | pub struct ApiKey(pub String) |
http with scheme: bearer | pub struct BearerAuth(pub String) |
http with scheme: basic | pub struct BasicAuth { pub username: String, pub password: String } |
oauth2, openIdConnect | pub struct OAuth(pub String), the access token sent as Authorization: Bearer <token> |
These rules decide which operations require credentials:
- Document-level
securityis the default for every operation. - An operation’s own
securityreplaces that default. security: []turns auth off for an operation (listPetsin the example spec).- If an operation lists several alternatives (an OR), the macro generates an
{operation}::Authenum with one variant per scheme. - An empty requirement (
security: [{}, {bearerAuth: []}]) makes auth optional: the server passesOption<…>to the handler, withNonefor 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
httpwithscheme: digest, ormutualTLS) or a scheme that isn’t declared incomponents.securitySchemesis 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 keyword | serde_valid attribute |
|---|---|
minLength / maxLength / pattern | min_length / max_length / pattern |
minimum / maximum (+ exclusiveMinimum / exclusiveMaximum) | minimum / maximum (or exclusive_minimum / exclusive_maximum) |
multipleOf | multiple_of |
minItems / maxItems / uniqueItems | min_items / max_items / unique_items |
$ref / inline object / oneOf·anyOf·allOf field | bare #[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_validcan’t recurse intoOption<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:
| Item | Generated from | axum | client |
|---|---|---|---|
Structs and enums with serde derives | components/schemas | ✅ | ✅ |
A module per operation, named like its method (get_pet) | operations | ✅ | ✅ |
{operation}::Request struct | path, query and header parameters, plus body | ✅ | ✅ |
{operation}::Response enum, one Status{code} variant per response, plus Default { status, … } for default | responses | ✅ | ✅ |
impl IntoResponse for each response enum | ✅ | ||
Credential structs (BearerAuth, ApiKey, …) | securitySchemes | ✅ | ✅ |
{operation}::Auth enum, for operations that accept several schemes | security | ✅ | ✅ |
PetsApi trait with a router() method | operations | ✅ | |
NotImplemented marker error | ✅ | ||
PetsClient trait | operations | ✅ | |
PetsAuthState, PetsClientAuth, ReqwestPetsClientError, and the blanket reqwest impl | security 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:
| Item | When |
|---|---|
Request, Response | always |
Context (the handler’s argument: req, auth, headers) | always, server only |
Auth | the operation accepts more than one security scheme |
Status{code} payload struct (body plus headers) | the response declares headers |
Body, Status{code}Body, DefaultBody | the request or response body schema is inline |
{Param}Path, {Param}Query, {Param}Header enums | a 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
typebecomesr#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
Vprefix when the value starts with a digit or is a keyword ("1.0"→V1_0,"self"→VSelf) andEmptyfor"". 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
PersonAddressschema,Person.addressbecomesPersonAddress2.
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
operationIdor 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
2XXresponse 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
| Feature | Status |
|---|---|
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 additionalProperties | falls 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
| Feature | Status |
|---|---|
| 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
| Scheme | Status |
|---|---|
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,unevaluatedPropertiesand$dynamicRef- keywords next to a
$ref contentEncodingandcontentMediaType; useformat: binaryor a binary media type for binary bodieswebhooksandcomponents/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
| Argument | Directory |
|---|---|
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 undertarget/(except a build script’sOUT_DIR). Aninclude!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-traitvisible, or usesource_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_filenothing 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_fileproduces 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_DEBUG | Behaviour |
|---|---|
unset, empty, 0, false | disabled (default) |
1, true | write to $OUT_DIR/openapi-trait-debug, or to <temp dir>/openapi-trait-debug if the crate has no build script |
| any other value | used 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 asSerializestay 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_fileinstead. 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.
| Crate | Role |
|---|---|
openapi-trait | Public entry point: re-exports the macros and holds the runtime support types (RequestOptions, ByteStream, …) |
openapi-trait-axum | Proc-macro for the axum attribute |
openapi-trait-client | Proc-macros for the client attribute and ReqwestClient derive |
openapi-trait-shared | Framework-agnostic code generation used by the macros above |
Cargo features
| Feature | Default | Effect |
|---|---|---|
reqwest-client | ✅ | Blanket reqwest client implementation, #[derive(ReqwestClient)], ByteStream, and the reqwest re-export |
validation | serde_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.