Skip to content

OpenAPI / Swagger Generation

Pattern + adoption status for OpenAPI specs across po-platform services.

Why

Hand-written markdown API references (under this directory) drift every time endpoints change. OpenAPI specs auto-generate from controller decorators and stay in sync with the running code by definition. Generated specs also unlock client-SDK generation, Postman imports, and API-shape diff tooling.

This page tracks the rollout. Riff #12 scoped the first wave (auth-service + calendar-service); Riff #21 (closed 2026-05-17) wired the api-gateway aggregation under /api/<svc>/docs-json + an HTML index at /api/openapi. Plan #026 Slice J (in flight 2026-05-12) closes the remaining NestJS gaps (contract, file, payment) plus a fleet-wide decoration pass — payment-service was the pilot.

Conventions

Concern Decision Rationale
Library @nestjs/swagger@11.x for NestJS services First-class support; auto-discovery of routes; minimal boilerplate.
Library (Express) swagger-jsdoc + swagger-ui-express (when adopted) Matches the existing Express style in api-gateway; avoids a NestJS rewrite.
Spec hosting Per-service runtime endpoint at /<prefix>/docs (UI) and /<prefix>/docs-json (raw spec) Mirrors NestJS conventions; no static spec files committed; specs always reflect the running version.
Path prefix Match the service's existing setGlobalPrefix (e.g. api/docs for calendar-service, auth/docs for auth-service) Keeps Traefik routing consistent with existing routes.
Auth shape Bearer (addBearerAuth()) for backend-API services; cookie (addCookieAuth('access_token')) for auth-service's session endpoints Matches the actual transport in each service.
Aggregation ✅ Path A (Riff #21, 2026-05-17) — api-gateway proxies each downstream service's /docs-json at /api/<svc>/docs-json (no auth, dedicated lightweight fetch handlers registered before the general proxy chain). HTML index at /api/openapi. Path B (static merge into a single spec) was rejected as premature; per-service proxy keeps each spec authoritative at the source. Re-evaluate when SDK generation needs a single artifact.
Code-gen / SDK ✅ Adopted (Plan #026 Slice J) — packages/contracts/src/clients/<svc>/types.ts generated from each service's committed openapi.json via openapi-typescript. Run npm run clients:gen from packages/contracts/. Single artifact would help generic codegen; for now the per-service approach matches the per-service openapi.json source-of-truth model.

Adoption status

Service Status Endpoint(s) Notes
calendar-service ✅ Wired 2026-04-28 /api/docs, /api/docs-json Bootstrap config in src/main.ts. Controllers not yet decorated with @ApiTags/@ApiOperation/@ApiProperty — auto-discovered routes appear in the spec but lack rich descriptions. Decoration pass tracked as Riff #22.
auth-service ✅ Wired 2026-04-28 /auth/docs, /auth/docs-json REST surface only — GraphQL surface is documented via Apollo's playground at /graphql.
partner-service ✅ Wired 2026-04-28 /api/docs, /api/docs-json Used by all three frontends. Domain: partner profiles, partner-services catalogue, contracts, document workflows, service-text AI pipelines.
experience-service ✅ Wired 2026-04-28 /api/docs, /api/docs-json Customer-facing experience catalogue + AI-driven Experience assembly.
api-gateway ✅ Wired 2026-05-12 /api/docs, /api/docs-json Slice J Express pilot. Hand-defined spec at src/openapi/spec.ts (plain TS object — gateway is mostly proxy code, so swagger-jsdoc would yield empty output). 11 paths: /health + 10 wildcard entries documenting the proxy table (downstream service shapes live in each target's openapi.json). 2 schemas (HealthResponse, ProxyError). Served by swagger-ui-express.
contract-service ✅ Wired 2026-05-12 /api/docs, /api/docs-json Slice J fleet. 20 paths, 8 schemas. Four controllers tagged: action-reasons, admin-partner-contracts, partner-contracts, health. DTO decoration pass deferred (8 DTOs across action-reasons + partner-contracts modules).
notification-service ✅ Wired 2026-05-02 /api/docs, /api/docs-json Email (Resend), WebSocket (Socket.IO), push. Consumes partner.events / contract.events topics.
document-signing-service ✅ Wired 2026-05-02 /api/docs, /api/docs-json Bootstrap inserted after the body-parser/CORS/DB-init prelude unique to this service. PDF eIDAS signing surface.
review-service ✅ Wired 2026-05-02 /api/docs, /api/docs-json Customer reviews + ratings. W2-5 Yelp Fusion ingestion still pending.
file-service ✅ Wired 2026-05-12 /api/docs, /api/docs-json Slice J fleet. 4 paths, 0 schemas (single flat controller; inline anonymous types — Slice K can migrate to @po/contracts DTOs). @ApiTags('files'). Pre-existing root-owned node_modules/@nestjs/ blocked npm install; resolved 2026-05-12 via temp/fix-file-service-node-modules-ownership.sh (one-time chown).
payment-service ✅ Wired 2026-05-12 (pilot) /api/docs, /api/docs-json Slice J pilot. payments.controller + CreatePaymentIntentDto + PaymentIntentResponseDto are the canonical decoration reference. 11 routes, 6 schemas; spec extracts to services/payment-service/openapi.json via npm run openapi:extract. Connect/refunds/transfers/webhooks controllers still need the decoration pass.
user-management ⏳ TODO Mothballed 2026-04-30 — defer until un-mothballed.
ai-service ❌ Skip FastAPI auto-generates OpenAPI at /openapi.json natively; no work needed. RAG endpoints (/search, /chat, /convert) folded in W2-7.

Wiring a NestJS service (template)

In src/main.ts, after NestFactory.create(...) and before app.listen(...):

import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

// ... existing app config ...

const config = new DocumentBuilder()
  .setTitle('<Service> Service')
  .setDescription('Brief one-liner; link to ADR or domain doc.')
  .setVersion('1.0')
  .addBearerAuth()  // or .addCookieAuth(...) per the service's auth shape
  .build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('<prefix>/docs', app, document, {
  jsonDocumentUrl: '<prefix>/docs-json',
});

Add @nestjs/swagger to package.json dependencies (currently ^11.4.2). After wiring, decorate at minimum:

  • Each controller class with @ApiTags('<resource>')
  • Each route with @ApiOperation({ summary: '...', description: '...' }) and one @ApiResponse({ status: <code>, ... }) per documented response
  • Each DTO field with @ApiProperty({ description: '...', example: ... }) — use @ApiPropertyOptional for optional fields. Mark format: 'uuid' | 'date-time' | 'email' where applicable
  • DTOs must be classes, not interfaces — @nestjs/swagger reads decorators via reflection and interfaces don't carry them. If the existing DTO is an interface, convert to a class; the wire shape stays identical.

See services/payment-service/src/modules/payments/ for the canonical reference (payments.controller.ts + dto/create-payment-intent.dto.ts + dto/payment-intent-response.dto.ts).

Extract script + on-disk spec

Each NestJS service exposes an npm run openapi:extract script that boots a disposable Nest context and writes openapi.json to the service root. Adopted across all 10 NestJS services during Slice J (2026-05-12). Spec sizes:

Service Paths Schemas
partner-service 61 31
calendar-service 35 13
contract-service 20 8
experience-service 15 1
document-signing-service 14 3
payment-service 11 6
api-gateway 11 2
notification-service 6 4
review-service 6 2
auth-service 4 0
file-service 4 0
Total 187 70

This unblocks two downstream uses:

  1. CI freshness checkmake check-openapi-fresh (shipped 2026-05-12) re-runs the extract on every NestJS service and git diff --exit-code openapi.json fails on drift. CI job check-openapi-fresh: in .gitlab-ci.yml runs on every services/*/ change. Local re-run after touching controllers/DTOs: make check-openapi-fresh (or make check-openapi-fresh SERVICE=<name> for one service).
  2. @po/contracts SDK generation — shipped 2026-05-12 via openapi-typescript. Run cd packages/contracts && npm run clients:gen to regenerate all 10 services' types under packages/contracts/src/clients/<service>/types.ts. Deep-imported as @po/contracts/clients/<service>. See packages/contracts/src/clients/README.md for the consumer recipe.

Template (copy into each NestJS service at scripts/extract-openapi.ts, then add the npm script):

// services/<svc>/scripts/extract-openapi.ts
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from '../src/app.module';

async function extract() {
  const app = await NestFactory.create(AppModule, { logger: false });
  const config = new DocumentBuilder()
    .setTitle('<Service> Service')
    .setDescription('Match src/main.ts exactly.')
    .setVersion('1.0')
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  const outPath = resolve(__dirname, '..', 'openapi.json');
  writeFileSync(outPath, JSON.stringify(document, null, 2) + '\n');
  await app.close();
}

extract().catch((err) => { console.error(err); process.exit(1); });

Add to package.json scripts:

"openapi:extract": "ts-node -r tsconfig-paths/register scripts/extract-openapi.ts"

Keep the DocumentBuilder arguments identical to those in src/main.ts — the canonical pattern lives in main; the extract script mirrors it.

The reference implementation is services/payment-service/scripts/extract-openapi.ts.

Per-service quirks

A few services have heavier OnModuleInit handlers than the canonical pattern; their extract scripts compensate:

  • auth-service: AuthService constructor throws on missing KEYCLOAK_URL / OIDC_CONFIDENTIAL_CLIENT_SECRET. The extract script sets placeholder values via process.env.X ??= '...' before importing AppModule. No real Keycloak call is made — app.close() runs immediately after SwaggerModule.createDocument(...).
  • document-signing-service: sets OPENAPI_EXTRACT_MODE=1 so JobProcessorService.onModuleInit short-circuits (its startPolling() registers a setInterval that keeps the event loop alive otherwise). Also points LOCAL_STORAGE_PATH at /tmp so StorageService's constructor-time mkdirSync doesn't trip on a root-owned services/document-signing-service/storage/. The script ends with process.exit(0) as belt-and-braces.

Both quirks are documented inline in their scripts/extract-openapi.ts. If a future service hits similar trouble, mirror these patterns and add a row here.

Decorator-pass priority (Riff #22)

Bootstrap wiring exposes the route map; rich descriptions need a decoration pass. Order recommended:

  1. payment-service ✅ — pilot done 2026-05-12, payments.controller only (Connect/refunds/transfers/webhooks pending fleet sweep).
  2. calendar-service — most complex domain (ADR-001 / ADR-006), highest payoff.
  3. partner-service — used by all three frontends.
  4. experience-service — customer-facing.
  5. Remaining services in inventory order.

Each service's pass: ~1-2h to decorate controllers + DTOs. Use the payment-service pilot as the structural reference for decorator density and DTO conversion (interface → class) patterns.

Validation

After wiring, verify locally:

make dev-rebuild-<service>
curl -s http://localhost:<port>/<prefix>/docs-json | jq '.info, (.paths | keys | length) as $n | "paths: \($n)"'

The spec is also auditable in CI — a follow-up validate-openapi job can run npx swagger-cli validate <prefix>/docs-json against each service's running container during deploy:qual smoke. Tracked in Riff #21 alongside aggregation.

Generated client types (Plan #026 Slice J)

packages/contracts/src/clients/<service>/types.ts is generated from each service's openapi.json by openapi-typescript. Regenerate with:

cd packages/contracts && bash scripts/gen-clients.sh

Two gates keep this honest:

Gate Detects Fix-forward
make check-openapi-fresh controller/DTO drift vs committed openapi.json cd services/<svc> && npm run openapi:extract
make check-clients-fresh committed openapi.json drift vs committed clients/types.ts cd packages/contracts && bash scripts/gen-clients.sh

Both run in .gitlab-ci.yml's validate stage. Together they fail any PR that touches a controller without refreshing both the spec and the generated client types.

Consuming generated paths in frontend code

The exported paths interface gives compile-time URL safety. Pattern (see frontends/partner-console/src/services/payment.service.ts for a live POC):

import type { paths as PaymentPaths } from '@po/contracts/clients/payment-service/types';

const ACCOUNT_PATH =
  '/payments/connect/accounts/{partnerId}' satisfies keyof PaymentPaths;

If anyone renames or deletes that route upstream, the next regen of the client types removes the matching keyof entry; the satisfies check then fails tsc --noEmit at the frontend, not at runtime in qual.

Note: response body types still rely on the upstream controller using @ApiProperty classes (not bare interfaces). When a DTO is declared as an interface, @nestjs/swagger cannot introspect it, so the generated spec carries content?: never and the consumer must hand-type the response body. Migrating bare-interface DTOs to classes is opportunistic follow-up (Riff #143 re-export barrels, plus per-service decoration passes tracked in Riff #23+).

References