From 0aedb1753a65bdadea66fc0bb074b8cb27371807 Mon Sep 17 00:00:00 2001 From: ohamamarachi474-del Date: Fri, 25 Sep 2026 12:57:13 +0100 Subject: [PATCH] Refactor OpenAPI setup and add N+1 query detection with unit tests --- .env.example | 9 ++ src/config/api-docs.controller.ts | 2 +- src/config/swagger.config.ts | 45 ++++--- src/database/n1-detector.spec.ts | 176 ++++++++++++++++++++++++ src/database/n1-detector.ts | 186 ++++++++++++++++++++++++++ src/database/prisma.service.ts | 51 +++---- src/main.ts | 7 +- test/e2e/openapi-endpoint.e2e.spec.ts | 74 ++++++++++ 8 files changed, 497 insertions(+), 53 deletions(-) create mode 100644 src/database/n1-detector.spec.ts create mode 100644 src/database/n1-detector.ts create mode 100644 test/e2e/openapi-endpoint.e2e.spec.ts diff --git a/.env.example b/.env.example index 90699611..d01279e9 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,15 @@ PGBOUNCER_POOL_SIZE=20 # Timeout (ms) for acquiring a connection from the pool PGBOUNCER_POOL_TIMEOUT=10000 +# N+1 Query Detection (Issue #911) +# Warns when the same query shape hits the same table DB_N1_THRESHOLD times +# within DB_N1_WINDOW_MS. Unset = on outside production, off in production. +# 'true' opts production in (also enables Prisma query events, small overhead); +# 'false' disables it everywhere. +# DB_N1_DETECTION=true +# DB_N1_WINDOW_MS=100 +# DB_N1_THRESHOLD=5 + # Cache Warming # Set to 'false' to disable startup and periodic cache warming CACHE_WARMING_ENABLED=true diff --git a/src/config/api-docs.controller.ts b/src/config/api-docs.controller.ts index 900ebfeb..c297cdde 100644 --- a/src/config/api-docs.controller.ts +++ b/src/config/api-docs.controller.ts @@ -20,7 +20,7 @@ export class ApiDocsController { */ @Get('openapi.json') getOpenApiSpec(@Res() res: Response) { - // This will be populated by setupSwagger + // Populated by setupOpenAPIEndpoint (called from bootstrap in main.ts) const spec = (res.req.app as AppWithOpenApiDoc).openAPIDocument; if (spec) { diff --git a/src/config/swagger.config.ts b/src/config/swagger.config.ts index 43a58280..094f8d34 100644 --- a/src/config/swagger.config.ts +++ b/src/config/swagger.config.ts @@ -12,7 +12,7 @@ interface AppWithOpenApiDoc { openAPIDocument?: OpenAPIObject; } -export function setupSwagger(app: INestApplication): void { +export function setupSwagger(app: INestApplication): OpenAPIObject { const config = new DocumentBuilder() .setTitle('PropChain API') .setDescription('Blockchain-Powered Real Estate Platform API Documentation') @@ -134,28 +134,35 @@ export function setupSwagger(app: INestApplication): void { }); logger.log('Swagger UI available at http://localhost:3000/api/docs'); + + return document; } /** - * Generate OpenAPI JSON at /api/docs-json endpoint + * Expose the generated OpenAPI document at GET /api/openapi.json. + * + * ApiDocsController reads the spec from the underlying Express app + * (`req.app.openAPIDocument`), so it must be attached to the HTTP adapter's + * instance rather than to the Nest application wrapper. Pass the document + * returned by setupSwagger so both endpoints serve the same spec; if omitted, + * a minimal document is generated. */ -export function setupOpenAPIEndpoint(app: INestApplication): void { - const config = new DocumentBuilder() - .setTitle('PropChain API') - .setDescription('Blockchain-Powered Real Estate Platform API') - .setVersion('2.0.0') - .addBearerAuth( - { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - }, - 'access-token', - ) - .build(); +export function setupOpenAPIEndpoint(app: INestApplication, document?: OpenAPIObject): OpenAPIObject { + const spec = + document ?? + SwaggerModule.createDocument( + app, + new DocumentBuilder() + .setTitle('PropChain API') + .setDescription('Blockchain-Powered Real Estate Platform API') + .setVersion('2.0.0') + .addBearerAuth({ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 'access-token') + .build(), + ); - const document = SwaggerModule.createDocument(app, config); + const httpApp = app.getHttpAdapter().getInstance() as AppWithOpenApiDoc; + httpApp.openAPIDocument = spec; - // Store document in app for access via endpoint - (app as unknown as AppWithOpenApiDoc).openAPIDocument = document; + logger.log('OpenAPI spec available at /api/openapi.json'); + return spec; } diff --git a/src/database/n1-detector.spec.ts b/src/database/n1-detector.spec.ts new file mode 100644 index 00000000..2369cb93 --- /dev/null +++ b/src/database/n1-detector.spec.ts @@ -0,0 +1,176 @@ +import { + N1Detector, + extractCteNames, + extractPrimaryTable, + extractTables, + fingerprintQuery, + isN1DetectionEnabled, + n1OptionsFromEnv, +} from './n1-detector'; + +describe('n1-detector', () => { + describe('extractPrimaryTable', () => { + it('handles Prisma-style schema-qualified quoted identifiers', () => { + const sql = + 'SELECT "public"."User"."id", "public"."User"."email" FROM "public"."User" WHERE "public"."User"."id" = $1 LIMIT $2 OFFSET $3'; + expect(extractPrimaryTable(sql)).toBe('User'); + }); + + it('handles unquoted tables', () => { + expect(extractPrimaryTable('select * from users where id = $1')).toBe('users'); + }); + + it('returns the driving table of a joined FROM', () => { + const sql = + 'SELECT t.* FROM "public"."Property" t INNER JOIN "public"."User" u ON u."id" = t."ownerId" WHERE t."id" = $1'; + expect(extractPrimaryTable(sql)).toBe('Property'); + expect(extractTables(sql)).toEqual(['Property', 'User']); + }); + + it('looks through a FROM (…) subquery to the real table', () => { + const sql = 'SELECT sub.id FROM (SELECT "id" FROM "public"."Transaction" WHERE "amount" > $1) AS sub'; + expect(extractPrimaryTable(sql)).toBe('Transaction'); + }); + + it('ignores CTE names and attributes to the table inside the CTE', () => { + const sql = ` + WITH recent AS (SELECT * FROM "public"."Order" WHERE "createdAt" > $1), + "totals" AS (SELECT "userId", sum("amount") FROM recent GROUP BY "userId") + SELECT * FROM "totals" JOIN recent ON recent."userId" = "totals"."userId"`; + expect(extractCteNames(sql)).toEqual(new Set(['recent', 'totals'])); + expect(extractTables(sql)).toEqual(['Order']); + expect(extractPrimaryTable(sql)).toBe('Order'); + }); + + it('supports WITH RECURSIVE and column lists', () => { + const sql = + 'WITH RECURSIVE tree(id, parent) AS (SELECT id, parent FROM "Category" UNION ALL SELECT c.id, c.parent FROM "Category" c JOIN tree ON c.parent = tree.id) SELECT * FROM tree'; + expect(extractTables(sql)).toEqual(['Category', 'Category']); + }); + + it('ignores comments and string literals', () => { + const sql = `/* FROM "Ghost" */ -- FROM "Phantom" + SELECT * FROM "public"."Document" WHERE "note" = 'copied FROM Other'`; + expect(extractTables(sql)).toEqual(['Document']); + }); + + it('ignores FROM inside EXTRACT / SUBSTRING', () => { + const sql = + 'SELECT EXTRACT(YEAR FROM "createdAt"), SUBSTRING("name" FROM 1 FOR 3) FROM "public"."Session"'; + expect(extractPrimaryTable(sql)).toBe('Session'); + }); + + it('handles INSERT, UPDATE and DELETE', () => { + expect(extractPrimaryTable('INSERT INTO "public"."AuditLog" ("id") VALUES ($1)')).toBe('AuditLog'); + expect(extractPrimaryTable('UPDATE "public"."User" SET "name" = $1 WHERE "id" = $2')).toBe('User'); + expect(extractPrimaryTable('DELETE FROM "public"."Session" WHERE "id" = $1')).toBe('Session'); + }); + + it('returns null for statements without a table', () => { + expect(extractPrimaryTable('SELECT 1')).toBeNull(); + expect(extractPrimaryTable('BEGIN')).toBeNull(); + }); + }); + + describe('fingerprintQuery', () => { + it('maps the same statement with different params to one fingerprint', () => { + const a = fingerprintQuery('SELECT * FROM "User" WHERE "id" = $1 LIMIT 10'); + const b = fingerprintQuery('SELECT * FROM "User" WHERE "id" = $1 LIMIT 25'); + expect(a).toBe(b); + }); + + it('collapses IN-lists of any length', () => { + expect(fingerprintQuery('SELECT * FROM "User" WHERE "id" IN ($1,$2,$3)')).toBe( + fingerprintQuery('SELECT * FROM "User" WHERE "id" IN ($1, $2)'), + ); + }); + + it('distinguishes different statements on the same table', () => { + expect(fingerprintQuery('SELECT * FROM "User" WHERE "id" = $1')).not.toBe( + fingerprintQuery('SELECT * FROM "User" WHERE "email" = $1'), + ); + }); + }); + + describe('N1Detector', () => { + const q = (id: number) => `SELECT * FROM "public"."Property" WHERE "ownerId" = $1 /* ${id} */`; + + it('fires exactly once when the threshold is reached within the window', () => { + const detector = new N1Detector({ windowMs: 100, threshold: 5 }); + const results = [0, 10, 20, 30, 40, 50].map((t, i) => detector.record(q(i), 1000 + t)); + expect(results.slice(0, 4)).toEqual([null, null, null, null]); + expect(results[4]).toMatchObject({ table: 'Property', count: 5, windowMs: 100 }); + expect(results[5]).toBeNull(); + }); + + it('does not fire when repetitions are spread beyond the window', () => { + const detector = new N1Detector({ windowMs: 100, threshold: 3 }); + const results = [0, 60, 120, 180, 240].map((t, i) => detector.record(q(i), t)); + expect(results.every((r) => r === null)).toBe(true); + }); + + it('does not flag a legitimate batch of distinct queries on the same table', () => { + const detector = new N1Detector({ windowMs: 100, threshold: 3 }); + const batch = [ + 'SELECT count(*) FROM "User"', + 'SELECT * FROM "User" WHERE "id" = $1', + 'SELECT * FROM "User" WHERE "email" = $1', + 'SELECT * FROM "User" WHERE "id" IN ($1,$2,$3,$4)', + 'UPDATE "User" SET "lastSeen" = $1 WHERE "id" = $2', + ]; + expect(batch.map((sql) => detector.record(sql, 0)).every((r) => r === null)).toBe(true); + }); + + it('ignores statements without a table', () => { + const detector = new N1Detector({ windowMs: 100, threshold: 2 }); + expect(detector.record('SELECT 1', 0)).toBeNull(); + expect(detector.record('SELECT 1', 1)).toBeNull(); + expect(detector.size).toBe(0); + }); + + it('sweeps stale keys once maxKeys is exceeded', () => { + const detector = new N1Detector({ windowMs: 10, threshold: 5, maxKeys: 2 }); + detector.record('SELECT * FROM "A"', 0); + detector.record('SELECT * FROM "B"', 0); + detector.record('SELECT * FROM "C"', 100); + expect(detector.size).toBe(1); + }); + }); + + describe('isN1DetectionEnabled', () => { + it('is on by default outside production', () => { + expect(isN1DetectionEnabled({ NODE_ENV: 'development' })).toBe(true); + expect(isN1DetectionEnabled({})).toBe(true); + }); + + it('is off by default in production', () => { + expect(isN1DetectionEnabled({ NODE_ENV: 'production' })).toBe(false); + }); + + it('can be opted into in production with DB_N1_DETECTION=true', () => { + expect(isN1DetectionEnabled({ NODE_ENV: 'production', DB_N1_DETECTION: 'true' })).toBe(true); + expect(isN1DetectionEnabled({ NODE_ENV: 'production', DB_N1_DETECTION: '1' })).toBe(true); + }); + + it('can be disabled everywhere with DB_N1_DETECTION=false', () => { + expect(isN1DetectionEnabled({ NODE_ENV: 'development', DB_N1_DETECTION: 'false' })).toBe(false); + }); + }); + + describe('n1OptionsFromEnv', () => { + it('uses defaults when unset or invalid', () => { + expect(n1OptionsFromEnv({})).toEqual({ windowMs: 100, threshold: 5 }); + expect(n1OptionsFromEnv({ DB_N1_WINDOW_MS: 'abc', DB_N1_THRESHOLD: '1' })).toEqual({ + windowMs: 100, + threshold: 5, + }); + }); + + it('reads overrides', () => { + expect(n1OptionsFromEnv({ DB_N1_WINDOW_MS: '250', DB_N1_THRESHOLD: '10' })).toEqual({ + windowMs: 250, + threshold: 10, + }); + }); + }); +}); diff --git a/src/database/n1-detector.ts b/src/database/n1-detector.ts new file mode 100644 index 00000000..0a5d1f52 --- /dev/null +++ b/src/database/n1-detector.ts @@ -0,0 +1,186 @@ +/** + * N+1 query detector (Issue #911). + * + * Pure, dependency-free helpers used by PrismaService to spot the same query + * shape being fired against the same table many times in a short window — + * the classic signature of an N+1 access pattern. + * + * Kept separate from PrismaService so the SQL classification and windowing + * logic can be unit tested without a database. + */ + +export interface N1DetectorOptions { + /** Rolling window length in milliseconds. */ + windowMs: number; + /** Number of repetitions within the window that triggers a detection. */ + threshold: number; + /** Upper bound on tracked keys before stale entries are swept. */ + maxKeys?: number; +} + +export interface N1Detection { + table: string; + count: number; + windowMs: number; + fingerprint: string; +} + +export const N1_DEFAULT_WINDOW_MS = 100; +export const N1_DEFAULT_THRESHOLD = 5; +const N1_DEFAULT_MAX_KEYS = 1000; + +/** SQL functions whose argument syntax contains FROM but is not a table reference. */ +const FROM_KEYWORD_FUNCTIONS = /\b(?:EXTRACT|SUBSTRING|TRIM|POSITION|OVERLAY)\s*\([^()]*\)/gi; + +/** A possibly schema-qualified, possibly quoted identifier, e.g. "public"."User". */ +const IDENT = String.raw`(?:"(?:[^"]|"")+"|[A-Za-z_][\w$]*)`; +const QUALIFIED_IDENT = String.raw`${IDENT}(?:\s*\.\s*${IDENT})*`; + +const TABLE_REF = new RegExp( + String.raw`\b(FROM|JOIN|INTO|UPDATE)\s+(\(|${QUALIFIED_IDENT})`, + 'gi', +); +const CTE_NAME = new RegExp( + String.raw`(?:\bWITH(?:\s+RECURSIVE)?|,)\s*(${IDENT})\s*(?:\([^)]*\)\s*)?AS\s*(?:NOT\s+)?(?:MATERIALIZED\s*)?\(`, + 'gi', +); + +/** Remove comments and string literals so they cannot produce false matches. */ +export function stripSqlNoise(sql: string): string { + return sql + .replace(/\/\*[\s\S]*?\*\//g, ' ') + .replace(/--[^\n]*/g, ' ') + .replace(/'(?:[^']|'')*'/g, "''") + .replace(FROM_KEYWORD_FUNCTIONS, ' '); +} + +/** Last segment of a (schema-qualified) identifier, with quotes removed. */ +function unquote(ident: string): string { + const match = ident.match(/(?:"((?:[^"]|"")+)"|([A-Za-z_][\w$]*))\s*$/); + if (!match) return ident.trim(); + return match[1] !== undefined ? match[1].replace(/""/g, '"') : match[2]; +} + +/** Names defined by a leading WITH clause; references to these are not real tables. */ +export function extractCteNames(sql: string): Set { + const cleaned = stripSqlNoise(sql); + const names = new Set(); + if (!/^\s*WITH\b/i.test(cleaned)) return names; + for (const match of cleaned.matchAll(CTE_NAME)) { + names.add(unquote(match[1]).toLowerCase()); + } + return names; +} + +/** + * Return every real table referenced by FROM / JOIN / INTO / UPDATE, in order + * of appearance, with schema qualifiers dropped. Subquery openings and CTE + * names are skipped. + */ +export function extractTables(sql: string): string[] { + const cleaned = stripSqlNoise(sql); + const ctes = extractCteNames(sql); + const tables: string[] = []; + for (const match of cleaned.matchAll(TABLE_REF)) { + const target = match[2]; + if (target === '(') continue; + const name = unquote(target); + if (ctes.has(name.toLowerCase())) continue; + tables.push(name); + } + return tables; +} + +/** + * The table an N+1 warning should be attributed to: the first real table + * referenced by the statement (the driving table for SELECT, the target for + * INSERT/UPDATE/DELETE). + */ +export function extractPrimaryTable(sql: string): string | null { + return extractTables(sql)[0] ?? null; +} + +/** + * Normalise a query to its shape so the same statement with different + * parameters maps to the same key, while distinct statements against the same + * table (a legitimate batch) do not. + */ +export function fingerprintQuery(sql: string): string { + return stripSqlNoise(sql) + .replace(/\$\d+/g, '?') + .replace(/\b\d+(?:\.\d+)?\b/g, '?') + .replace(/''/g, '?') + .replace(/\(\s*\?(?:\s*,\s*\?)*\s*\)/g, '(?)') + .replace(/\s+/g, ' ') + .trim() + .toLowerCase(); +} + +/** + * Rolling-window counter. `record` returns a detection exactly once per burst: + * when a (table, query shape) pair reaches the threshold within the window. + */ +export class N1Detector { + private readonly windowMs: number; + private readonly threshold: number; + private readonly maxKeys: number; + private readonly window = new Map(); + + constructor(options: Partial = {}) { + this.windowMs = options.windowMs ?? N1_DEFAULT_WINDOW_MS; + this.threshold = options.threshold ?? N1_DEFAULT_THRESHOLD; + this.maxKeys = options.maxKeys ?? N1_DEFAULT_MAX_KEYS; + } + + record(sql: string, now: number = Date.now()): N1Detection | null { + const table = extractPrimaryTable(sql); + if (!table) return null; + + const fingerprint = fingerprintQuery(sql); + const key = `${table}\u0000${fingerprint}`; + const timestamps = (this.window.get(key) ?? []).filter((t) => now - t < this.windowMs); + timestamps.push(now); + this.window.set(key, timestamps); + + if (this.window.size > this.maxKeys) this.sweep(now); + + if (timestamps.length === this.threshold) { + return { table, count: timestamps.length, windowMs: this.windowMs, fingerprint }; + } + return null; + } + + /** Number of tracked (table, shape) keys — exposed for tests. */ + get size(): number { + return this.window.size; + } + + private sweep(now: number): void { + for (const [key, timestamps] of this.window) { + if (timestamps.every((t) => now - t >= this.windowMs)) this.window.delete(key); + } + } +} + +/** + * Resolve whether N+1 detection is active. + * + * DB_N1_DETECTION=true → on (including production — opt-in) + * DB_N1_DETECTION=false → off everywhere + * unset → on outside production, off in production + */ +export function isN1DetectionEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const flag = env.DB_N1_DETECTION?.trim().toLowerCase(); + if (flag === 'true' || flag === '1') return true; + if (flag === 'false' || flag === '0') return false; + return env.NODE_ENV !== 'production'; +} + +export function n1OptionsFromEnv(env: NodeJS.ProcessEnv = process.env): N1DetectorOptions { + const windowMs = parseInt(env.DB_N1_WINDOW_MS ?? '', 10); + const threshold = parseInt(env.DB_N1_THRESHOLD ?? '', 10); + return { + windowMs: windowMs > 0 ? windowMs : N1_DEFAULT_WINDOW_MS, + threshold: threshold > 1 ? threshold : N1_DEFAULT_THRESHOLD, + }; +} diff --git a/src/database/prisma.service.ts b/src/database/prisma.service.ts index 91fdd928..d23f0164 100644 --- a/src/database/prisma.service.ts +++ b/src/database/prisma.service.ts @@ -15,6 +15,7 @@ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +import { N1Detector, isN1DetectionEnabled, n1OptionsFromEnv } from './n1-detector'; const POOL_SIZE_DEFAULT = 10; const POOL_TIMEOUT_MS = 10_000; @@ -82,9 +83,16 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul const isProduction = process.env.NODE_ENV === 'production'; - // In development we emit all log levels; in production only errors/warnings. + // Issue #911 – N+1 detection is on by default outside production and can + // be opted into in production with DB_N1_DETECTION=true (see .env.example). + const n1DetectionEnabled = isN1DetectionEnabled(); + + // In development we emit all log levels; in production only errors/warnings, + // plus query events when N+1 detection has been opted into. const logLevels = isProduction - ? (['error', 'warn'] as const) + ? n1DetectionEnabled + ? (['error', 'warn', 'query'] as const) + : (['error', 'warn'] as const) : (['error', 'warn', 'info', 'query'] as const); super({ @@ -107,14 +115,9 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul // ── Query event logging & slow query detection (#917) ───────────────── const slowThreshold = isProduction ? SLOW_QUERY_THRESHOLD_PROD : SLOW_QUERY_THRESHOLD_DEV; - // Issue #911 – N+1 detection: track how many queries are fired in a short - // rolling window per table. If the same table is queried more than the - // N1_REPETITION_THRESHOLD times within N1_WINDOW_MS milliseconds we emit a - // warning so the pattern can be caught in development before it reaches - // production. - const N1_WINDOW_MS = 100; - const N1_REPETITION_THRESHOLD = 5; - const queryWindow: Map = new Map(); + // Issue #911 – N+1 detection: the same query shape hitting the same table + // repeatedly within a short window. Classification lives in n1-detector.ts. + const n1Detector = n1DetectionEnabled ? new N1Detector(n1OptionsFromEnv()) : null; // eslint-disable-next-line @typescript-eslint/no-explicit-any (this as any).$on('query', (event: { query: string; params: string; duration: number }) => { @@ -140,26 +143,14 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul this.logger.debug(`[Query] ${duration}ms`); } - // Issue #911 – N+1 detection (development + staging only; skipped in - // production to avoid overhead in hot paths). - if (!isProduction) { - // Extract the primary table name from the query (heuristic: first word - // after SELECT/INSERT/UPDATE/DELETE ... FROM/INTO/UPDATE). - const tableMatch = query.match(/(?:FROM|INTO|UPDATE)\s+"?(\w+)"?/i); - if (tableMatch) { - const table = tableMatch[1]; - const now = Date.now(); - const timestamps = (queryWindow.get(table) ?? []).filter((t) => now - t < N1_WINDOW_MS); - timestamps.push(now); - queryWindow.set(table, timestamps); - - if (timestamps.length === N1_REPETITION_THRESHOLD) { - const sanitised = query.replace(/\$\d+/g, '?').substring(0, 200); - this.logger.warn( - `[N+1 Detected] Table "${table}" queried ${timestamps.length} times ` + - `within ${N1_WINDOW_MS}ms. Possible N+1 pattern. Last query: ${sanitised}`, - ); - } + if (n1Detector) { + const detection = n1Detector.record(query); + if (detection) { + const sanitised = query.replace(/\$\d+/g, '?').substring(0, 200); + this.logger.warn( + `[N+1 Detected] Table "${detection.table}" queried ${detection.count} times ` + + `within ${detection.windowMs}ms. Possible N+1 pattern. Last query: ${sanitised}`, + ); } } }); diff --git a/src/main.ts b/src/main.ts index 2c70e133..d1c181ad 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,7 +10,7 @@ import { RateLimitGuard } from './auth/guards/rate-limit.guard'; import { RateLimitService } from './auth/rate-limit.service'; import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor'; import { ResponseFormatInterceptor } from './common/interceptors/response-format.interceptor'; -import { setupSwagger } from './config/swagger.config'; +import { setupOpenAPIEndpoint, setupSwagger } from './config/swagger.config'; import { validateEnvironment } from './utils/validate-env'; // Issue #914 – Structured JSON logging in production, pretty-print in dev import { AppLogger } from './common/logger'; @@ -161,8 +161,9 @@ async function bootstrap() { const rateLimitService = app.get(RateLimitService); app.useGlobalGuards(new RateLimitGuard(reflector, rateLimitService)); - // Setup Swagger documentation - setupSwagger(app); + // Setup Swagger documentation and serve the same spec at /api/openapi.json + const openApiDocument = setupSwagger(app); + setupOpenAPIEndpoint(app, openApiDocument); app.enableShutdownHooks(); diff --git a/test/e2e/openapi-endpoint.e2e.spec.ts b/test/e2e/openapi-endpoint.e2e.spec.ts new file mode 100644 index 00000000..9d760931 --- /dev/null +++ b/test/e2e/openapi-endpoint.e2e.spec.ts @@ -0,0 +1,74 @@ +/** + * E2E test: GET /api/openapi.json serves the generated OpenAPI spec. + * + * Previously setupOpenAPIEndpoint was never called and stored the document on + * the Nest app wrapper instead of the Express app, so ApiDocsController + * always returned 404. + */ + +import { Controller, Get, INestApplication } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { Test } from '@nestjs/testing'; +import * as request from 'supertest'; +import { ApiDocsController } from '../../src/config/api-docs.controller'; +import { setupOpenAPIEndpoint, setupSwagger } from '../../src/config/swagger.config'; + +@ApiTags('Properties') +@Controller('properties') +class SampleController { + @Get() + @ApiOkResponse({ description: 'List properties' }) + list() { + return []; + } +} + +async function createApp(): Promise { + const moduleRef = await Test.createTestingModule({ + controllers: [ApiDocsController, SampleController], + }).compile(); + const app = moduleRef.createNestApplication({ logger: false }); + return app; +} + +describe('GET /api/openapi.json', () => { + let app: INestApplication; + + afterEach(async () => { + await app?.close(); + }); + + it('returns the document generated by setupSwagger', async () => { + app = await createApp(); + const document = setupSwagger(app); + setupOpenAPIEndpoint(app, document); + await app.init(); + + const res = await request(app.getHttpServer()).get('/api/openapi.json').expect(200); + + expect(res.body.openapi).toMatch(/^3\./); + expect(res.body.info).toMatchObject({ title: 'PropChain API', version: '2.0.0' }); + expect(res.body.paths).toHaveProperty('/properties'); + expect(res.body.components?.securitySchemes).toHaveProperty('access-token'); + // The ApiDocsController is excluded from the spec itself + expect(res.body.paths).not.toHaveProperty('/api/openapi.json'); + }); + + it('generates a spec when called without a document', async () => { + app = await createApp(); + setupOpenAPIEndpoint(app); + await app.init(); + + const res = await request(app.getHttpServer()).get('/api/openapi.json').expect(200); + + expect(res.body.openapi).toMatch(/^3\./); + expect(res.body.paths).toHaveProperty('/properties'); + }); + + it('returns 404 when the endpoint was never set up', async () => { + app = await createApp(); + await app.init(); + + await request(app.getHttpServer()).get('/api/openapi.json').expect(404); + }); +});