From 4c2bc315ba8417fc6a82e767b4fc3b219e927ed3 Mon Sep 17 00:00:00 2001 From: Lukas Kahwe Smith Date: Mon, 6 Jul 2026 17:22:52 +0200 Subject: [PATCH] docs(recipe): add NestJS + Swagger OpenAPI merge guide Extend the NestJS recipe with two patterns drawn from a production ZenStack + NestJS app: - Sharing the RestApiHandler across thin, per-resource controllers so guards, interceptors, and Swagger decorators can be attached per resource while delegation stays in one place. - Merging the handler's generated OpenAPI spec with @nestjs/swagger into a single Swagger UI: re-prefixing ZenStack paths to the NestJS global prefix, merging paths (NestJS operation wins) and component schemas, and using respectAccessPolicies so the published spec matches what the API exposes. --- docs/recipe/nestjs.md | 137 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/docs/recipe/nestjs.md b/docs/recipe/nestjs.md index e993c0e5..4ee9ad0f 100644 --- a/docs/recipe/nestjs.md +++ b/docs/recipe/nestjs.md @@ -165,3 +165,140 @@ export class AppController { ``` With this setup, all requests to `/api/*` will be handled by the ZenStack API handler, which performs the necessary CRUD operations with access control enforced. Refer to the [API handlers documentation](../service/api-handler/) for request and response formats and other details. + +### Sharing the Handler Across Thin Controllers + +A single catch-all controller is the simplest setup, but it makes it hard to attach per-resource NestJS features — guards, interceptors, rate limits, or Swagger decorators. An alternative is to wrap the handler in a service and forward from small, per-resource controllers: + +```ts title="src/db/resthandler.service.ts" +import { Inject, Injectable } from '@nestjs/common'; +import { RestApiHandler } from '@zenstackhq/server/api'; +import type { Request, Response } from 'express'; +import { schema, type SchemaType } from '../../zenstack/schema'; + +@Injectable() +export class RestHandlerService { + private readonly apiHandler = new RestApiHandler({ + schema, + endpoint: 'http://localhost:3000/api', + }); + + constructor(@Inject('AUTH_DB') private readonly dbService: any) {} + + async handleRequest(req: Request, res: Response) { + // Strip the controller's path prefix so the handler sees a resource-relative + // path (e.g. "post/1"), matching the catch-all example above. + const relativePath = req.path.replace(/^\/api\//, ''); + const result = await this.apiHandler.handleRequest({ + method: req.method, + path: relativePath, + query: req.query as Record, + requestBody: req.body, + client: this.dbService, + }); + res.status(result.status); + return result.body; + } +} +``` + +```ts title="src/post/post.controller.ts" +import { Controller, Get, Post, Req, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import type { Request, Response } from 'express'; +import { RestHandlerService } from '../db/resthandler.service'; + +@ApiTags('post') +@Controller('post') +export class PostController { + constructor(private readonly rest: RestHandlerService) {} + + // Declaring the methods (rather than a single `@All`) lets you attach + // guards, Swagger decorators, and rate limits per operation while still + // delegating the actual work to the shared ZenStack handler. + @Get(['', '/:id']) + get(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + return this.rest.handleRequest(req, res); + } + + @Post() + create(@Req() req: Request, @Res({ passthrough: true }) res: Response) { + return this.rest.handleRequest(req, res); + } +} +``` + +Each controller still delegates all CRUD work to the same handler, so there's no duplicated logic — the controllers exist only to carry NestJS metadata (guards, tags, decorators) for their resource. + +## Merging the OpenAPI Spec With NestJS Swagger + +The RESTful and RPC API handlers can [generate an OpenAPI spec](../service/openapi/restful.md) at runtime from your ZModel schema. When you also have hand-written NestJS controllers (custom actions, auth endpoints, or the thin delegating controllers above), you'll want a single Swagger UI that documents both. The trick is to generate each spec separately and merge their `paths` and `components`. + +Two details make the merge work: + +- **Path prefixes differ.** ZenStack paths are relative to the handler's `endpoint` (e.g. `/post/{id}`), while NestJS paths include the [global prefix](https://docs.nestjs.com/faq/global-prefix) (e.g. `/api/post/{id}`). Re-prefix the ZenStack paths before merging. +- **Both contribute component schemas.** Keep ZenStack's resource/error schemas and NestJS's DTO schemas. + +```ts title="src/swagger.ts" +import type { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule, type OpenAPIObject } from '@nestjs/swagger'; +import type { RestApiHandler } from '@zenstackhq/server/api'; + +export async function setupSwagger( + app: INestApplication, + handler: RestApiHandler, + prefix = 'api', +) { + // 1. ZenStack's runtime spec. `respectAccessPolicies` prunes fields and + // operations the anonymous/base role can't reach, so the published spec + // matches what the API actually exposes. + const zenStackSpec = (await handler.generateSpec({ + title: 'My API', + respectAccessPolicies: true, + })) as OpenAPIObject; + + // 2. Re-prefix ZenStack paths ("/post/{id}" -> "/api/post/{id}") so they line + // up with NestJS's global prefix. + const zenStackPaths = Object.fromEntries( + Object.entries(zenStackSpec.paths ?? {}).map(([path, config]) => [ + `/${prefix}${path}`, + config, + ]), + ); + + // 3. NestJS spec from your decorated controllers. + const nestDocument = SwaggerModule.createDocument( + app, + new DocumentBuilder().setTitle('My API').build(), + ); + + // 4. Merge paths. Where a controller documents the same path+method as + // ZenStack, the NestJS operation wins (it carries your guards, headers, + // and hand-written descriptions); otherwise ZenStack fills it in. + const paths: OpenAPIObject['paths'] = { ...zenStackPaths }; + for (const [path, operations] of Object.entries(nestDocument.paths ?? {})) { + paths[path] = { ...(paths[path] ?? {}), ...operations }; + } + + const merged: OpenAPIObject = { + ...nestDocument, + paths, + components: { + ...zenStackSpec.components, + ...nestDocument.components, + schemas: { + ...zenStackSpec.components?.schemas, + ...nestDocument.components?.schemas, + }, + }, + }; + + SwaggerModule.setup(prefix, app, merged); +} +``` + +Call it from `main.ts` after the app is created (and after `app.setGlobalPrefix(prefix)`), passing the same handler instance your controllers use. + +:::tip Going further +The merge step is plain object manipulation, so you can layer on whatever your API needs: merge response objects instead of overwriting them (to keep NestJS-declared headers on ZenStack operations), publish separate public vs. internal specs by filtering operations on a custom `x-*` extension, add security schemes, or prune component schemas that end up unreferenced after filtering. Because the ZenStack spec is regenerated from the schema on every boot, it never drifts from your data model. +:::