Skip to content

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.

Terminal window
cargo add nest-rs --features openapi

Add OpenApiModule::for_root to the app root, alongside the controllers it should document:

apps/api/src/module.rs (from the demo)
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.

  • 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 under NESTRS_HTTP__VERSIONING=header or media_type. OpenAPI 3.1 keys operations by path, so two versions selected by a header cannot both be described at /posts. Under the default uri strategy the version is already in the path and /api-json describes every version at once. See Versioning.
  • Schemas for free. Request and response bodies are derived from your Json<T> payload types via schemars. 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.
Terminal window
$ 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.

The document is complete without annotations, but #[api(...)] adds a summary, a longer description, and tags to any handler:

crates/features/src/users/http/controller.rs (from the demo)
#[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.

crates/features/src/users/http/controller.rs
#[get("/")]
#[api(summary = "List Users", response = Vec<User>)]
async fn list(&self, _authz: Authorize<Read, UserEntity>) -> Result<Response> { /* ... */ }

multipart = Type and response_content_type = "…" are the two escape hatches for a body Json<T> cannot describe — an upload form, a streamed download.

crates/features/src/audio/http/controller.rs
#[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.

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:

crates/features/src/notify/http/controller.rs
#[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.

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 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(...):

crates/features/src/users/http/controller.rs
#[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.

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.

Six keys, all on the framework-wide dual path — the .env cascade and the pinned struct:

FieldEnv varDefaultEffect
enabledNESTRS_OPENAPI__ENABLEDdev/test onlyServes /api and /api-json. Left on outside a dev profile, the boot logs a warn — the document and the UI are public.
titleNESTRS_OPENAPI__TITLEthe crate’s nameThe document’s info.title.
versionNESTRS_OPENAPI__VERSIONthe crate’s versionThe document’s info.version.
descriptionNESTRS_OPENAPI__DESCRIPTIONnoneThe document’s info.description.
emit_documentNESTRS_OPENAPI__EMIT_DOCUMENTfalseWrites the document to document_path once at boot — the committed-artifact workflow the GraphQL SDL emit has.
document_pathNESTRS_OPENAPI__DOCUMENT_PATHopenapi.jsonWhere emit_document writes.

The info block is the common case:

Terminal window
$ NESTRS_OPENAPI__TITLE="Acme API" \
NESTRS_OPENAPI__VERSION="2.1.0" \
NESTRS_OPENAPI__DESCRIPTION="Public REST surface" \
nestrs run dev api

Or pass an OpenApiConfig at the import site instead of reading the environment:

apps/api/src/module.rs
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.

The document composes from the route table, so everything in it is derived:

  • the info block, and servers from the transport’s global_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 a bearerAuth entry in components.securitySchemes and per-operation security on guarded routes;
  • the error statuses an operation can actually produce — 400 / 404 / 429 alongside its success code — as RFC 9457 ProblemDetails.

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 / examples on 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 / externalDocs on the document header — title, version, and description are the configurable fields.
  • Which version /api-json describes, 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 boot warn. 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.)

  • apps/api/ — mounts OpenApiModule::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.
  • 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 AuthnGuard / AbilityGuard; protected routes still appear in the spec.