OpenAPI
Your REST API documents itself — an OpenAPI 3.1 spec and a bundled Swagger UI, composed from the route table and your types.
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 --features 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::core::module;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.GET /api-json/v{n}— one document per API version, and only underNESTRS_HTTP__VERSIONING=headerormedia_type. OpenAPI 3.1 keys operations by path, so two versions selected by a header cannot both be described at/posts. Under the defaulturistrategy the version is already in the path and/api-jsondescribes every version at once. See Versioning.- 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 six keys — summary, description, tags(...),
response = Type, multipart = Type and
response_content_type = "type/subtype" — and nothing else. Everything else
on the operation (path, method, parameters, request schema, success status) is
inferred from the handler signature; the macro rejects unknown keys at compile
time.
response is the one escape hatch, for a handler that builds its own
Response and therefore states no payload in its return type. The #[crud]
paginated list is the case in the framework itself: it returns a Response so
it can carry x-next-cursor, and declares
#[api(response = Vec<Post>)] so the document still types the collection.
#[get("/")]#[api(summary = "List Users", response = Vec<User>)]async fn list(&self, _authz: Authorize<Read, UserEntity>) -> Result<Response> { /* ... */ }Bodies that are not JSON
Section titled “Bodies that are not JSON”multipart = Type and response_content_type = "…" are the two escape hatches
for a body Json<T> cannot describe — an upload form, a streamed download.
#[post("/uploads/direct")]#[api( summary = "Upload an audio file directly as multipart/form-data", multipart = DirectUploadDto,)]async fn upload_direct(&self, upload: UploadedAudio) -> Result<Json<PresignedUrlDto>> { /* ... */ }
#[get("/download")]#[api( summary = "Stream a transcoded object back through the server", response_content_type = "audio/mpeg",)]async fn download(&self, query: Valid<Query<TranscodeDto>>) -> Result<Response> { /* ... */ }The form’s parts are a type like any other payload — a String field carrying
#[schemars(extend("format" = "binary"))] is the file part Swagger UI renders a
file picker for. A handler that takes poem’s Multipart directly and declares
nothing still documents multipart/form-data with a free-form object: the media
type it accepts is knowledge a client needs, even when no type states the parts.
response_content_type replaces application/json on the success response and,
with no response = Type beside it, types the body the way OpenAPI spells a
stream — string with format: binary, or plain string for a text/* media
type. An #[sse] route needs no annotation at all: it
answers text/event-stream and nothing else, so the document reads the media
type off the decorator — and declaring response_content_type there is a
compile error, because it could only describe something the route never sends.
Header parameters
Section titled “Header parameters”A handler that reads headers binds them as a DTO — Header<T>, the header-map
twin of Query<T> — and each property becomes an in: header parameter, an
Option<_> field being an optional one:
#[derive(Deserialize, JsonSchema)]pub struct StreamResumeDto { #[serde(rename = "Last-Event-ID")] pub last_event_id: Option<u32>,}
#[sse("/events")]async fn events(&self, resume: Header<StreamResumeDto>) -> SseStream { /* ... */ }A missing required header, or a value that does not parse into its field’s type,
is rejected with the same RFC 9457 400 every other edge rejection carries —
naming the header and never quoting its value, because that is where credentials
travel. A required header also makes the operation advertise its 400.
Ability-shaped responses
Section titled “Ability-shaped responses”A route with an Authorize<_, _> parameter masks its response per caller, so
the fields a given client receives are a subset of the published schema.
The document publishes the full shape anyway and says so in the response
description — a schema a caller may see less of is far more useful to a
generated client than no schema at all, which is what an any-typed CRUD
response used to be.
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”Six keys, all on the framework-wide dual path — the .env cascade
and the pinned struct:
| Field | Env var | Default | Effect |
|---|---|---|---|
enabled | NESTRS_OPENAPI__ENABLED | dev/test only | Serves /api and /api-json. Left on outside a dev profile, the boot logs a warn — the document and the UI are public. |
title | NESTRS_OPENAPI__TITLE | the crate’s name | The document’s info.title. |
version | NESTRS_OPENAPI__VERSION | the crate’s version | The document’s info.version. |
description | NESTRS_OPENAPI__DESCRIPTION | none | The document’s info.description. |
emit_document | NESTRS_OPENAPI__EMIT_DOCUMENT | false | Writes the document to document_path once at boot — the committed-artifact workflow the GraphQL SDL emit has. |
document_path | NESTRS_OPENAPI__DOCUMENT_PATH | openapi.json | Where emit_document writes. |
The info block is the common case:
$ 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 is the base NESTRS_OPENAPI__* overlays, field by field —
so the deployment can still override any one of them. See the
precedence chain.
Limits
Section titled “Limits”The document composes from the route table, so everything in it is derived:
- the
infoblock, andserversfrom the transport’sglobal_prefix; - every operation under
paths, with its path, query and header parameters; - its request body, and the media type that body arrives as;
- a shared
components.schemas, plus abearerAuthentry incomponents.securitySchemesand per-operationsecurityon guarded routes; - the error statuses an operation can actually produce —
400/404/429alongside its success code — as RFC 9457ProblemDetails.
operationId is derived too, as <controller>_<handler>, mapped onto what an
identifier can carry — PostsController::list publishes posts_list, and a raw
handler r#type publishes …_r_type rather than an id with a # in it. Qualified by the controller because #[crud] names every
resource’s handlers identically, and OpenAPI requires the id to be unique across
the document: a generator meeting two lists either errors or renames one, so a
method goes missing from the SDK. A versioned operation carries its version too
(posts_list_v2).
What the composer does not derive, it leaves to you:
example/exampleson inputs and responses. schemars derives the shape; concrete sample payloads are a documentation choice, so they live in the#[api]description rather than in a generated block.- Response headers other than the two the framework sends itself
(
Location,Retry-After). A header a handler sets is not something the route table knows about. contact/license/externalDocson the document header —title,version, anddescriptionare the configurable fields.- Which version
/api-jsondescribes, when the deployment names no default. It then aggregates every declared version and resolves a contested path to the highest one, naming the loser in a bootwarn. Point a client at/api-json/v{n}when it needs one version’s shapes exactly.
Don’t hand-patch the document: it is composed at boot from the route table, so an out-of-band edit is lost the next time the app starts and breaks the no-drift invariant that makes the spec worth trusting. (Composed once, not per request — serving it is a string write.)
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.