diff --git a/.env.example b/.env.example index 56cb8b7..295e27e 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,6 @@ RECOVERY_TTL_MINUTES=15 # SMTP_FROM=support@example.com # RECOVERY_URL=https://shop.example.com/reset DATABASE_POOL_SIZE=10 + +# Optional; defaults to true only in development. +# SWAGGER_ENABLED=true diff --git a/README.md b/README.md index 8adcf9b..3462ada 100644 --- a/README.md +++ b/README.md @@ -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 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. + +### 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. diff --git a/docs/swagger.md b/docs/swagger.md new file mode 100644 index 0000000..6abb92c --- /dev/null +++ b/docs/swagger.md @@ -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. diff --git a/package.json b/package.json index 98ad812..5a71f5e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@nestjs/common": "^11.1.0", "@nestjs/core": "^11.1.0", "@nestjs/platform-express": "^11.1.0", + "@nestjs/swagger": "^11.4.7", "@prisma/adapter-pg": "^7.0.0", "@prisma/client": "^7.0.0", "dotenv": "^17.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 45edf04..03f8410 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@nestjs/platform-express': 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) + '@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': specifier: ^7.0.0 version: 7.10.0 @@ -610,6 +613,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.16.0': + resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -648,12 +654,42 @@ packages: '@nestjs/websockets': 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': resolution: {integrity: sha512-YFQvRXT2de1qNL9LJPUBQ31+RsfI4cJ+sbpU9ENM/hDCgoHSEhm7oxUuGGKmhTZBNZEYm8mDYdfoTFmAH1LIJg==} peerDependencies: '@nestjs/common': ^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': resolution: {integrity: sha512-7ANDWlkm8Xw4CYIhCNZhtBzANsQUKqjteA2yx/6sjqGyWhekeBKz8wgCJykm0vo+ltrg6U34dZlm2NgiRcNHPQ==} peerDependencies: @@ -901,6 +937,9 @@ packages: '@types/react': optional: true + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@sinclair/typebox@0.34.52': resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} @@ -1245,6 +1284,9 @@ packages: argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} @@ -2051,6 +2093,10 @@ packages: resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} hasBin: true + js-yaml@5.3.0: + resolution: {integrity: sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2649,6 +2695,9 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + swagger-ui-dist@5.32.13: + resolution: {integrity: sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==} + synckit@0.11.13: resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} engines: {node: ^14.18.0 || >=16.0.0} @@ -3380,6 +3429,8 @@ snapshots: '@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)': dependencies: '@emnapi/core': 1.10.0 @@ -3412,6 +3463,11 @@ snapshots: 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/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)': dependencies: '@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: - 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)': dependencies: '@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: '@types/react': 19.2.18 + '@scarf/scarf@1.4.0': {} + '@sinclair/typebox@0.34.52': {} '@sinonjs/commons@3.0.1': @@ -4022,6 +4092,8 @@ snapshots: dependencies: sprintf-js: 1.0.3 + argparse@2.0.1: {} + asap@2.0.6: {} asynckit@0.4.0: {} @@ -5045,6 +5117,10 @@ snapshots: argparse: 1.0.10 esprima: 4.0.1 + js-yaml@5.3.0: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json-parse-even-better-errors@2.3.1: {} @@ -5586,6 +5662,10 @@ snapshots: dependencies: has-flag: 4.0.0 + swagger-ui-dist@5.32.13: + dependencies: + '@scarf/scarf': 1.4.0 + synckit@0.11.13: dependencies: '@pkgr/core': 0.3.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ecea8f6..3bf832a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ allowBuilds: '@parcel/watcher': true '@prisma/engines': true + '@scarf/scarf': false esbuild: true prisma: true unrs-resolver: true diff --git a/src/common/validation.pipe.ts b/src/common/validation.pipe.ts index 5c0a2b2..071123b 100644 --- a/src/common/validation.pipe.ts +++ b/src/common/validation.pipe.ts @@ -3,7 +3,7 @@ import { z } from 'zod'; import { AppError } from './errors/app-error'; export class SchemaPipe implements PipeTransform { - constructor(private readonly schema: z.ZodType) {} + constructor(readonly schema: z.ZodType) {} transform(value: unknown): T { const result = this.schema.safeParse(value); if (!result.success) { diff --git a/src/config/environment.ts b/src/config/environment.ts index f127e39..a66feac 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -8,6 +8,10 @@ const schema = z .enum(['development', 'test', 'production']) .default('development'), 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_URL: z.url().refine((value) => /^postgres(ql)?:/.test(value)), CORS_ORIGINS: z diff --git a/src/documentation/configure-swagger.spec.ts b/src/documentation/configure-swagger.spec.ts new file mode 100644 index 0000000..4f6d718 --- /dev/null +++ b/src/documentation/configure-swagger.spec.ts @@ -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'); + }); +}); diff --git a/src/documentation/configure-swagger.ts b/src/documentation/configure-swagger.ts new file mode 100644 index 0000000..7cc7c0f --- /dev/null +++ b/src/documentation/configure-swagger.ts @@ -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', + }, + }); +} diff --git a/src/documentation/document-errors.ts b/src/documentation/document-errors.ts new file mode 100644 index 0000000..afc883c --- /dev/null +++ b/src/documentation/document-errors.ts @@ -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' }, + }, + }, + }; + } + } +} diff --git a/src/documentation/enrich-operations.ts b/src/documentation/enrich-operations.ts new file mode 100644 index 0000000..c0a8cbc --- /dev/null +++ b/src/documentation/enrich-operations.ts @@ -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(); + 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; + 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 = + 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, + }); + } + } + } + } + } + } +} diff --git a/src/main.ts b/src/main.ts index 651a877..d767ba1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,11 +5,13 @@ import { AppModule } from './app.module'; import { ENVIRONMENT } from './config/environment.module'; import type { Environment } from './config/environment'; import { configureApp } from './configure-app'; +import { configureSwagger } from './documentation/configure-swagger'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); const environment = app.get(ENVIRONMENT); configureApp(app, environment); + configureSwagger(app, environment); await app.listen(environment.PORT); } diff --git a/test/helpers/identity-app.ts b/test/helpers/identity-app.ts index de1b80b..dac7069 100644 --- a/test/helpers/identity-app.ts +++ b/test/helpers/identity-app.ts @@ -9,9 +9,10 @@ import { DatabaseService } from '../../src/database/database.service'; import { BootstrapService } from '../../src/identity/bootstrap.service'; import { RecoveryMailer } from '../../src/identity/recovery-mailer'; import { configureApp } from '../../src/configure-app'; +import { configureSwagger } from '../../src/documentation/configure-swagger'; export const ownerPassword = 'correct horse battery staple'; -export async function identityApp() { +export async function identityApp(swagger = false) { const database = await testDatabase(); const env = parseEnvironment({ DATABASE_URL: database.connectionUrl, @@ -31,6 +32,7 @@ export async function identityApp() { const app = module.createNestApplication(); app.useLogger(false); configureApp(app, env); + if (swagger) configureSwagger(app, { ...env, SWAGGER_ENABLED: true }); await app.init(); const db = app.get(DatabaseService); const owner = await app.get(BootstrapService).createOwner('Mani Candles', { diff --git a/test/swagger.spec.ts b/test/swagger.spec.ts new file mode 100644 index 0000000..c35a577 --- /dev/null +++ b/test/swagger.spec.ts @@ -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[]) { + for (const operation of Object.values(path)) { + expect(operation.security).toBeDefined(); + expect(operation.responses.default).toBeDefined(); + } + } + } finally { + await fixture.close(); + } + }, 60000); +});