Skip to content

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.

Terminal window
cargo add nest-rs-openapi

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

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

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.

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

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:

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.

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 — no bearerAuth block, no securitySchemes entry, and no per-operation security: [...]. 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’s description.
  • Multi-response codes. Every operation declares one 200 response with the handler’s Json<T> schema; a route that actually returns 404 / 422 / 409 is not reflected. The handler’s Result type carries the error variants; the doc doesn’t yet read them.
  • example / examples on 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 a parameters entry.
  • contact / license / servers / externalDocs on the document header — only title, version, and description are 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.

  • 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.
  • 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.