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.

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

apps/api/src/app.rs
use nestrs_openapi::OpenApiModule;
#[module(
imports = [
UsersHttpModule,
// ... the rest of your HTTP modules ...
OpenApiModule::for_root(None), // None → reads NESTRS_OPENAPI__* / defaults
],
)]
pub struct AppModule;

That’s the whole opt-in. The module self-mounts both endpoints on the existing HttpTransport — same port, same CORS, no second server.

  • 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.0"
"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<CreateUserInput>>,
) -> Result<Json<User>> {
Ok(Json(self.svc.create_in_org(body.into_inner(), auth.org_id).await?))
}

summary and description show on the operation; tags(...) group operations in the UI. Everything else — path, method, parameters, request and response schemas, status codes — is inferred from the handler signature.

The info block (title, version, description) comes from NESTRS_OPENAPI__* in the .env cascade, with sensible defaults:

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

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

use nestrs_openapi::{OpenApiConfig, OpenApiModule};
OpenApiModule::for_root(OpenApiConfig {
title: "Acme API".into(),
version: "2.1.0".into(),
description: Some("Public REST surface".into()),
})
  • 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/nestrs-openapi/OpenApiModule, OpenApiConfig, the document composer, the bundled Swagger UI.