feat(docs): add Swagger UI with validation schemas and bearer authentication

This commit is contained in:
mihir 2026-09-11 17:15:37 +05:30
parent 3da6f4e469
commit 2a85081c7d
15 changed files with 455 additions and 2 deletions

View File

@ -13,3 +13,6 @@ RECOVERY_TTL_MINUTES=15
# SMTP_FROM=support@example.com # SMTP_FROM=support@example.com
# RECOVERY_URL=https://shop.example.com/reset # RECOVERY_URL=https://shop.example.com/reset
DATABASE_POOL_SIZE=10 DATABASE_POOL_SIZE=10
# Optional; defaults to true only in development.
# SWAGGER_ENABLED=true

View File

@ -32,3 +32,7 @@ Identity: see [API contract](docs/identity-api.md) and [setup/release guide](doc
Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/commerce-api.md), [errors](docs/error-contract.md), [migrations](docs/migrations.md), [security preparation](docs/vapt-readiness.md) and [verification](docs/verification.md). Phase 1C adds catalog, private addresses and inventory. See [commerce API](docs/commerce-api.md), [errors](docs/error-contract.md), [migrations](docs/migrations.md), [security preparation](docs/vapt-readiness.md) and [verification](docs/verification.md).
Phase 1D adds versioned carts, coupons, atomic checkout and private order snapshots. See [checkout API and pricing boundary](docs/checkout-api.md). Payment remains disabled until tax, shipping and payment rules are finalized. Phase 1D adds versioned carts, coupons, atomic checkout and private order snapshots. See [checkout API and pricing boundary](docs/checkout-api.md). Payment remains disabled until tax, shipping and payment rules are finalized.
Phase 1E provides a [provider-independent blueprint and test matrix](docs/phase1e-blueprint.md), configurable pricing snapshots and an [operational outbox/API](docs/operations-api.md). No real gateway or message delivery is enabled. Phase 1E provides a [provider-independent blueprint and test matrix](docs/phase1e-blueprint.md), configurable pricing snapshots and an [operational outbox/API](docs/operations-api.md). No real gateway or message delivery is enabled.
### Swagger UI
Open http://localhost:3000/api/docs in development to browse and test the API. See [API explorer](docs/swagger.md) for authentication and configuration.

15
docs/swagger.md Normal file
View File

@ -0,0 +1,15 @@
# API explorer
Start the backend with the existing database configuration (`pnpm build`, then `pnpm start`). In development, open **http://localhost:3000/api/docs**. The machine-readable OpenAPI specification is at **/api/docs-json**. If PORT differs, use that port.
1. Bootstrap an owner using the setup instructions in README, or use an existing account.
2. Expand Auth and execute `POST /api/v1/auth/login` with your organization UUID, email and password.
3. Copy `accessToken` from the successful response. Click **Authorize**, paste the token without a `Bearer` prefix and confirm.
4. Select an endpoint, click **Try it out**, fill its parameters/body and execute. Required permissions appear in the endpoint description. Requests use your real account permissions and can change data.
5. Log out through the API when finished and clear authorization in the UI.
The UI groups all registered controller routes. Request bodies, required fields, query defaults and constraints are derived from the same Zod schemas used by validation. Custom cross-field refinements and business rules still apply on the server. Successful response bodies are not yet exhaustively modeled; inspect actual responses. Errors share a documented envelope with distinct codes and a request ID for log correlation.
`SWAGGER_ENABLED=true` explicitly enables documentation; `false` disables it. If omitted, it is enabled only in development. Documentation itself does not require a session, so enable it on a deployed environment only when its API inventory should be visible there. Protected API operations still require authentication and permissions. Tokens are not persisted by Swagger across reloads. Assets are served locally and external schema validation is disabled.
Payment and shipping remain test blueprints. No gateway credentials or real integration are required by Swagger. No database schema changes or migrations are needed for this feature.

View File

@ -31,6 +31,7 @@
"@nestjs/common": "^11.1.0", "@nestjs/common": "^11.1.0",
"@nestjs/core": "^11.1.0", "@nestjs/core": "^11.1.0",
"@nestjs/platform-express": "^11.1.0", "@nestjs/platform-express": "^11.1.0",
"@nestjs/swagger": "^11.4.7",
"@prisma/adapter-pg": "^7.0.0", "@prisma/adapter-pg": "^7.0.0",
"@prisma/client": "^7.0.0", "@prisma/client": "^7.0.0",
"dotenv": "^17.0.0", "dotenv": "^17.0.0",

View File

@ -22,6 +22,9 @@ importers:
'@nestjs/platform-express': '@nestjs/platform-express':
specifier: ^11.1.0 specifier: ^11.1.0
version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1) version: 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
'@nestjs/swagger':
specifier: ^11.4.7
version: 11.4.7(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2)
'@prisma/adapter-pg': '@prisma/adapter-pg':
specifier: ^7.0.0 specifier: ^7.0.0
version: 7.10.0 version: 7.10.0
@ -610,6 +613,9 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'} engines: {node: '>=8'}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@napi-rs/wasm-runtime@1.2.3': '@napi-rs/wasm-runtime@1.2.3':
resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==}
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
@ -648,12 +654,42 @@ packages:
'@nestjs/websockets': '@nestjs/websockets':
optional: true optional: true
'@nestjs/mapped-types@2.1.1':
resolution: {integrity: sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
class-transformer: ^0.4.0 || ^0.5.0
class-validator: ^0.13.0 || ^0.14.0 || ^0.15.0
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/platform-express@11.2.3': '@nestjs/platform-express@11.2.3':
resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==} resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==}
peerDependencies: peerDependencies:
'@nestjs/common': ^11.0.0 '@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0 '@nestjs/core': ^11.0.0
'@nestjs/swagger@11.4.7':
resolution: {integrity: sha512-QyDYnmfP4IRucgmtQxMqzgRBdWtjFoDp8eFvvgf92+3wdLCL+Q0xOFO1948j/ntW/Wi7qT2dyck6ka8ADzPWQQ==}
peerDependencies:
'@fastify/static': ^8.0.0 || ^9.0.0 || ^10.0.0
'@nestjs/common': ^11.0.1
'@nestjs/core': ^11.0.1
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
'@fastify/static':
optional: true
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/testing@11.2.3': '@nestjs/testing@11.2.3':
resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==} resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==}
peerDependencies: peerDependencies:
@ -901,6 +937,9 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@sinclair/typebox@0.34.52': '@sinclair/typebox@0.34.52':
resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==}
@ -1245,6 +1284,9 @@ packages:
argparse@1.0.10: argparse@1.0.10:
resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asap@2.0.6: asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
@ -2051,6 +2093,10 @@ packages:
resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==}
hasBin: true hasBin: true
js-yaml@5.3.0:
resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==}
hasBin: true
jsesc@3.1.0: jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -2649,6 +2695,9 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
swagger-ui-dist@5.32.13:
resolution: {integrity: sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==}
synckit@0.11.13: synckit@0.11.13:
resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==}
engines: {node: ^14.18.0 || >=16.0.0} engines: {node: ^14.18.0 || >=16.0.0}
@ -3380,6 +3429,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {} '@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.16.0': {}
'@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies: dependencies:
'@emnapi/core': 1.10.0 '@emnapi/core': 1.10.0
@ -3412,6 +3463,11 @@ snapshots:
optionalDependencies: optionalDependencies:
'@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1) '@nestjs/platform-express': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)
'@nestjs/mapped-types@2.1.1(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
reflect-metadata: 0.2.2
'@nestjs/platform-express@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)': '@nestjs/platform-express@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(supports-color@8.1.1)':
dependencies: dependencies:
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
@ -3424,6 +3480,18 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@nestjs/swagger@11.4.7(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(reflect-metadata@0.2.2)':
dependencies:
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
'@nestjs/core': 11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/platform-express@11.2.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.1.1(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)
js-yaml: 5.3.0
lodash: 4.18.1
path-to-regexp: 8.4.2
reflect-metadata: 0.2.2
swagger-ui-dist: 5.32.13
'@nestjs/testing@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)': '@nestjs/testing@11.2.3(@nestjs/common@11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3)(@nestjs/platform-express@11.2.3)':
dependencies: dependencies:
'@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1) '@nestjs/common': 11.2.3(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)
@ -3659,6 +3727,8 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.2.18 '@types/react': 19.2.18
'@scarf/scarf@1.4.0': {}
'@sinclair/typebox@0.34.52': {} '@sinclair/typebox@0.34.52': {}
'@sinonjs/commons@3.0.1': '@sinonjs/commons@3.0.1':
@ -4022,6 +4092,8 @@ snapshots:
dependencies: dependencies:
sprintf-js: 1.0.3 sprintf-js: 1.0.3
argparse@2.0.1: {}
asap@2.0.6: {} asap@2.0.6: {}
asynckit@0.4.0: {} asynckit@0.4.0: {}
@ -5045,6 +5117,10 @@ snapshots:
argparse: 1.0.10 argparse: 1.0.10
esprima: 4.0.1 esprima: 4.0.1
js-yaml@5.3.0:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {} jsesc@3.1.0: {}
json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@2.3.1: {}
@ -5586,6 +5662,10 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
swagger-ui-dist@5.32.13:
dependencies:
'@scarf/scarf': 1.4.0
synckit@0.11.13: synckit@0.11.13:
dependencies: dependencies:
'@pkgr/core': 0.3.6 '@pkgr/core': 0.3.6

View File

@ -1,6 +1,7 @@
allowBuilds: allowBuilds:
'@parcel/watcher': true '@parcel/watcher': true
'@prisma/engines': true '@prisma/engines': true
'@scarf/scarf': false
esbuild: true esbuild: true
prisma: true prisma: true
unrs-resolver: true unrs-resolver: true

View File

@ -3,7 +3,7 @@ import { z } from 'zod';
import { AppError } from './errors/app-error'; import { AppError } from './errors/app-error';
export class SchemaPipe<T> implements PipeTransform<unknown, T> { export class SchemaPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: z.ZodType<T>) {} constructor(readonly schema: z.ZodType<T>) {}
transform(value: unknown): T { transform(value: unknown): T {
const result = this.schema.safeParse(value); const result = this.schema.safeParse(value);
if (!result.success) { if (!result.success) {

View File

@ -8,6 +8,10 @@ const schema = z
.enum(['development', 'test', 'production']) .enum(['development', 'test', 'production'])
.default('development'), .default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000), PORT: z.coerce.number().int().min(1).max(65535).default(3000),
SWAGGER_ENABLED: z
.enum(['true', 'false'])
.transform((value) => value === 'true')
.optional(),
DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10), DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(50).default(10),
DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)), DATABASE_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)),
CORS_ORIGINS: z CORS_ORIGINS: z

View File

@ -0,0 +1,132 @@
import 'reflect-metadata';
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { z } from 'zod';
import { configureApp } from '../configure-app';
import { parseEnvironment } from '../config/environment';
import { SchemaPipe } from '../common/validation.pipe';
import { Public } from '../identity/access.decorator';
import { configureSwagger } from './configure-swagger';
@Controller('sample')
class SampleController {
@Public()
@Post()
create(
@Body(
new SchemaPipe(
z.strictObject({
email: z.email(),
date: z.iso.datetime().transform((value) => new Date(value)),
}),
),
)
input: unknown,
) {
return input;
}
@Get()
list(
@Query(
new SchemaPipe(
z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
}),
),
)
input: unknown,
) {
return input;
}
@Post('logout')
@HttpCode(204)
logout() {}
}
describe('Swagger documentation', () => {
async function fixture(nodeEnv: string, enabled?: string) {
const module = await Test.createTestingModule({
controllers: [SampleController],
}).compile();
const app = module.createNestApplication();
app.useLogger(false);
const env = parseEnvironment({
DATABASE_URL: 'postgresql://local/test',
NODE_ENV: nodeEnv,
SWAGGER_ENABLED: enabled,
});
configureApp(app, env);
configureSwagger(app, env);
await app.init();
return { app, api: request(app.getHttpServer()) };
}
it('serves UI, local assets and accurate input/auth/error documentation', async () => {
const { app, api } = await fixture('development');
try {
const ui = await api.get('/api/docs/').expect(200);
expect(ui.text).toContain('swagger-ui');
expect(ui.headers['content-security-policy']).not.toContain(
'upgrade-insecure-requests',
);
await api.get('/api/docs/swagger-ui-bundle.js').expect(200);
const init = await api.get('/api/docs/swagger-ui-init.js').expect(200);
expect(init.text).toContain('"persistAuthorization": false');
const { body: doc } = await api.get('/api/docs-json').expect(200);
const sample = doc.paths['/api/v1/sample'];
expect(sample.post.security).toEqual([]);
expect(sample.get.security).toEqual([{ bearer: [] }]);
expect(
sample.post.requestBody.content['application/json'].schema,
).toMatchObject({
required: ['email', 'date'],
properties: { email: { format: 'email' }, date: { type: 'string' } },
});
expect(sample.get.parameters).toContainEqual(
expect.objectContaining({
name: 'limit',
in: 'query',
schema: expect.objectContaining({ maximum: 100, default: 20 }),
}),
);
expect(
doc.paths['/api/v1/sample/logout'].post.responses['204'],
).toBeDefined();
expect(doc.components.schemas.ApiError.properties.code.enum).toContain(
'REQUEST_INVALID',
);
expect(
(await api.get('/api/v1/sample')).headers['content-security-policy'],
).toContain('upgrade-insecure-requests');
} finally {
await app.close();
}
});
it.each([
['production', undefined],
['test', undefined],
['development', 'false'],
])('hides docs in %s when enabled=%s', async (mode, enabled) => {
const { app, api } = await fixture(mode!, enabled);
try {
await api.get('/api/docs-json').expect(404);
await api.get('/api/docs').expect(404);
} finally {
await app.close();
}
});
it('allows an explicit opt-in and rejects invalid settings', async () => {
const { app, api } = await fixture('production', 'true');
try {
await api.get('/api/docs-json').expect(200);
} finally {
await app.close();
}
expect(() =>
parseEnvironment({
DATABASE_URL: 'postgresql://local/test',
SWAGGER_ENABLED: 'yes',
}),
).toThrow('SWAGGER_ENABLED');
});
});

View File

@ -0,0 +1,51 @@
import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import type { Environment } from '../config/environment';
import { documentErrors } from './document-errors';
import { enrichOperations } from './enrich-operations';
export function configureSwagger(
app: INestApplication,
environment: Environment,
): void {
if (!(environment.SWAGGER_ENABLED ?? environment.NODE_ENV === 'development'))
return;
const config = new DocumentBuilder()
.setTitle('Mani Candles API')
.setDescription(
'Log in using the Auth endpoints, then paste the accessToken into Authorize. Requests run against this server and may change data. Input schemas come from runtime validation; cross-field business rules are enforced by the API. Payment and shipping integrations currently remain test blueprints.',
)
.setVersion('1')
.addBearerAuth({
type: 'http',
scheme: 'bearer',
description: 'Opaque session access token returned by login.',
})
.build();
const document = SwaggerModule.createDocument(app, config, {
operationIdFactory: (controller, method) => `${controller}_${method}`,
});
enrichOperations(app, document);
documentErrors(document);
app.use(
'/api/docs',
helmet.contentSecurityPolicy({
directives: { upgradeInsecureRequests: null },
}),
);
SwaggerModule.setup('api/docs', app, document, {
jsonDocumentUrl: '/api/docs-json',
raw: ['json'],
customSiteTitle: 'Mani Candles API',
swaggerOptions: {
persistAuthorization: false,
validatorUrl: null,
queryConfigEnabled: false,
docExpansion: 'none',
filter: true,
displayRequestDuration: true,
tagsSorter: 'alpha',
},
});
}

View File

@ -0,0 +1,37 @@
import type { OpenAPIObject } from '@nestjs/swagger';
import { ERRORS } from '../common/errors/error-catalog';
export function documentErrors(document: OpenAPIObject): void {
document.components ??= {};
document.components.schemas ??= {};
document.components.schemas.ApiError = {
type: 'object',
required: ['statusCode', 'code', 'message', 'requestId'],
properties: {
statusCode: { type: 'integer' },
code: { type: 'string', enum: Object.keys(ERRORS) },
message: { type: 'string' },
requestId: { type: 'string', format: 'uuid' },
fields: { type: 'array', items: { type: 'string' } },
},
};
for (const path of Object.values(document.paths)) {
for (const operation of Object.values(path)) {
if (
!operation ||
typeof operation !== 'object' ||
!('responses' in operation)
)
continue;
operation.responses.default = {
description:
'Error response. A distinct code identifies the failure; requestId correlates with server logs. Validation errors can include field paths. Possible errors vary by endpoint.',
content: {
'application/json': {
schema: { $ref: '#/components/schemas/ApiError' },
},
},
};
}
}
}

View File

@ -0,0 +1,86 @@
import type { INestApplication } from '@nestjs/common';
import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants';
import { ModulesContainer } from '@nestjs/core';
import type {
OpenAPIObject,
OperationObject,
SchemaObject,
} from '@nestjs/swagger';
import { z } from 'zod';
import { SchemaPipe } from '../common/validation.pipe';
import {
PUBLIC_ROUTE,
REQUIRED_PERMISSION,
} from '../identity/access.decorator';
type Argument = { data?: string; pipes: unknown[] };
/** Reuse runtime validation metadata so documentation cannot drift from input DTOs. */
export function enrichOperations(
app: INestApplication,
document: OpenAPIObject,
): void {
const operations = new Map<string, OperationObject>();
for (const path of Object.values(document.paths)) {
for (const value of Object.values(path)) {
if (value && typeof value === 'object' && 'operationId' in value)
operations.set(value.operationId as string, value as OperationObject);
}
}
for (const module of app.get(ModulesContainer).values()) {
for (const { metatype } of module.controllers.values()) {
if (!metatype) continue;
const prototype = metatype.prototype as Record<string, object>;
for (const method of Object.getOwnPropertyNames(prototype)) {
const operation = operations.get(`${metatype.name}_${method}`);
if (!operation) continue;
const handler = prototype[method];
const isPublic =
Reflect.getMetadata(PUBLIC_ROUTE, handler) ??
Reflect.getMetadata(PUBLIC_ROUTE, metatype);
const permission =
Reflect.getMetadata(REQUIRED_PERMISSION, handler) ??
Reflect.getMetadata(REQUIRED_PERMISSION, metatype);
operation.security = isPublic ? [] : [{ bearer: [] }];
operation.summary = method.replace(/([a-z])([A-Z])/g, '$1 $2');
operation.description = permission
? `Required permission: ${permission}.`
: isPublic
? 'Public endpoint.'
: 'Requires a valid session; access is scoped to the authenticated principal.';
const argumentsMetadata: Record<string, Argument> =
Reflect.getMetadata(ROUTE_ARGS_METADATA, metatype, method) ?? {};
for (const [key, argument] of Object.entries(argumentsMetadata)) {
const pipe = argument.pipes.find(
(candidate) => candidate instanceof SchemaPipe,
);
if (!(pipe instanceof SchemaPipe)) continue;
const schema = z.toJSONSchema(pipe.schema, {
target: 'openapi-3.0',
io: 'input',
}) as SchemaObject;
if (key.startsWith('3:')) {
operation.requestBody = {
required: true,
content: { 'application/json': { schema } },
};
} else if (key.startsWith('4:')) {
operation.parameters = (operation.parameters ?? []).filter(
(item) => '$ref' in item || item.in !== 'query',
);
for (const [name, property] of Object.entries(
schema.properties ?? {},
)) {
operation.parameters.push({
name,
in: 'query',
required: schema.required?.includes(name) ?? false,
schema: property,
});
}
}
}
}
}
}
}

View File

@ -5,11 +5,13 @@ import { AppModule } from './app.module';
import { ENVIRONMENT } from './config/environment.module'; import { ENVIRONMENT } from './config/environment.module';
import type { Environment } from './config/environment'; import type { Environment } from './config/environment';
import { configureApp } from './configure-app'; import { configureApp } from './configure-app';
import { configureSwagger } from './documentation/configure-swagger';
async function bootstrap(): Promise<void> { async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const environment = app.get<Environment>(ENVIRONMENT); const environment = app.get<Environment>(ENVIRONMENT);
configureApp(app, environment); configureApp(app, environment);
configureSwagger(app, environment);
await app.listen(environment.PORT); await app.listen(environment.PORT);
} }

View File

@ -9,9 +9,10 @@ import { DatabaseService } from '../../src/database/database.service';
import { BootstrapService } from '../../src/identity/bootstrap.service'; import { BootstrapService } from '../../src/identity/bootstrap.service';
import { RecoveryMailer } from '../../src/identity/recovery-mailer'; import { RecoveryMailer } from '../../src/identity/recovery-mailer';
import { configureApp } from '../../src/configure-app'; import { configureApp } from '../../src/configure-app';
import { configureSwagger } from '../../src/documentation/configure-swagger';
export const ownerPassword = 'correct horse battery staple'; export const ownerPassword = 'correct horse battery staple';
export async function identityApp() { export async function identityApp(swagger = false) {
const database = await testDatabase(); const database = await testDatabase();
const env = parseEnvironment({ const env = parseEnvironment({
DATABASE_URL: database.connectionUrl, DATABASE_URL: database.connectionUrl,
@ -31,6 +32,7 @@ export async function identityApp() {
const app = module.createNestApplication(); const app = module.createNestApplication();
app.useLogger(false); app.useLogger(false);
configureApp(app, env); configureApp(app, env);
if (swagger) configureSwagger(app, { ...env, SWAGGER_ENABLED: true });
await app.init(); await app.init();
const db = app.get(DatabaseService); const db = app.get(DatabaseService);
const owner = await app.get(BootstrapService).createOwner('Mani Candles', { const owner = await app.get(BootstrapService).createOwner('Mani Candles', {

35
test/swagger.spec.ts Normal file
View File

@ -0,0 +1,35 @@
import { identityApp } from './helpers/identity-app';
describe('Application OpenAPI', () => {
it('exports all application controllers and accepts a documented login', async () => {
const fixture = await identityApp(true);
try {
const { body: doc } = await fixture
.api()
.get('/api/docs-json')
.expect(200);
expect(Object.keys(doc.paths).length).toBeGreaterThan(30);
expect(
doc.paths['/api/v1/auth/login'].post.requestBody.content[
'application/json'
].schema.required,
).toEqual(
expect.arrayContaining(['organizationId', 'email', 'password']),
);
const login = await fixture.login();
await fixture
.api()
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${login.body.accessToken}`)
.expect(200);
for (const path of Object.values(doc.paths) as Record<string, any>[]) {
for (const operation of Object.values(path)) {
expect(operation.security).toBeDefined();
expect(operation.responses.default).toBeDefined();
}
}
} finally {
await fixture.close();
}
}, 60000);
});