OpenAPI
Import one module and your REST API documents itself. OpenApiModule serves an
OpenAPI 3.1 document at GET /api-json and a bundled, offline Swagger UI
at GET /api — composed from the route table and your Json<T> types. There is
no spec to hand-write, and it cannot drift from the code: change a handler,
the document changes with it.
nest-rs-openapi builds on schemars for the JSON
Schema derivation and bundles Swagger UI
as the offline browser asset — the framework wires both behind the route table
so the spec composes itself.
Install
Section titled “Install”cargo add nest-rs-openapiWire it in
Section titled “Wire it in”Add OpenApiModule::for_root to the app root, alongside the controllers it
should document:
use nest_rs_openapi::OpenApiModule;
#[module( imports = [ UsersHttpModule, // ... the rest of your HTTP modules ... OpenApiModule::for_root(None), ],)]pub struct ApiModule;That’s the whole opt-in. The module self-mounts both endpoints on the existing
HttpTransport — same port, same CORS, no second server. None reads
NESTRS_OPENAPI__* from the .env cascade; pass an
OpenApiConfig to pin it in code instead.
What you get
Section titled “What you get”GET /api-json— the OpenAPI 3.1 document, composed from the route table your app serves. Every#[controller]your app mounts contributes its operations; nothing is listed by hand.GET /api— a bundled Swagger UI. The assets ship inside the binary, so it works with no internet access and no CDN.- Schemas for free. Request and response bodies are derived from your
Json<T>payload types viaschemars. An entity declared with#[expose]already produces its JSON Schema — the same type feeds the handler, the GraphQL schema, and this document, so the three stay in sync by construction. See Database.
Run it
Section titled “Run it”$ curl -s http://localhost:3000/api-json | jq '.openapi, .info.title, (.paths | keys)'"3.1.2""nestrs API"[ "/users", "/users/{id}"]Open http://localhost:3000/api for the interactive
Swagger UI — try a request straight from the browser.
Enrich an operation with #[api]
Section titled “Enrich an operation with #[api]”The document is complete without annotations, but #[api(...)] adds a summary,
a longer description, and tags to any handler:
#[post("/")]#[api( summary = "Create a user in the caller's org", description = "Requires a bearer JWT. The user's org is taken from the \ caller's token, never the body.", tags("User"))]async fn create( &self, _authz: Authorize<Create, UserEntity>, auth: Ctx<Claims>, body: Valid<Json<CreateUser>>,) -> Result<Json<User>> { Ok(Json(self.svc.create_in_org(body.into_inner(), auth.org_id).await?))}#[api] accepts three keys today — summary, description, and
tags(...) — and nothing else. Everything else on the operation
(path, method, parameters, request and response schemas, 200 status) is
inferred from the handler signature; the macro rejects unknown keys at compile
time.
Tags — group operations in the UI
Section titled “Tags — group operations in the UI”Tags group operations in the Swagger UI’s sidebar. Every route inherits a
default tag equal to its controller struct name, so out of the box your
routes group sensibly with no annotation. Override per-operation with
tags(...):
#[get("/")]#[api(tags("User", "Public"))]async fn list_public(&self) -> Result<Json<Vec<User>>> { /* ... */ }The strings flow through to components and Swagger UI groups by them.
Schemas from #[expose]
Section titled “Schemas from #[expose]”An entity decorated with #[expose] produces a wire DTO whose JsonSchema
ends up in components.schemas the moment a handler returns Json<User>.
The schema generator is shared across every route: payloads referenced from
multiple handlers de-duplicate, and a User returned from GET /users/:id is
the same $ref as the one returned from the List endpoint.
The same type powers the GraphQL output type, the wire DTO the handler returns,
and the JSON Schema in /api-json — change one column on the entity, all three
move together. No hand-written schema, no annotation, nothing to drift.
Configure the document
Section titled “Configure the document”The info block — title, version, description — is the only header the
framework writes today, and every field is settable from the
.env cascade and the pinned struct (the framework-wide
dual-path rule):
$ NESTRS_OPENAPI__TITLE="Acme API" \ NESTRS_OPENAPI__VERSION="2.1.0" \ NESTRS_OPENAPI__DESCRIPTION="Public REST surface" \ nestrs run dev apiOr pass an OpenApiConfig at the import site instead of reading the
environment:
use nest_rs_openapi::{OpenApiConfig, OpenApiModule};
OpenApiModule::for_root(OpenApiConfig { title: "Acme API".into(), version: "2.1.0".into(), description: Some("Public REST surface".into()),})The pinned struct wins over the environment when both are present.
Not yet in the document
Section titled “Not yet in the document”Be honest about what the framework writes and what it doesn’t. The current
composer emits the info block, every operation under paths, and a shared
components.schemas. It does not yet emit:
components.securitySchemes— nobearerAuthblock, nosecuritySchemesentry, and no per-operationsecurity: [...]. Protected routes still appear in the spec, but Swagger UI’s “Authorize” button has nothing to bind to. For now, document the bearer requirement in the operation’sdescription.- Multi-response codes. Every operation declares one
200response with the handler’sJson<T>schema; a route that actually returns404/422/409is not reflected. The handler’sResulttype carries the error variants; the doc doesn’t yet read them. example/exampleson inputs and responses. schemars derives the shape; supplying concrete sample payloads is on the roadmap and not part of#[api]today.- Header parameters — only path parameters are emitted; a
Header<T>extractor does not yet contribute aparametersentry. contact/license/servers/externalDocson the document header — onlytitle,version, anddescriptionare configurable.
These are framework gaps, not your gaps. When you hit one, file an issue rather than hand-patching the spec — the whole point of the module is that the spec composes from the code, and an out-of-band patch breaks that invariant.
Going further
Section titled “Going further”- HTTP — the controllers, routes and
Json<T>types this document is built from. - Database —
#[expose]turns one entity into a wire DTO, a GraphQL type and a JSON Schema at once. - Security — bind
AuthGuard/AbilityGuard; protected routes still appear in the spec.
Reference
Section titled “Reference”apps/api/— mountsOpenApiModule::for_root(None)next to REST + GraphQL.crates/features/src/users/http/controller.rs— real#[api(...)]usage.crates/nest-rs-openapi/—OpenApiModule,OpenApiConfig, the document composer, the bundled Swagger UI.