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@ApiPropertyOptionalfor optional fields. Markformat: 'uuid' | 'date-time' | 'email'where applicable - DTOs must be classes, not interfaces —
@nestjs/swaggerreads 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:
- CI freshness check —
make check-openapi-fresh(shipped 2026-05-12) re-runs the extract on every NestJS service andgit diff --exit-code openapi.jsonfails on drift. CI jobcheck-openapi-fresh:in.gitlab-ci.ymlruns on every services/*/ change. Local re-run after touching controllers/DTOs:make check-openapi-fresh(ormake check-openapi-fresh SERVICE=<name>for one service). @po/contractsSDK generation — shipped 2026-05-12 viaopenapi-typescript. Runcd packages/contracts && npm run clients:gento regenerate all 10 services' types underpackages/contracts/src/clients/<service>/types.ts. Deep-imported as@po/contracts/clients/<service>. Seepackages/contracts/src/clients/README.mdfor 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:
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:AuthServiceconstructor throws on missingKEYCLOAK_URL/OIDC_CONFIDENTIAL_CLIENT_SECRET. The extract script sets placeholder values viaprocess.env.X ??= '...'before importingAppModule. No real Keycloak call is made —app.close()runs immediately afterSwaggerModule.createDocument(...).document-signing-service: setsOPENAPI_EXTRACT_MODE=1soJobProcessorService.onModuleInitshort-circuits (itsstartPolling()registers asetIntervalthat keeps the event loop alive otherwise). Also pointsLOCAL_STORAGE_PATHat/tmpsoStorageService's constructor-timemkdirSyncdoesn't trip on a root-ownedservices/document-signing-service/storage/. The script ends withprocess.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:
- payment-service ✅ — pilot done 2026-05-12,
payments.controlleronly (Connect/refunds/transfers/webhooks pending fleet sweep). - calendar-service — most complex domain (ADR-001 / ADR-006), highest payoff.
- partner-service — used by all three frontends.
- experience-service — customer-facing.
- 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:
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¶
- @nestjs/swagger docs
- swagger-jsdoc — Express alternative
- Internal:
docs/developers/api/README.md,docs/developers/adr/