openapi-trait 0.0.4

Generate typed Rust traits from OpenAPI specifications using a proc-macro attribute
Documentation

openapi-trait

Build status Crates.io Documentation

A Rust proc-macro attribute that reads an OpenAPI specification file at compile time and generates typed Rust traits from it, so you can implement your API server or define a transport-agnostic client contract with full type safety and no boilerplate.

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

use petstore::PetstoreApi as _;

#[derive(Clone)]
struct AppState;

#[derive(Clone)]
struct MyServer;

impl petstore::PetstoreApi<AppState> for MyServer {
    type Error = petstore::NotImplemented;

    async fn get_pet_by_id(
        &self,
        req: petstore::GetPetByIdRequest,
        _state: axum::extract::State<AppState>,
        _headers: axum::http::HeaderMap,
    ) -> Result<petstore::GetPetByIdResponse, Self::Error> {
        Ok(petstore::GetPetByIdResponse::Status200(petstore::Pet {
            id: Some(req.pet_id),
            name: "doggie".into(),
            photo_urls: vec![],
            category: None,
            tags: None,
            status: None,
        }))
    }
}

// Wire up an axum router.
let app = MyServer.router().with_state(AppState);
#[openapi_trait::client("openapi/petstore.yaml")]
pub mod petstore {}

struct MyClient;

impl petstore::PetstoreClient for MyClient {
    type Error = std::convert::Infallible;

    async fn get_pet_by_id(
        &self,
        req: petstore::GetPetByIdRequest,
    ) -> Result<petstore::GetPetByIdResponse, Self::Error> {
        Ok(petstore::GetPetByIdResponse::Status200(petstore::Pet {
            id: Some(req.pet_id),
            name: "doggie".into(),
            photo_urls: vec![],
            category: None,
            tags: None,
            status: None,
        }))
    }
}

What gets generated

For every OpenAPI spec the macro emits inside the target module:

Generated item Source
Structs with serde derives components/schemas
{OperationId}Request structs Path, query, header params + request body per operation
Per-operation {OperationId}Response enums HTTP status codes per operation
impl axum::response::IntoResponse For every response enum generated by openapi_trait::axum
{ModName}Api<S = ()> trait One method per operationId for server implementations
{ModName}Client trait One method per operationId for transport-agnostic client implementations
router method on the trait Wires all operations to an axum::Router when using openapi_trait::axum
NotImplemented marker struct Emitted by openapi_trait::axum; trait default method bodies return Err(NotImplemented.into()), so Self::Error must satisfy From<NotImplemented>

Crates

Crate Purpose
openapi-trait Main entry point — add this to your Cargo.toml
openapi-trait-axum Axum proc-macro — not for direct use
openapi-trait-client Client proc-macro and ReqwestClient derive — not for direct use
openapi-trait-shared Framework-agnostic codegen helpers — not for direct use

Usage

Add to Cargo.toml:

[dependencies]
openapi-trait = "0.1"

Then apply the macro to a mod block:

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

Or generate a transport-agnostic client trait:

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

The path is resolved relative to the crate root (CARGO_MANIFEST_DIR). The file is tracked by Cargo — the crate recompiles automatically when the spec changes.

The generated trait name comes from the module name, so mod petstore {} produces petstore::PetstoreApi and petstore::PetstoreClient.

Reqwest client support

openapi-trait enables the reqwest-client feature by default. That adds:

  • #[derive(openapi_trait::ReqwestClient)] for carrier structs that hold a reqwest::Client
  • A blanket implementation of the generated {ModName}Client trait for any type implementing openapi_trait::ReqwestClientCore
  • Re-exports of reqwest and percent_encoding for generated client code
#[openapi_trait::client("openapi/petstore.yaml")]
pub mod petstore {}

#[derive(Clone, openapi_trait::ReqwestClient)]
struct PetstoreClient {
    #[openapi_trait(client)]
    http: openapi_trait::reqwest::Client,
    #[openapi_trait(base_url)]
    endpoint: String,
}

Per-request headers and authentication

Every generated client method takes an Option<openapi_trait::RequestOptions> argument in addition to the operation request. Use it to attach extra headers or authentication to a single call without re-instantiating the client. Pass None when you have nothing to add:

use petstore::PetstoreClient as _;

// No extras:
client
    .get_pet_by_id(petstore::GetPetByIdRequest { pet_id: 42 }, None)
    .await?;

// Bearer auth + a custom header, scoped to this request only:
client
    .get_pet_by_id(
        petstore::GetPetByIdRequest { pet_id: 42 },
        Some(
            RequestOptions::new()
                .bearer_auth("token-123")
                .header("X-Request-Id", "abc-789"),
        ),
    )
    .await?;

RequestOptions also offers basic_auth(username, password). Options are applied after the operation's declared headers.

Disable default features if you only want the transport-agnostic trait:

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

OpenAPI support

Feature Status
components/schemas → structs
String format → specialized types ✅ — date-timechrono::DateTime<Utc>, datechrono::NaiveDate, uuiduuid::Uuid, binaryVec<u8>; email/others → String (chrono/uuid re-exported from the facade)
Path parameters
Query parameters (including string enums)
Header parameters
Request bodies (JSON)
Response enums per operation
oneOf ✅ — tagged enum when a discriminator is present, otherwise #[serde(untagged)]
allOf ✅ — merged struct ($ref branches via #[serde(flatten)], inline objects inlined)
anyOf ✅ — #[serde(untagged)] enum
Inline compositions in object properties ✅ — hoisted to a top-level type named {ParentStruct}{Property}
not / unconstrained any Falls back to serde_json::Value
Security schemes ✅ — apiKey (header/query/cookie) and http (bearer + basic); oauth2 / openIdConnect recognised but skipped

Security

For each scheme declared in components.securitySchemes, a typed struct is generated (named after the scheme key, PascalCased): apiKey-style schemes become a newtype pub struct Foo(pub String), http basic becomes pub struct Foo { username, password }. Operation-level security overrides the document-level default; security: [] disables auth on an operation; an OR of alternatives generates an {Op}Auth enum with one variant per scheme.

Server (axum) — handlers receive credentials as an extra auth parameter; the framework only extracts the raw value, the handler validates:

async fn get_admin(
    &self,
    req: api::GetAdminRequest,
    auth: api::BearerAuth,
    state: axum::extract::State<S>,
    headers: axum::http::HeaderMap,
) -> Result<api::GetAdminResponse, Self::Error> { /* ... */ }

Missing credentials return 401 Unauthorized before the handler runs.

Client (reqwest) — credentials live on the client carrier and are injected on every call. Add a field for the generated {Mod}AuthState and mark it #[openapi_trait(auth)]; the generated {Mod}ClientAuth extension trait supplies fluent with_<scheme>(...) setters:

#[derive(Clone, openapi_trait::ReqwestClient)]
struct MyClient {
    #[openapi_trait(client)] http: reqwest::Client,
    #[openapi_trait(base_url)] base_url: String,
    #[openapi_trait(auth)] auth: api::ApiAuthState,
}

use api::ApiClientAuth as _;
let client = MyClient { /* ... */ }.with_bearer_auth("token");

Server and client signatures differ intentionally: server handlers see credentials per-call (so RBAC decisions can vary by request); clients carry them session-wide.

Debugging generated code

To inspect what the macro produces, set the OPENAPI_TRAIT_DEBUG environment variable at build time. Each annotated module is written to a prettyprinted <module_name>.rs file:

OPENAPI_TRAIT_DEBUG=1 cargo build

The value controls where the files go:

OPENAPI_TRAIT_DEBUG Behaviour
unset, empty, 0, false Disabled (default)
1, true Write to $OUT_DIR/openapi-trait-debug, or <temp dir>/openapi-trait-debug if there is no build script
any other value Treated as the target directory path, e.g. OPENAPI_TRAIT_DEBUG=./gen

The resolved path of each file is printed to stderr during the build. Write failures are reported but never abort compilation.

Unlike cargo expand, this dumps only the code the macro emits directly — nested derives (Serialize, Deserialize, …) are left as #[derive(...)] rather than recursively expanded — which keeps the output focused on this crate's code generation. The macro re-runs only when the crate is recompiled, so use cargo clean -p <crate> or edit the spec to force a fresh dump.

Note that the filename is just the module name, so two mod petstore { … } declarations writing to the same directory will overwrite each other; point OPENAPI_TRAIT_DEBUG at a per-build directory to keep them separate.