From 6d3cb34dc037efd39a32d73d3db920195a4ddc57 Mon Sep 17 00:00:00 2001 From: nafiuishaaq Date: Thu, 26 Mar 2026 14:54:14 +0100 Subject: [PATCH 1/5] implemented the patches --- backend/docs/API_VERSIONING.md | 59 ++++++ backend/docs/migrations/v0-to-v1.md | 9 + backend/docs/migrations/v1-to-v2.md | 37 ++++ backend/src/app.module.ts | 18 +- .../versioning/api-version.constants.ts | 39 ++++ .../versioning/api-version.interceptor.ts | 49 +++++ .../versioning/api-version.middleware.spec.ts | 107 ++++++++++ .../versioning/api-version.middleware.ts | 158 +++++++++++++++ .../common/versioning/api-version.service.ts | 94 +++++++++ .../common/versioning/api-version.types.ts | 24 +++ backend/src/common/versioning/index.ts | 6 + .../common/versioning/swagger-versioning.ts | 22 +++ backend/src/docs/docs.controller.ts | 75 +++++++ backend/src/main.ts | 68 +++++-- .../controllers/puzzles-v1.controller.ts | 73 +++++++ .../controllers/puzzles-v2.controller.ts | 185 ++++++++++++++++++ backend/src/puzzles/puzzles.module.ts | 5 +- backend/src/types/express.d.ts | 8 + 18 files changed, 1010 insertions(+), 26 deletions(-) create mode 100644 backend/docs/API_VERSIONING.md create mode 100644 backend/docs/migrations/v0-to-v1.md create mode 100644 backend/docs/migrations/v1-to-v2.md create mode 100644 backend/src/common/versioning/api-version.constants.ts create mode 100644 backend/src/common/versioning/api-version.interceptor.ts create mode 100644 backend/src/common/versioning/api-version.middleware.spec.ts create mode 100644 backend/src/common/versioning/api-version.middleware.ts create mode 100644 backend/src/common/versioning/api-version.service.ts create mode 100644 backend/src/common/versioning/api-version.types.ts create mode 100644 backend/src/common/versioning/index.ts create mode 100644 backend/src/common/versioning/swagger-versioning.ts create mode 100644 backend/src/docs/docs.controller.ts create mode 100644 backend/src/puzzles/controllers/puzzles-v1.controller.ts create mode 100644 backend/src/puzzles/controllers/puzzles-v2.controller.ts create mode 100644 backend/src/types/express.d.ts diff --git a/backend/docs/API_VERSIONING.md b/backend/docs/API_VERSIONING.md new file mode 100644 index 0000000..bdef3e2 --- /dev/null +++ b/backend/docs/API_VERSIONING.md @@ -0,0 +1,59 @@ +# API Versioning + +The backend now supports concurrent API versions for versioned resources. + +## Supported selectors + +- URL path: `/api/v1/puzzles`, `/api/v2/puzzles` +- Header: `X-API-Version: 1` +- Query parameter: `?api_version=1` + +If more than one selector is provided, all explicit values must match. Conflicts return `400 Bad Request`. + +## Resolution order + +1. URL path version +2. Header version +3. Query parameter version +4. Latest active version when no version is supplied + +Current default version: `v2` + +## Lifecycle + +- `v2`: active +- `v1`: deprecated, sunset scheduled for `2026-06-24T00:00:00.000Z` +- `v0`: removed and returns `410 Gone` + +Deprecated responses include: + +- `X-API-Deprecation: true` +- `Warning: 299 - "..."` +- `Sunset: ` when a sunset date exists + +Every versioned response includes: + +- `X-API-Version` +- `X-API-Latest-Version` +- `X-API-Version-Status` + +## Version differences + +### v1 puzzles + +- Pagination uses `page` and `limit` +- Item endpoints return the raw puzzle object +- Collection endpoints return `{ data, meta: { page, limit, total } }` + +### v2 puzzles + +- Pagination uses `page` and `pageSize` +- `pageSize` is capped at 50 +- Item endpoints return `{ data, version }` +- Collection endpoints return `{ data, meta: { page, pageSize, total, version, includeCategorySummary } }` + +## Auto-generated docs + +- Latest: `/api/docs/latest` +- v1: `/api/docs/v1` +- v2: `/api/docs/v2` diff --git a/backend/docs/migrations/v0-to-v1.md b/backend/docs/migrations/v0-to-v1.md new file mode 100644 index 0000000..6b2b616 --- /dev/null +++ b/backend/docs/migrations/v0-to-v1.md @@ -0,0 +1,9 @@ +# Migration Guide: v0 to v1 + +Version 0 has been removed and now returns `410 Gone`. + +## Required action + +1. Move all clients to `/api/v1/*` +2. Update any fallback version headers or query parameters to `1` +3. Re-test integrations against the maintained v1 contract diff --git a/backend/docs/migrations/v1-to-v2.md b/backend/docs/migrations/v1-to-v2.md new file mode 100644 index 0000000..280f97d --- /dev/null +++ b/backend/docs/migrations/v1-to-v2.md @@ -0,0 +1,37 @@ +# Migration Guide: v1 to v2 + +This guide covers the breaking changes between puzzle API v1 and v2. + +## Routing + +- Preferred: move from `/api/v1/puzzles` to `/api/v2/puzzles` +- Alternative negotiation also works with `X-API-Version: 2` or `?api_version=2` + +## Response contract changes + +- `GET /puzzles/:id` + - v1: returns a puzzle object directly + - v2: returns `{ data: , version: "2" }` + +- `GET /puzzles` + - v1: returns `{ data, meta: { page, limit, total } }` + - v2: returns `{ data, meta: { page, pageSize, total, version, includeCategorySummary } }` + +- `GET /puzzles/daily-quest` + - v1: returns `Puzzle[]` + - v2: returns `{ data: Puzzle[], meta: ... }` + +## Request contract changes + +- Replace `limit` with `pageSize` +- Expect stricter validation in v2: + - `pageSize` max is `50` + - unsupported legacy query fields are rejected by the validation pipe + +## Suggested frontend rollout + +1. Update API client defaults to send `X-API-Version: 2` +2. Adjust response mappers for the new envelope format +3. Replace any `limit` usage with `pageSize` +4. Monitor deprecation headers while v1 traffic drains +5. Remove v1-specific parsing before the sunset date diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5da1b31..756f436 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,6 +22,11 @@ import jwtConfig from './auth/authConfig/jwt.config'; import { UsersService } from './users/providers/users.service'; import { GeolocationMiddleware } from './common/middleware/geolocation.middleware'; import { HealthModule } from './health/health.module'; +import { + ApiVersionMiddleware, + ApiVersionService, +} from './common/versioning'; +import { DocsController } from './docs/docs.controller'; // const ENV = process.env.NODE_ENV; // console.log('NODE_ENV:', process.env.NODE_ENV); @@ -98,13 +103,13 @@ import { HealthModule } from './health/health.module'; redisClient: redisClient, validateUser: async (userId: string) => await usersService.findOneById(userId), logging: true, - publicRoutes: ['/auth', '/api', '/docs', '/health'], + publicRoutes: ['/api/auth', '/api/docs', '/health'], }), }), HealthModule, ], - controllers: [AppController], - providers: [AppService], + controllers: [AppController, DocsController], + providers: [AppService, ApiVersionService], }) export class AppModule implements NestModule { /** @@ -115,10 +120,15 @@ export class AppModule implements NestModule { .apply(GeolocationMiddleware) .forRoutes('*'); + consumer + .apply(ApiVersionMiddleware) + .forRoutes({ path: 'api/*path', method: RequestMethod.ALL }); + consumer .apply(JwtAuthMiddleware) .exclude( - { path: 'auth/(.*)', method: RequestMethod.ALL }, + { path: 'api/auth/(.*)', method: RequestMethod.ALL }, + { path: 'api/docs/(.*)', method: RequestMethod.GET }, { path: 'api', method: RequestMethod.GET }, { path: 'docs', method: RequestMethod.GET }, { path: 'health', method: RequestMethod.GET }, diff --git a/backend/src/common/versioning/api-version.constants.ts b/backend/src/common/versioning/api-version.constants.ts new file mode 100644 index 0000000..d9baabe --- /dev/null +++ b/backend/src/common/versioning/api-version.constants.ts @@ -0,0 +1,39 @@ +import { ApiVersionDefinition } from './api-version.types'; + +export const API_VERSION_HEADER = 'x-api-version'; +export const API_VERSION_QUERY_PARAM = 'api_version'; +export const API_VERSION_ROUTE_PREFIX = 'v'; + +export const VERSIONED_RESOURCES = ['puzzles'] as const; + +export const API_VERSION_DEFINITIONS: ApiVersionDefinition[] = [ + { + version: '0', + status: 'removed', + releaseDate: '2025-01-15T00:00:00.000Z', + deprecated: true, + deprecationMessage: + 'Version 0 has been removed. Upgrade to v1 or v2 immediately.', + removedAt: '2025-12-31T23:59:59.000Z', + successorVersion: '1', + supportedResources: ['puzzles'], + }, + { + version: '1', + status: 'deprecated', + releaseDate: '2025-06-01T00:00:00.000Z', + deprecated: true, + deprecationMessage: + 'Version 1 remains available during the migration window. Plan your upgrade to v2.', + sunsetDate: '2026-06-24T00:00:00.000Z', + successorVersion: '2', + supportedResources: ['puzzles'], + }, + { + version: '2', + status: 'active', + releaseDate: '2026-03-26T00:00:00.000Z', + deprecated: false, + supportedResources: ['puzzles'], + }, +]; diff --git a/backend/src/common/versioning/api-version.interceptor.ts b/backend/src/common/versioning/api-version.interceptor.ts new file mode 100644 index 0000000..2fc92b7 --- /dev/null +++ b/backend/src/common/versioning/api-version.interceptor.ts @@ -0,0 +1,49 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { ApiVersionService } from './api-version.service'; + +@Injectable() +export class ApiVersionInterceptor implements NestInterceptor { + constructor(private readonly apiVersionService: ApiVersionService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + const response = httpContext.getResponse(); + const versionContext = request.apiVersionContext; + + if (versionContext) { + const { definition, resolvedVersion, latestVersion } = versionContext; + + response.setHeader('X-API-Version', resolvedVersion); + response.setHeader('X-API-Latest-Version', latestVersion); + response.setHeader('X-API-Version-Status', definition.status); + + if (this.apiVersionService.isDeprecated(definition)) { + response.setHeader('X-API-Deprecation', 'true'); + response.setHeader( + 'Warning', + `299 - "${this.apiVersionService.buildDeprecationNotice(definition, versionContext.source)}"`, + ); + } + + if (definition.sunsetDate) { + response.setHeader('Sunset', new Date(definition.sunsetDate).toUTCString()); + } + + if (definition.successorVersion) { + response.setHeader( + 'Link', + `; rel="successor-version"`, + ); + } + } + + return next.handle(); + } +} diff --git a/backend/src/common/versioning/api-version.middleware.spec.ts b/backend/src/common/versioning/api-version.middleware.spec.ts new file mode 100644 index 0000000..5691629 --- /dev/null +++ b/backend/src/common/versioning/api-version.middleware.spec.ts @@ -0,0 +1,107 @@ +import { BadRequestException, GoneException } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ApiVersionMiddleware } from './api-version.middleware'; +import { ApiVersionService } from './api-version.service'; + +describe('ApiVersionMiddleware', () => { + let middleware: ApiVersionMiddleware; + + beforeEach(() => { + middleware = new ApiVersionMiddleware(new ApiVersionService()); + }); + + it('rewrites unversioned versioned-resource URLs to the latest version', () => { + const request = createRequest({ + path: '/api/puzzles', + url: '/api/puzzles?page=1', + }); + const next = jest.fn(); + + middleware.use(request as Request, {} as Response, next); + + expect(request.url).toBe('/api/v2/puzzles?page=1'); + expect(request.apiVersionContext?.resolvedVersion).toBe('2'); + expect(request.apiVersionContext?.source).toBe('default'); + expect(next).toHaveBeenCalled(); + }); + + it('accepts header-based version selection', () => { + const request = createRequest({ + path: '/api/puzzles', + url: '/api/puzzles', + headers: { 'x-api-version': '1' }, + }); + const next = jest.fn(); + + middleware.use(request as Request, {} as Response, next); + + expect(request.url).toBe('/api/v1/puzzles'); + expect(request.apiVersionContext?.resolvedVersion).toBe('1'); + expect(request.apiVersionContext?.source).toBe('header'); + expect(next).toHaveBeenCalled(); + }); + + it('accepts query-based version selection', () => { + const request = createRequest({ + path: '/api/puzzles', + url: '/api/puzzles?api_version=1', + query: { api_version: '1' }, + }); + const next = jest.fn(); + + middleware.use(request as Request, {} as Response, next); + + expect(request.url).toBe('/api/v1/puzzles?api_version=1'); + expect(request.apiVersionContext?.resolvedVersion).toBe('1'); + expect(request.apiVersionContext?.source).toBe('query'); + expect(next).toHaveBeenCalled(); + }); + + it('rejects conflicting version selectors', () => { + const request = createRequest({ + path: '/api/puzzles', + url: '/api/puzzles?api_version=2', + headers: { 'x-api-version': '1' }, + query: { api_version: '2' }, + }); + + expect(() => + middleware.use(request as Request, {} as Response, jest.fn()), + ).toThrow(BadRequestException); + }); + + it('returns 410 for removed versions', () => { + const request = createRequest({ + path: '/api/v0/puzzles', + url: '/api/v0/puzzles', + }); + + expect(() => + middleware.use(request as Request, {} as Response, jest.fn()), + ).toThrow(GoneException); + }); + + it('skips non-versioned resources', () => { + const request = createRequest({ + path: '/api/auth/login', + url: '/api/auth/login', + }); + const next = jest.fn(); + + middleware.use(request as Request, {} as Response, next); + + expect(request.apiVersionContext).toBeUndefined(); + expect(request.url).toBe('/api/auth/login'); + expect(next).toHaveBeenCalled(); + }); +}); + +function createRequest(overrides: Partial): Partial { + return { + path: '/api/puzzles', + url: '/api/puzzles', + query: {}, + headers: {}, + ...overrides, + }; +} diff --git a/backend/src/common/versioning/api-version.middleware.ts b/backend/src/common/versioning/api-version.middleware.ts new file mode 100644 index 0000000..2c6b318 --- /dev/null +++ b/backend/src/common/versioning/api-version.middleware.ts @@ -0,0 +1,158 @@ +import { + BadRequestException, + GoneException, + Injectable, + NestMiddleware, +} from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; +import { + API_VERSION_HEADER, + API_VERSION_QUERY_PARAM, + API_VERSION_ROUTE_PREFIX, +} from './api-version.constants'; +import { ApiVersionService } from './api-version.service'; +import { ApiVersionContext } from './api-version.types'; + +@Injectable() +export class ApiVersionMiddleware implements NestMiddleware { + constructor(private readonly apiVersionService: ApiVersionService) {} + + use(request: Request, _: Response, next: NextFunction): void { + const resource = this.extractVersionedResource(request.path); + + if (!resource) { + next(); + return; + } + + const pathVersion = this.extractVersionFromPath(request.path); + const headerVersion = this.apiVersionService.normalizeVersion( + request.headers[API_VERSION_HEADER], + ); + const queryVersion = this.apiVersionService.normalizeVersion( + request.query[API_VERSION_QUERY_PARAM] as string | string[] | undefined, + ); + + const explicitVersions = new Map(); + + if (pathVersion) { + explicitVersions.set('url', pathVersion); + } + if (headerVersion) { + explicitVersions.set('header', headerVersion); + } + if (queryVersion) { + explicitVersions.set('query', queryVersion); + } + + const distinctVersions = [...new Set(explicitVersions.values())]; + if (distinctVersions.length > 1) { + throw new BadRequestException( + 'Conflicting API versions provided across URL, header, or query parameter.', + ); + } + + const resolvedVersion = + distinctVersions[0] ?? this.apiVersionService.getLatestVersion(); + const versionSource = pathVersion + ? 'url' + : headerVersion + ? 'header' + : queryVersion + ? 'query' + : 'default'; + + const definition = + this.apiVersionService.getVersionDefinition(resolvedVersion); + + if (!definition) { + throw new BadRequestException( + `Unsupported API version "${resolvedVersion}". Supported versions: ${this.getAvailableVersions()}.`, + ); + } + + if (!this.apiVersionService.isCompatibleWithResource(resolvedVersion, resource)) { + throw new BadRequestException( + `API version "${resolvedVersion}" does not support the "${resource}" resource.`, + ); + } + + if (this.apiVersionService.isRemoved(definition)) { + throw new GoneException({ + message: `API version ${resolvedVersion} is no longer available.`, + upgradeTo: definition.successorVersion + ? `v${definition.successorVersion}` + : undefined, + migrationGuide: definition.successorVersion + ? `/api/docs/migrations/v${resolvedVersion}-to-v${definition.successorVersion}` + : undefined, + }); + } + + const context: ApiVersionContext = { + requestedVersion: distinctVersions[0], + resolvedVersion, + latestVersion: this.apiVersionService.getLatestVersion(), + source: versionSource, + definition, + resource, + }; + + request.apiVersionContext = context; + + if (!pathVersion) { + request.url = this.injectVersionIntoUrl( + request.url, + resource, + resolvedVersion, + ); + } + + next(); + } + + private extractVersionedResource(path: string): string | undefined { + const sanitizedPath = path.replace(/^\/api\/?/, '').replace(/^\/+/, ''); + const segments = sanitizedPath.split('/').filter(Boolean); + const [firstSegment, secondSegment] = segments; + + if ( + firstSegment && + /^v\d+$/i.test(firstSegment) && + this.apiVersionService.isVersionedResource(secondSegment) + ) { + return secondSegment; + } + + if (this.apiVersionService.isVersionedResource(firstSegment)) { + return firstSegment; + } + + return undefined; + } + + private extractVersionFromPath(path: string): string | undefined { + const match = path.match( + new RegExp(`^/api/${API_VERSION_ROUTE_PREFIX}(\\d+)(?:/|$)`, 'i'), + ); + + return match?.[1]; + } + + private injectVersionIntoUrl( + url: string, + resource: string, + resolvedVersion: string, + ): string { + const resourcePattern = new RegExp(`^/api/${resource}(?=/|\\?|$)`, 'i'); + + return url.replace( + resourcePattern, + `/api/${API_VERSION_ROUTE_PREFIX}${resolvedVersion}/${resource}`, + ); + } + + private getAvailableVersions(): string { + return ['v1', 'v2'].join(', '); + } +} diff --git a/backend/src/common/versioning/api-version.service.ts b/backend/src/common/versioning/api-version.service.ts new file mode 100644 index 0000000..d41c0dd --- /dev/null +++ b/backend/src/common/versioning/api-version.service.ts @@ -0,0 +1,94 @@ +import { Injectable } from '@nestjs/common'; +import { + API_VERSION_DEFINITIONS, + VERSIONED_RESOURCES, +} from './api-version.constants'; +import { + ApiVersionDefinition, + ApiVersionSource, +} from './api-version.types'; + +@Injectable() +export class ApiVersionService { + private readonly versions = API_VERSION_DEFINITIONS; + private readonly versionedResources = [...VERSIONED_RESOURCES]; + + getLatestVersion(): string { + const latestActiveVersion = [...this.versions] + .filter((definition) => definition.status === 'active') + .sort((left, right) => Number(right.version) - Number(left.version))[0]; + + if (!latestActiveVersion) { + throw new Error('No active API version is configured.'); + } + + return latestActiveVersion.version; + } + + getSupportedResources(): string[] { + return this.versionedResources; + } + + isVersionedResource(resource?: string): resource is string { + return !!resource && this.versionedResources.includes(resource); + } + + getVersionDefinition(version: string): ApiVersionDefinition | undefined { + return this.versions.find((definition) => definition.version === version); + } + + isKnownVersion(version: string): boolean { + return !!this.getVersionDefinition(version); + } + + isRemoved(definition: ApiVersionDefinition): boolean { + if (definition.status === 'removed') { + return true; + } + + if (definition.sunsetDate && new Date(definition.sunsetDate) <= new Date()) { + return true; + } + + return !!definition.removedAt && new Date(definition.removedAt) <= new Date(); + } + + isDeprecated(definition: ApiVersionDefinition): boolean { + return definition.deprecated || definition.status === 'deprecated'; + } + + isCompatibleWithResource(version: string, resource: string): boolean { + const definition = this.getVersionDefinition(version); + + return !!definition && definition.supportedResources.includes(resource); + } + + normalizeVersion(rawVersion?: string | string[]): string | undefined { + if (!rawVersion) { + return undefined; + } + + const value = Array.isArray(rawVersion) ? rawVersion[0] : rawVersion; + const normalized = value.trim().toLowerCase().replace(/^v/, ''); + + return normalized || undefined; + } + + buildDeprecationNotice( + definition: ApiVersionDefinition, + source: ApiVersionSource, + ): string | undefined { + if (!this.isDeprecated(definition)) { + return undefined; + } + + const sunsetNotice = definition.sunsetDate + ? ` Sunset on ${definition.sunsetDate}.` + : ''; + const upgradeNotice = definition.successorVersion + ? ` Upgrade to v${definition.successorVersion}.` + : ''; + + return `API version ${definition.version} was selected via ${source}.${sunsetNotice}${upgradeNotice} ${definition.deprecationMessage ?? ''}`.trim(); + } +} diff --git a/backend/src/common/versioning/api-version.types.ts b/backend/src/common/versioning/api-version.types.ts new file mode 100644 index 0000000..aedf10c --- /dev/null +++ b/backend/src/common/versioning/api-version.types.ts @@ -0,0 +1,24 @@ +export type ApiVersionStatus = 'active' | 'deprecated' | 'removed'; + +export type ApiVersionSource = 'url' | 'header' | 'query' | 'default'; + +export interface ApiVersionDefinition { + version: string; + status: ApiVersionStatus; + releaseDate: string; + deprecated: boolean; + deprecationMessage?: string; + sunsetDate?: string; + removedAt?: string; + successorVersion?: string; + supportedResources: string[]; +} + +export interface ApiVersionContext { + requestedVersion?: string; + resolvedVersion: string; + latestVersion: string; + source: ApiVersionSource; + definition: ApiVersionDefinition; + resource: string; +} diff --git a/backend/src/common/versioning/index.ts b/backend/src/common/versioning/index.ts new file mode 100644 index 0000000..c54e21c --- /dev/null +++ b/backend/src/common/versioning/index.ts @@ -0,0 +1,6 @@ +export * from './api-version.constants'; +export * from './api-version.interceptor'; +export * from './api-version.middleware'; +export * from './api-version.service'; +export * from './api-version.types'; +export * from './swagger-versioning'; diff --git a/backend/src/common/versioning/swagger-versioning.ts b/backend/src/common/versioning/swagger-versioning.ts new file mode 100644 index 0000000..b72f4ff --- /dev/null +++ b/backend/src/common/versioning/swagger-versioning.ts @@ -0,0 +1,22 @@ +import { OpenAPIObject } from '@nestjs/swagger'; + +export function buildVersionedSwaggerDocument( + document: OpenAPIObject, + version: string, +): OpenAPIObject { + const versionPrefix = `/api/v${version}/`; + + return { + ...document, + info: { + ...document.info, + version: `v${version}`, + title: `${document.info.title} v${version}`, + }, + paths: Object.fromEntries( + Object.entries(document.paths).filter(([path]) => + path.startsWith(versionPrefix), + ), + ), + }; +} diff --git a/backend/src/docs/docs.controller.ts b/backend/src/docs/docs.controller.ts new file mode 100644 index 0000000..93c16e4 --- /dev/null +++ b/backend/src/docs/docs.controller.ts @@ -0,0 +1,75 @@ +import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; + +type MigrationGuide = { + from: string; + to: string; + summary: string; + breakingChanges: string[]; + actions: string[]; + docPath: string; +}; + +const MIGRATION_GUIDES: Record = { + 'v0-to-v1': { + from: 'v0', + to: 'v1', + summary: + 'v0 has been removed. Move clients to the maintained v1 route shape immediately.', + breakingChanges: [ + 'Requests to v0 now return 410 Gone.', + 'Clients must switch to /api/v1/* or send X-API-Version: 1.', + ], + actions: [ + 'Update hard-coded v0 URLs to /api/v1/*.', + 'Retest pagination and response handling against the v1 contract.', + ], + docPath: '/backend/docs/migrations/v0-to-v1.md', + }, + 'v1-to-v2': { + from: 'v1', + to: 'v2', + summary: + 'Puzzle responses move to a response envelope and pagination uses pageSize instead of limit.', + breakingChanges: [ + 'GET /puzzles/:id returns { data, version } instead of a raw puzzle object.', + 'GET /puzzles returns meta.pageSize instead of meta.limit.', + 'GET /puzzles/daily-quest returns an envelope instead of a plain array.', + 'v2 rejects legacy limit in favor of pageSize.', + ], + actions: [ + 'Update clients to request /api/v2/* or send X-API-Version: 2.', + 'Replace limit with pageSize in frontend query builders.', + 'Adjust response mappers to read payloads from data.', + 'Monitor X-API-Deprecation and Sunset headers while v1 traffic drains.', + ], + docPath: '/backend/docs/migrations/v1-to-v2.md', + }, +}; + +@Controller('docs') +export class DocsController { + @Get() + getVersionDocsIndex() { + return { + latest: '/api/docs/latest', + versions: { + v1: '/api/docs/v1', + v2: '/api/docs/v2', + }, + migrations: Object.keys(MIGRATION_GUIDES).map( + (guideId) => `/api/docs/migrations/${guideId}`, + ), + }; + } + + @Get('migrations/:guideId') + getMigrationGuide(@Param('guideId') guideId: string) { + const guide = MIGRATION_GUIDES[guideId]; + + if (!guide) { + throw new NotFoundException(`Migration guide "${guideId}" was not found.`); + } + + return guide; + } +} diff --git a/backend/src/main.ts b/backend/src/main.ts index ec9a1de..031886f 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,15 +1,22 @@ -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, VersioningType } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { AppModule } from './app.module'; import { AllExceptionsFilter } from './common/filters/http-exception.filter'; import { CorrelationIdMiddleware } from './common/middleware/correlation-id.middleware'; -import { AppModule } from './app.module'; +import { + API_VERSION_HEADER, + API_VERSION_QUERY_PARAM, + ApiVersionInterceptor, + ApiVersionService, + buildVersionedSwaggerDocument, +} from './common/versioning'; import { HealthService } from './health/health.service'; async function bootstrap() { const app = await NestFactory.create(AppModule); + const apiVersionService = app.get(ApiVersionService); - // Enable global validation app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -18,42 +25,62 @@ async function bootstrap() { }), ); - // Stamp every request with a correlation ID before any other handler runs app.use(new CorrelationIdMiddleware().use.bind(new CorrelationIdMiddleware())); - // Enable global exception handling (catches ALL errors, not just HttpExceptions) + app.setGlobalPrefix('api', { + exclude: ['health', 'health/*path'], + }); + + app.enableVersioning({ + type: VersioningType.URI, + prefix: 'v', + defaultVersion: apiVersionService.getLatestVersion(), + }); + app.useGlobalFilters(new AllExceptionsFilter()); + app.useGlobalInterceptors(new ApiVersionInterceptor(apiVersionService)); - // Setup Swagger API Documentation at http://localhost:3000/api const config = new DocumentBuilder() .setTitle('MindBlock API') - .setDescription('API documentation for MindBlock Backend') - .setVersion('1.0') + .setDescription( + `API documentation for MindBlock Backend. Primary versioning uses URL paths (/api/v1/*, /api/v2/*). Header (${API_VERSION_HEADER}) and query (${API_VERSION_QUERY_PARAM}) negotiation are also supported for versioned resources.`, + ) + .setVersion(`v${apiVersionService.getLatestVersion()}`) .build(); const document = SwaggerModule.createDocument(app, config); - SwaggerModule.setup('api', app, document); + const v1Document = buildVersionedSwaggerDocument(document, '1'); + const v2Document = buildVersionedSwaggerDocument(document, '2'); + + SwaggerModule.setup('api/docs/v1', app, v1Document); + SwaggerModule.setup('api/docs/v2', app, v2Document); + SwaggerModule.setup('api/docs/latest', app, v2Document); app.enableCors({ origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], + allowedHeaders: ['Content-Type', 'Authorization', API_VERSION_HEADER], + exposedHeaders: [ + 'X-API-Version', + 'X-API-Latest-Version', + 'X-API-Deprecation', + 'X-API-Version-Status', + 'Sunset', + 'Warning', + 'Link', + ], credentials: true, }); - // Graceful shutdown handling const healthService = app.get(HealthService); - + const gracefulShutdown = async (signal: string) => { - console.log(`\nšŸ›‘ Received ${signal}. Starting graceful shutdown...`); - - // Signal health checks that we're shutting down + console.log(`Received ${signal}. Starting graceful shutdown...`); healthService.setIsShuttingDown(); - - // Wait a moment for load balancers to detect the unhealthy state + setTimeout(async () => { - console.log('šŸ”„ Closing HTTP server...'); + console.log('Closing HTTP server...'); await app.close(); - console.log('āœ… Graceful shutdown completed'); + console.log('Graceful shutdown completed'); process.exit(0); }, 5000); }; @@ -62,6 +89,7 @@ async function bootstrap() { process.on('SIGINT', () => gracefulShutdown('SIGINT')); await app.listen(3000); - console.log('šŸš€ Application is running on: http://localhost:3000'); + console.log('Application is running on: http://localhost:3000'); } + void bootstrap(); diff --git a/backend/src/puzzles/controllers/puzzles-v1.controller.ts b/backend/src/puzzles/controllers/puzzles-v1.controller.ts new file mode 100644 index 0000000..e5529c2 --- /dev/null +++ b/backend/src/puzzles/controllers/puzzles-v1.controller.ts @@ -0,0 +1,73 @@ +import { Body, Controller, Get, Param, Post, Query, Version } from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { PuzzlesService } from '../providers/puzzles.service'; +import { CreatePuzzleDto } from '../dtos/create-puzzle.dto'; +import { Puzzle } from '../entities/puzzle.entity'; +import { PuzzleQueryDto } from '../dtos/puzzle-query.dto'; + +@Controller('puzzles') +@Version('1') +@ApiTags('puzzles-v1') +@ApiHeader({ + name: 'X-API-Version', + required: false, + description: 'Alternative version selector. Supported values: 1 or v1.', +}) +@ApiQuery({ + name: 'api_version', + required: false, + description: 'Fallback version selector. Supported values: 1 or v1.', +}) +export class PuzzlesV1Controller { + constructor(private readonly puzzlesService: PuzzlesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new puzzle (v1 contract)' }) + @ApiResponse({ + status: 201, + description: 'Puzzle created successfully', + type: Puzzle, + }) + async create(@Body() createPuzzleDto: CreatePuzzleDto): Promise { + return this.puzzlesService.create(createPuzzleDto); + } + + @Get('daily-quest') + @ApiOperation({ summary: 'Get the legacy v1 daily quest puzzle selection' }) + @ApiResponse({ + status: 200, + description: 'Daily quest puzzles retrieved successfully', + type: Puzzle, + isArray: true, + }) + getDailyQuest() { + return this.puzzlesService.getDailyQuestPuzzles(); + } + + @Get() + @ApiOperation({ summary: 'Get puzzles with the v1 pagination contract' }) + @ApiResponse({ + status: 200, + description: 'Puzzles retrieved successfully', + }) + findAll(@Query() query: PuzzleQueryDto) { + return this.puzzlesService.findAll(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a puzzle by ID with the v1 response shape' }) + @ApiResponse({ + status: 200, + description: 'Puzzle retrieved successfully', + type: Puzzle, + }) + getById(@Param('id') id: string) { + return this.puzzlesService.getPuzzleById(id); + } +} diff --git a/backend/src/puzzles/controllers/puzzles-v2.controller.ts b/backend/src/puzzles/controllers/puzzles-v2.controller.ts new file mode 100644 index 0000000..721af99 --- /dev/null +++ b/backend/src/puzzles/controllers/puzzles-v2.controller.ts @@ -0,0 +1,185 @@ +import { Body, Controller, Get, Param, Post, Query, Version } from '@nestjs/common'; +import { + ApiHeader, + ApiOperation, + ApiProperty, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsInt, + IsOptional, + IsUUID, + Max, + Min, +} from 'class-validator'; +import { PuzzlesService } from '../providers/puzzles.service'; +import { CreatePuzzleDto } from '../dtos/create-puzzle.dto'; +import { Puzzle } from '../entities/puzzle.entity'; +import { PuzzleDifficulty } from '../enums/puzzle-difficulty.enum'; + +class PuzzleV2QueryDto { + @IsOptional() + @IsUUID() + categoryId?: string; + + @IsOptional() + @IsEnum(PuzzleDifficulty) + difficulty?: PuzzleDifficulty; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + pageSize?: number = 20; + + @IsOptional() + @Type(() => Boolean) + @IsBoolean() + includeCategorySummary?: boolean = true; +} + +class PuzzleV2MetaDto { + @ApiProperty() + page!: number; + + @ApiProperty() + pageSize!: number; + + @ApiProperty() + total!: number; + + @ApiProperty() + version!: string; + + @ApiProperty() + includeCategorySummary!: boolean; +} + +class PuzzleV2CollectionResponseDto { + @ApiProperty({ type: Puzzle, isArray: true }) + data!: Puzzle[]; + + @ApiProperty({ type: PuzzleV2MetaDto }) + meta!: PuzzleV2MetaDto; +} + +class PuzzleV2ItemResponseDto { + @ApiProperty({ type: Puzzle }) + data!: Puzzle; + + @ApiProperty() + version!: string; +} + +@Controller('puzzles') +@Version('2') +@ApiTags('puzzles-v2') +@ApiHeader({ + name: 'X-API-Version', + required: false, + description: 'Alternative version selector. Supported values: 2 or v2.', +}) +@ApiQuery({ + name: 'api_version', + required: false, + description: 'Fallback version selector. Supported values: 2 or v2.', +}) +export class PuzzlesV2Controller { + constructor(private readonly puzzlesService: PuzzlesService) {} + + @Post() + @ApiOperation({ summary: 'Create a new puzzle (v2 contract)' }) + @ApiResponse({ + status: 201, + description: 'Puzzle created successfully', + type: PuzzleV2ItemResponseDto, + }) + async create(@Body() createPuzzleDto: CreatePuzzleDto) { + const puzzle = await this.puzzlesService.create(createPuzzleDto); + + return { + data: puzzle, + version: '2', + }; + } + + @Get('daily-quest') + @ApiOperation({ summary: 'Get daily quest puzzles with the v2 envelope' }) + @ApiResponse({ + status: 200, + description: 'Daily quest puzzles retrieved successfully', + type: PuzzleV2CollectionResponseDto, + }) + async getDailyQuest() { + const puzzles = await this.puzzlesService.getDailyQuestPuzzles(); + + return { + data: puzzles, + meta: { + page: 1, + pageSize: puzzles.length, + total: puzzles.length, + version: '2', + includeCategorySummary: true, + }, + }; + } + + @Get() + @ApiOperation({ + summary: + 'Get puzzles with the v2 response envelope and stricter pagination contract', + }) + @ApiResponse({ + status: 200, + description: 'Puzzles retrieved successfully', + type: PuzzleV2CollectionResponseDto, + }) + async findAll(@Query() query: PuzzleV2QueryDto) { + const result = await this.puzzlesService.findAll({ + categoryId: query.categoryId, + difficulty: query.difficulty, + page: query.page, + limit: query.pageSize, + }); + + return { + data: result.data, + meta: { + page: result.meta.page, + pageSize: result.meta.limit, + total: result.meta.total, + version: '2', + includeCategorySummary: query.includeCategorySummary ?? true, + }, + }; + } + + @Get(':id') + @ApiOperation({ summary: 'Get a puzzle by ID with the v2 response envelope' }) + @ApiResponse({ + status: 200, + description: 'Puzzle retrieved successfully', + type: PuzzleV2ItemResponseDto, + }) + async getById(@Param('id') id: string) { + const puzzle = await this.puzzlesService.getPuzzleById(id); + + return { + data: puzzle, + version: '2', + }; + } +} diff --git a/backend/src/puzzles/puzzles.module.ts b/backend/src/puzzles/puzzles.module.ts index 276d4f5..c194961 100644 --- a/backend/src/puzzles/puzzles.module.ts +++ b/backend/src/puzzles/puzzles.module.ts @@ -2,14 +2,15 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Puzzle } from './entities/puzzle.entity'; import { Category } from '../categories/entities/category.entity'; -import { PuzzlesController } from './controllers/puzzles.controller'; +import { PuzzlesV1Controller } from './controllers/puzzles-v1.controller'; +import { PuzzlesV2Controller } from './controllers/puzzles-v2.controller'; import { PuzzlesService } from './providers/puzzles.service'; import { CreatePuzzleProvider } from './providers/create-puzzle.provider'; import { GetAllPuzzlesProvider } from './providers/getAll-puzzle.provider'; @Module({ imports: [TypeOrmModule.forFeature([Puzzle, Category])], - controllers: [PuzzlesController], + controllers: [PuzzlesV1Controller, PuzzlesV2Controller], providers: [PuzzlesService, CreatePuzzleProvider, GetAllPuzzlesProvider], exports: [TypeOrmModule, PuzzlesService], }) diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts new file mode 100644 index 0000000..c95a0f4 --- /dev/null +++ b/backend/src/types/express.d.ts @@ -0,0 +1,8 @@ +import 'express'; +import { ApiVersionContext } from '../common/versioning'; + +declare module 'express-serve-static-core' { + interface Request { + apiVersionContext?: ApiVersionContext; + } +} From 86fbfb14d85ed55c4161e858ee980019acac9361 Mon Sep 17 00:00:00 2001 From: nafiuishaaq Date: Thu, 26 Mar 2026 14:54:58 +0100 Subject: [PATCH 2/5] implemented the patches --- backend/src/puzzles/controllers/puzzles-v2.controller.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/puzzles/controllers/puzzles-v2.controller.ts b/backend/src/puzzles/controllers/puzzles-v2.controller.ts index 721af99..556c944 100644 --- a/backend/src/puzzles/controllers/puzzles-v2.controller.ts +++ b/backend/src/puzzles/controllers/puzzles-v2.controller.ts @@ -7,7 +7,7 @@ import { ApiResponse, ApiTags, } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { IsBoolean, IsEnum, @@ -45,7 +45,7 @@ class PuzzleV2QueryDto { pageSize?: number = 20; @IsOptional() - @Type(() => Boolean) + @Transform(({ value }) => value === true || value === 'true') @IsBoolean() includeCategorySummary?: boolean = true; } From 20043c354ca8cb13734da908a28645b8e4386950 Mon Sep 17 00:00:00 2001 From: nafiuishaaq Date: Thu, 26 Mar 2026 15:00:33 +0100 Subject: [PATCH 3/5] implemented the patches --- backend/docs/RBAC.md | 60 +++++++++ backend/src/app.module.ts | 3 +- .../src/auth/interfaces/activeInterface.ts | 5 + .../auth/middleware/jwt-auth.middleware.ts | 5 +- .../controllers/progress.controller.ts | 5 + .../controllers/puzzles-v1.controller.ts | 3 + .../controllers/puzzles-v2.controller.ts | 3 + backend/src/roles/roles.decorator.ts | 44 ++++++- backend/src/roles/roles.guard.spec.ts | 100 +++++++++++++++ backend/src/roles/roles.guard.ts | 114 +++++++++++++++--- .../src/users/controllers/users.controller.ts | 9 ++ backend/src/users/enums/userRole.enum.ts | 1 + 12 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 backend/docs/RBAC.md create mode 100644 backend/src/roles/roles.guard.spec.ts diff --git a/backend/docs/RBAC.md b/backend/docs/RBAC.md new file mode 100644 index 0000000..f50deec --- /dev/null +++ b/backend/docs/RBAC.md @@ -0,0 +1,60 @@ +# Role-Based Access Control + +The backend uses a route decorator plus guard for role-based access control. + +## Supported roles + +- `USER` +- `MODERATOR` +- `ADMIN` + +Canonical enum: `backend/src/users/enums/userRole.enum.ts` + +## Hierarchy + +- `ADMIN` inherits `MODERATOR` and `USER` permissions +- `MODERATOR` inherits `USER` permissions +- `USER` only has `USER` permissions + +## Basic usage + +```ts +@Roles(userRole.ADMIN) +@Post() +createPuzzle() {} +``` + +This returns `403 Forbidden` with: + +```txt +Access denied. Required role: ADMIN +``` + +## Multiple roles (OR logic) + +```ts +@Roles(userRole.ADMIN, userRole.MODERATOR) +@Get() +findAllUsers() {} +``` + +Any listed role is enough. + +## Ownership-aware access + +```ts +@Roles({ roles: [userRole.ADMIN], ownership: { param: 'id' } }) +@Patch(':id') +updateUser() {} +``` + +This allows either: + +- an `ADMIN` +- the authenticated user whose `userId` matches `req.params.id` + +## Notes + +- RBAC runs after authentication middleware and expects `request.user.userRole` +- Missing role information in the auth context is treated as a server error +- Denied access attempts are logged for audit/security review diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 756f436..1c09e73 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -27,6 +27,7 @@ import { ApiVersionService, } from './common/versioning'; import { DocsController } from './docs/docs.controller'; +import { RolesGuard } from './roles/roles.guard'; // const ENV = process.env.NODE_ENV; // console.log('NODE_ENV:', process.env.NODE_ENV); @@ -109,7 +110,7 @@ import { DocsController } from './docs/docs.controller'; HealthModule, ], controllers: [AppController, DocsController], - providers: [AppService, ApiVersionService], + providers: [AppService, ApiVersionService, RolesGuard], }) export class AppModule implements NestModule { /** diff --git a/backend/src/auth/interfaces/activeInterface.ts b/backend/src/auth/interfaces/activeInterface.ts index ccb040a..8371ece 100644 --- a/backend/src/auth/interfaces/activeInterface.ts +++ b/backend/src/auth/interfaces/activeInterface.ts @@ -1,3 +1,5 @@ +import { userRole } from '../../users/enums/userRole.enum'; + /**Active user data interface */ export interface ActiveUserData { /**sub of type number */ @@ -5,4 +7,7 @@ export interface ActiveUserData { /**email of type string */ email?: string; + + /**authenticated user role */ + userRole?: userRole; } diff --git a/backend/src/auth/middleware/jwt-auth.middleware.ts b/backend/src/auth/middleware/jwt-auth.middleware.ts index 39de808..292d6ed 100644 --- a/backend/src/auth/middleware/jwt-auth.middleware.ts +++ b/backend/src/auth/middleware/jwt-auth.middleware.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import { Request, Response, NextFunction } from 'express'; import * as jwt from 'jsonwebtoken'; +import { userRole } from '../../users/enums/userRole.enum'; /** * Interface for the Redis client to support token blacklisting. @@ -40,7 +41,7 @@ export interface JwtAuthMiddlewareOptions { export interface DecodedUserPayload { userId: string; email: string; - userRole: string; + userRole: userRole; [key: string]: any; } @@ -124,7 +125,7 @@ export class JwtAuthMiddleware implements NestMiddleware { const userPayload: DecodedUserPayload = { userId, email: decoded.email, - userRole: decoded.userRole || decoded.role, + userRole: (decoded.userRole || decoded.role || userRole.USER) as userRole, }; if (!userPayload.userId || !userPayload.email) { diff --git a/backend/src/progress/controllers/progress.controller.ts b/backend/src/progress/controllers/progress.controller.ts index 199f084..e068bdf 100644 --- a/backend/src/progress/controllers/progress.controller.ts +++ b/backend/src/progress/controllers/progress.controller.ts @@ -22,6 +22,8 @@ import { CategoryStatsDto } from '../dtos/category-stats.dto'; import { OverallStatsDto } from '../dtos/overall-stats.dto'; import { ActiveUser } from '../../auth/decorators/activeUser.decorator'; import { ActiveUserData } from '../../auth/interfaces/activeInterface'; +import { Roles } from '../../roles/roles.decorator'; +import { userRole } from '../../users/enums/userRole.enum'; @Controller('progress') @ApiTags('Progress') @@ -35,6 +37,7 @@ export class ProgressController { ) {} @Get() + @Roles(userRole.USER) @ApiOperation({ summary: 'Get paginated progress history', description: @@ -62,6 +65,7 @@ export class ProgressController { } @Get('stats') + @Roles(userRole.USER) @ApiOperation({ summary: 'Get overall user statistics', description: @@ -81,6 +85,7 @@ export class ProgressController { } @Get('category/:id') + @Roles(userRole.USER) @ApiOperation({ summary: 'Get category-specific statistics', description: diff --git a/backend/src/puzzles/controllers/puzzles-v1.controller.ts b/backend/src/puzzles/controllers/puzzles-v1.controller.ts index e5529c2..6bc5d51 100644 --- a/backend/src/puzzles/controllers/puzzles-v1.controller.ts +++ b/backend/src/puzzles/controllers/puzzles-v1.controller.ts @@ -10,6 +10,8 @@ import { PuzzlesService } from '../providers/puzzles.service'; import { CreatePuzzleDto } from '../dtos/create-puzzle.dto'; import { Puzzle } from '../entities/puzzle.entity'; import { PuzzleQueryDto } from '../dtos/puzzle-query.dto'; +import { Roles } from '../../roles/roles.decorator'; +import { userRole } from '../../users/enums/userRole.enum'; @Controller('puzzles') @Version('1') @@ -28,6 +30,7 @@ export class PuzzlesV1Controller { constructor(private readonly puzzlesService: PuzzlesService) {} @Post() + @Roles(userRole.ADMIN) @ApiOperation({ summary: 'Create a new puzzle (v1 contract)' }) @ApiResponse({ status: 201, diff --git a/backend/src/puzzles/controllers/puzzles-v2.controller.ts b/backend/src/puzzles/controllers/puzzles-v2.controller.ts index 556c944..cbe9131 100644 --- a/backend/src/puzzles/controllers/puzzles-v2.controller.ts +++ b/backend/src/puzzles/controllers/puzzles-v2.controller.ts @@ -21,6 +21,8 @@ import { PuzzlesService } from '../providers/puzzles.service'; import { CreatePuzzleDto } from '../dtos/create-puzzle.dto'; import { Puzzle } from '../entities/puzzle.entity'; import { PuzzleDifficulty } from '../enums/puzzle-difficulty.enum'; +import { Roles } from '../../roles/roles.decorator'; +import { userRole } from '../../users/enums/userRole.enum'; class PuzzleV2QueryDto { @IsOptional() @@ -100,6 +102,7 @@ export class PuzzlesV2Controller { constructor(private readonly puzzlesService: PuzzlesService) {} @Post() + @Roles(userRole.ADMIN) @ApiOperation({ summary: 'Create a new puzzle (v2 contract)' }) @ApiResponse({ status: 201, diff --git a/backend/src/roles/roles.decorator.ts b/backend/src/roles/roles.decorator.ts index 786eaab..670b0e4 100644 --- a/backend/src/roles/roles.decorator.ts +++ b/backend/src/roles/roles.decorator.ts @@ -1,5 +1,45 @@ -import { SetMetadata } from '@nestjs/common'; +import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiForbiddenResponse } from '@nestjs/swagger'; import { userRole } from '../users/enums/userRole.enum'; +import { RolesGuard } from './roles.guard'; export const ROLES_KEY = 'roles'; -export const Roles = (...roles: userRole[]) => SetMetadata(ROLES_KEY, roles); + +export interface OwnershipRequirement { + param: string; + userIdField?: string; +} + +export interface RolesOptions { + roles: userRole[]; + ownership?: OwnershipRequirement; +} + +export function Roles(...roles: userRole[]): MethodDecorator & ClassDecorator; +export function Roles( + options: RolesOptions, +): MethodDecorator & ClassDecorator; +export function Roles( + ...rolesOrOptions: [RolesOptions] | userRole[] +): MethodDecorator & ClassDecorator { + const options = + typeof rolesOrOptions[0] === 'object' && !Array.isArray(rolesOrOptions[0]) + ? (rolesOrOptions[0] as RolesOptions) + : ({ + roles: rolesOrOptions as userRole[], + } satisfies RolesOptions); + + const readableRoles = options.roles.map((role) => role.toUpperCase()).join(' or '); + const forbiddenMessage = options.ownership + ? `Access denied. Required role: ${readableRoles} or ownership of this resource` + : `Access denied. Required role: ${readableRoles}`; + + return applyDecorators( + SetMetadata(ROLES_KEY, options), + UseGuards(RolesGuard), + ApiBearerAuth(), + ApiForbiddenResponse({ + description: forbiddenMessage, + }), + ); +} diff --git a/backend/src/roles/roles.guard.spec.ts b/backend/src/roles/roles.guard.spec.ts new file mode 100644 index 0000000..3eff9fc --- /dev/null +++ b/backend/src/roles/roles.guard.spec.ts @@ -0,0 +1,100 @@ +import { + ExecutionContext, + ForbiddenException, + InternalServerErrorException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { userRole } from '../users/enums/userRole.enum'; +import { RolesGuard } from './roles.guard'; +import { RolesOptions } from './roles.decorator'; + +describe('RolesGuard', () => { + let reflector: Reflector & { + getAllAndOverride: jest.Mock; + }; + + let guard: RolesGuard; + + beforeEach(() => { + jest.clearAllMocks(); + reflector = { + getAllAndOverride: jest.fn(), + } as unknown as Reflector & { + getAllAndOverride: jest.Mock; + }; + guard = new RolesGuard(reflector); + }); + + it('allows admins through user routes via hierarchy', () => { + mockRoles({ roles: [userRole.USER] }); + const context = createContext({ + user: { userId: '1', userRole: userRole.ADMIN }, + }); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows access when any required role matches', () => { + mockRoles({ roles: [userRole.ADMIN, userRole.MODERATOR] }); + const context = createContext({ + user: { userId: '1', userRole: userRole.MODERATOR }, + }); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows ownership-based access', () => { + mockRoles({ + roles: [userRole.ADMIN], + ownership: { param: 'id' }, + }); + const context = createContext({ + user: { userId: '42', userRole: userRole.USER }, + params: { id: '42' }, + }); + + expect(guard.canActivate(context)).toBe(true); + }); + + it('throws 403 with a clear message when access is denied', () => { + mockRoles({ roles: [userRole.ADMIN] }); + const context = createContext({ + user: { userId: '9', userRole: userRole.USER }, + }); + + expect(() => guard.canActivate(context)).toThrow( + new ForbiddenException('Access denied. Required role: ADMIN'), + ); + }); + + it('throws 500 when the role is missing from auth context', () => { + mockRoles({ roles: [userRole.ADMIN] }); + const context = createContext({ + user: { userId: '9' }, + }); + + expect(() => guard.canActivate(context)).toThrow( + InternalServerErrorException, + ); + }); +}); + +function mockRoles(options: RolesOptions) { + reflector.getAllAndOverride.mockReturnValue(options); +} + +function createContext(request: Record): ExecutionContext { + return { + getClass: jest.fn(), + getHandler: jest.fn(), + switchToHttp: () => ({ + getRequest: () => ({ + method: 'GET', + url: '/users/42', + originalUrl: '/users/42', + params: {}, + ...request, + }), + }), + } as unknown as ExecutionContext; +} diff --git a/backend/src/roles/roles.guard.ts b/backend/src/roles/roles.guard.ts index bc10c82..2a5af36 100644 --- a/backend/src/roles/roles.guard.ts +++ b/backend/src/roles/roles.guard.ts @@ -1,38 +1,116 @@ import { CanActivate, ExecutionContext, - Injectable, ForbiddenException, + Injectable, + InternalServerErrorException, + Logger, } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; -import { ROLES_KEY } from './roles.decorator'; +import { Request } from 'express'; import { userRole } from '../users/enums/userRole.enum'; +import { OwnershipRequirement, ROLES_KEY, RolesOptions } from './roles.decorator'; + +type AuthenticatedRequestUser = { + userId?: string; + sub?: string; + email?: string; + userRole?: userRole; + role?: userRole; + [key: string]: unknown; +}; + +type AuthenticatedRequest = Request & { + user?: AuthenticatedRequestUser; +}; + +const ROLE_HIERARCHY: Record = { + [userRole.ADMIN]: [userRole.ADMIN, userRole.MODERATOR, userRole.USER], + [userRole.MODERATOR]: [userRole.MODERATOR, userRole.USER], + [userRole.USER]: [userRole.USER], + [userRole.GUEST]: [userRole.GUEST], +}; @Injectable() export class RolesGuard implements CanActivate { - constructor(private reflector: Reflector) {} + private readonly logger = new Logger(RolesGuard.name); + + constructor(private readonly reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { - const requiredRoles = this.reflector.getAllAndOverride( - ROLES_KEY, - [context.getHandler(), context.getClass()], - ); + const options = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); - if (!requiredRoles) return true; + if (!options || options.roles.length === 0) { + return true; + } - const request = context - .switchToHttp() - .getRequest<{ user?: { role?: userRole } }>(); + const request = context.switchToHttp().getRequest(); const user = request.user; - if ( - !user || - user.role === undefined || - !requiredRoles.includes(user.role) - ) { - throw new ForbiddenException('Forbidden: Insufficient role'); + if (!user) { + throw new ForbiddenException('Access denied. Authentication context is missing.'); + } + + const effectiveRole = user.userRole ?? user.role; + + if (!effectiveRole) { + this.logger.error( + `Authenticated user is missing a role on ${request.method} ${request.originalUrl ?? request.url}`, + ); + throw new InternalServerErrorException( + 'User role is missing from the authentication context.', + ); + } + + if (this.hasRequiredRole(effectiveRole, options.roles)) { + return true; } - return true; + if (this.isOwner(request, user, options.ownership)) { + return true; + } + + const requiredRoles = options.roles.map((role) => role.toUpperCase()).join(' or '); + const message = options.ownership + ? `Access denied. Required role: ${requiredRoles} or ownership of this resource` + : `Access denied. Required role: ${requiredRoles}`; + + this.logger.warn( + JSON.stringify({ + event: 'rbac_denied', + method: request.method, + path: request.originalUrl ?? request.url, + userId: user.userId ?? user.sub ?? null, + userRole: effectiveRole, + requiredRoles: options.roles, + ownershipParam: options.ownership?.param ?? null, + }), + ); + + throw new ForbiddenException(message); + } + + private hasRequiredRole(currentRole: userRole, requiredRoles: userRole[]): boolean { + const allowedRoles = ROLE_HIERARCHY[currentRole] ?? [currentRole]; + + return requiredRoles.some((requiredRole) => allowedRoles.includes(requiredRole)); + } + + private isOwner( + request: AuthenticatedRequest, + user: AuthenticatedRequestUser, + ownership?: OwnershipRequirement, + ): boolean { + if (!ownership) { + return false; + } + + const userId = user[ownership.userIdField ?? 'userId'] ?? user.userId ?? user.sub; + const resourceOwner = request.params?.[ownership.param]; + + return !!userId && !!resourceOwner && String(userId) === String(resourceOwner); } } diff --git a/backend/src/users/controllers/users.controller.ts b/backend/src/users/controllers/users.controller.ts index 3972b52..f9a8f1d 100644 --- a/backend/src/users/controllers/users.controller.ts +++ b/backend/src/users/controllers/users.controller.ts @@ -7,6 +7,7 @@ import { Param, Body, Query, + UseGuards, } from '@nestjs/common'; import { UsersService } from '../providers/users.service'; import { XpLevelService } from '../providers/xp-level.service'; @@ -15,9 +16,13 @@ import { paginationQueryDto } from '../../common/pagination/paginationQueryDto'; import { EditUserDto } from '../dtos/editUserDto.dto'; import { CreateUserDto } from '../dtos/createUserDto'; import { User } from '../user.entity'; +import { RolesGuard } from '../../roles/roles.guard'; +import { Roles } from '../../roles/roles.decorator'; +import { userRole } from '../enums/userRole.enum'; @Controller('users') @ApiTags('users') +@UseGuards(RolesGuard) export class UsersController { constructor( private readonly usersService: UsersService, @@ -25,6 +30,7 @@ export class UsersController { ) {} @Delete(':id') + @Roles(userRole.ADMIN) @ApiOperation({ summary: 'Delete user by ID' }) @ApiResponse({ status: 200, description: 'User successfully deleted' }) @ApiResponse({ status: 404, description: 'User not found' }) @@ -51,11 +57,13 @@ export class UsersController { } @Get() + @Roles(userRole.ADMIN, userRole.MODERATOR) findAll(@Query() dto: paginationQueryDto) { return this.usersService.findAllUsers(dto); } @Get(':id') + @Roles({ roles: [userRole.ADMIN, userRole.MODERATOR], ownership: { param: 'id' } }) findOne(@Param('id') id: string) { return id; } @@ -73,6 +81,7 @@ export class UsersController { } @Patch(':id') + @Roles({ roles: [userRole.ADMIN], ownership: { param: 'id' } }) @ApiOperation({ summary: 'Update user by ID' }) @ApiResponse({ status: 200, description: 'user successfully updated' }) @ApiResponse({ status: 404, description: 'User not found' }) diff --git a/backend/src/users/enums/userRole.enum.ts b/backend/src/users/enums/userRole.enum.ts index 98ce5b1..568bd2f 100644 --- a/backend/src/users/enums/userRole.enum.ts +++ b/backend/src/users/enums/userRole.enum.ts @@ -1,5 +1,6 @@ export enum userRole { ADMIN = 'admin', + MODERATOR = 'moderator', USER = 'user', GUEST = 'guest', } From cb453f5c1322c37a0763ffacdc8a7263fe27f817 Mon Sep 17 00:00:00 2001 From: nafiuishaaq Date: Thu, 26 Mar 2026 15:09:04 +0100 Subject: [PATCH 4/5] implemented the patches --- middleware/src/index.ts | 1 + .../advanced/circuit-breaker.middleware.ts | 243 ++++++++++++++++++ middleware/src/middleware/advanced/index.ts | 2 + .../middleware/advanced/timeout.middleware.ts | 62 +++++ middleware/src/middleware/index.ts | 1 + .../unit/circuit-breaker.middleware.spec.ts | 153 +++++++++++ .../tests/unit/timeout.middleware.spec.ts | 68 +++++ 7 files changed, 530 insertions(+) create mode 100644 middleware/src/middleware/advanced/circuit-breaker.middleware.ts create mode 100644 middleware/src/middleware/advanced/index.ts create mode 100644 middleware/src/middleware/advanced/timeout.middleware.ts create mode 100644 middleware/src/middleware/index.ts create mode 100644 middleware/tests/unit/circuit-breaker.middleware.spec.ts create mode 100644 middleware/tests/unit/timeout.middleware.spec.ts diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 79fc8e9..b4635e7 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -8,3 +8,4 @@ export * from './monitoring'; export * from './validation'; export * from './common'; export * from './config'; +export * from './middleware'; diff --git a/middleware/src/middleware/advanced/circuit-breaker.middleware.ts b/middleware/src/middleware/advanced/circuit-breaker.middleware.ts new file mode 100644 index 0000000..c67ab6d --- /dev/null +++ b/middleware/src/middleware/advanced/circuit-breaker.middleware.ts @@ -0,0 +1,243 @@ +import { + DynamicModule, + Global, + Inject, + Injectable, + Logger, + Module, + NestMiddleware, + ServiceUnavailableException, +} from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; + +export type CircuitBreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + +export const CIRCUIT_BREAKER_OPTIONS = 'CIRCUIT_BREAKER_OPTIONS'; + +export interface CircuitBreakerMiddlewareOptions { + name?: string; + failureThreshold?: number; + timeoutWindowMs?: number; + halfOpenRetryIntervalMs?: number; +} + +export interface CircuitBreakerSnapshot { + name: string; + state: CircuitBreakerState; + failureCount: number; + failureThreshold: number; + timeoutWindowMs: number; + halfOpenRetryIntervalMs: number; + nextAttemptAt: number | null; +} + +@Injectable() +export class CircuitBreakerService { + private readonly logger = new Logger(CircuitBreakerService.name); + private readonly name: string; + private readonly failureThreshold: number; + private readonly timeoutWindowMs: number; + private readonly halfOpenRetryIntervalMs: number; + + private state: CircuitBreakerState = 'CLOSED'; + private failureCount = 0; + private nextAttemptAt: number | null = null; + private halfOpenInFlight = false; + + constructor( + @Inject(CIRCUIT_BREAKER_OPTIONS) + options: CircuitBreakerMiddlewareOptions = {}, + ) { + this.name = options.name ?? 'middleware-circuit-breaker'; + this.failureThreshold = options.failureThreshold ?? 5; + this.timeoutWindowMs = options.timeoutWindowMs ?? 10_000; + this.halfOpenRetryIntervalMs = options.halfOpenRetryIntervalMs ?? 30_000; + } + + getState(): CircuitBreakerState { + this.refreshState(); + return this.state; + } + + getSnapshot(): CircuitBreakerSnapshot { + this.refreshState(); + + return { + name: this.name, + state: this.state, + failureCount: this.failureCount, + failureThreshold: this.failureThreshold, + timeoutWindowMs: this.timeoutWindowMs, + halfOpenRetryIntervalMs: this.halfOpenRetryIntervalMs, + nextAttemptAt: this.nextAttemptAt, + }; + } + + canRequest(): boolean { + this.refreshState(); + + if (this.state === 'OPEN') { + return false; + } + + if (this.state === 'HALF_OPEN' && this.halfOpenInFlight) { + return false; + } + + if (this.state === 'HALF_OPEN') { + this.halfOpenInFlight = true; + } + + return true; + } + + recordSuccess(): void { + const previousState = this.state; + + this.state = 'CLOSED'; + this.failureCount = 0; + this.nextAttemptAt = null; + this.halfOpenInFlight = false; + + if (previousState !== 'CLOSED') { + this.logger.log( + `Circuit "${this.name}" closed after a successful recovery attempt.`, + ); + } + } + + recordFailure(): void { + this.refreshState(); + this.failureCount += 1; + + if ( + this.state === 'HALF_OPEN' || + this.failureCount >= this.failureThreshold + ) { + this.openCircuit(); + return; + } + + this.logger.warn( + `Circuit "${this.name}" failure count is ${this.failureCount}/${this.failureThreshold}.`, + ); + } + + private refreshState(): void { + if ( + this.state === 'OPEN' && + this.nextAttemptAt !== null && + Date.now() >= this.nextAttemptAt + ) { + this.state = 'HALF_OPEN'; + this.halfOpenInFlight = false; + this.failureCount = Math.max(this.failureCount, this.failureThreshold); + this.logger.warn(`Circuit "${this.name}" moved to HALF_OPEN.`); + } + } + + private openCircuit(): void { + this.state = 'OPEN'; + this.nextAttemptAt = Date.now() + this.halfOpenRetryIntervalMs; + this.halfOpenInFlight = false; + + this.logger.error( + `Circuit "${this.name}" opened after ${this.failureCount} failures.`, + ); + } +} + +@Injectable() +export class CircuitBreakerMiddleware implements NestMiddleware { + private readonly logger = new Logger(CircuitBreakerMiddleware.name); + + constructor(private readonly circuitBreakerService: CircuitBreakerService) {} + + use(req: Request, res: Response, next: NextFunction): void { + if (!this.circuitBreakerService.canRequest()) { + const snapshot = this.circuitBreakerService.getSnapshot(); + const retryAt = snapshot.nextAttemptAt + ? new Date(snapshot.nextAttemptAt).toISOString() + : 'unknown'; + const message = `Circuit breaker is OPEN for ${snapshot.name}. Retry after ${retryAt}.`; + + this.logger.warn(message); + next(new ServiceUnavailableException(message)); + return; + } + + let settled = false; + + const finalizeSuccess = () => { + if (settled) { + return; + } + + settled = true; + cleanup(); + this.circuitBreakerService.recordSuccess(); + }; + + const finalizeFailure = () => { + if (settled) { + return; + } + + settled = true; + cleanup(); + this.circuitBreakerService.recordFailure(); + }; + + const onFinish = () => { + if (res.statusCode >= 500) { + finalizeFailure(); + return; + } + + finalizeSuccess(); + }; + + const onClose = () => { + if (!res.writableEnded) { + finalizeFailure(); + } + }; + + const cleanup = () => { + res.removeListener('finish', onFinish); + res.removeListener('close', onClose); + }; + + res.once('finish', onFinish); + res.once('close', onClose); + + next((error?: unknown) => { + if (error) { + finalizeFailure(); + } + + next(error as any); + }); + } +} + +@Global() +@Module({}) +export class CircuitBreakerModule { + static register( + options: CircuitBreakerMiddlewareOptions = {}, + ): DynamicModule { + return { + module: CircuitBreakerModule, + providers: [ + { + provide: CIRCUIT_BREAKER_OPTIONS, + useValue: options, + }, + CircuitBreakerService, + CircuitBreakerMiddleware, + ], + exports: [CircuitBreakerService, CircuitBreakerMiddleware], + }; + } +} diff --git a/middleware/src/middleware/advanced/index.ts b/middleware/src/middleware/advanced/index.ts new file mode 100644 index 0000000..39b4fd9 --- /dev/null +++ b/middleware/src/middleware/advanced/index.ts @@ -0,0 +1,2 @@ +export * from './timeout.middleware'; +export * from './circuit-breaker.middleware'; diff --git a/middleware/src/middleware/advanced/timeout.middleware.ts b/middleware/src/middleware/advanced/timeout.middleware.ts new file mode 100644 index 0000000..51cc2cf --- /dev/null +++ b/middleware/src/middleware/advanced/timeout.middleware.ts @@ -0,0 +1,62 @@ +import { + Inject, + Injectable, + Logger, + NestMiddleware, + ServiceUnavailableException, +} from '@nestjs/common'; +import { NextFunction, Request, Response } from 'express'; + +export const TIMEOUT_MIDDLEWARE_OPTIONS = 'TIMEOUT_MIDDLEWARE_OPTIONS'; + +export interface TimeoutMiddlewareOptions { + timeoutMs?: number; + message?: string; +} + +@Injectable() +export class TimeoutMiddleware implements NestMiddleware { + private readonly logger = new Logger(TimeoutMiddleware.name); + private readonly timeoutMs: number; + private readonly message: string; + + constructor( + @Inject(TIMEOUT_MIDDLEWARE_OPTIONS) + options: TimeoutMiddlewareOptions = {}, + ) { + this.timeoutMs = options.timeoutMs ?? 5000; + this.message = + options.message ?? + `Request timed out after ${this.timeoutMs}ms while waiting for middleware execution.`; + } + + use(_req: Request, res: Response, next: NextFunction): void { + let completed = false; + + const clear = () => { + completed = true; + clearTimeout(timer); + res.removeListener('finish', onComplete); + res.removeListener('close', onComplete); + }; + + const onComplete = () => { + clear(); + }; + + const timer = setTimeout(() => { + if (completed || res.headersSent) { + return; + } + + clear(); + this.logger.warn(this.message); + next(new ServiceUnavailableException(this.message)); + }, this.timeoutMs); + + res.once('finish', onComplete); + res.once('close', onComplete); + + next(); + } +} diff --git a/middleware/src/middleware/index.ts b/middleware/src/middleware/index.ts new file mode 100644 index 0000000..93f5841 --- /dev/null +++ b/middleware/src/middleware/index.ts @@ -0,0 +1 @@ +export * from './advanced'; diff --git a/middleware/tests/unit/circuit-breaker.middleware.spec.ts b/middleware/tests/unit/circuit-breaker.middleware.spec.ts new file mode 100644 index 0000000..538e917 --- /dev/null +++ b/middleware/tests/unit/circuit-breaker.middleware.spec.ts @@ -0,0 +1,153 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { + CircuitBreakerMiddleware, + CircuitBreakerService, +} from '../../src/middleware/advanced/circuit-breaker.middleware'; + +describe('CircuitBreakerService', () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-03-26T10:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('stays CLOSED until the configured failure threshold is reached', () => { + const service = new CircuitBreakerService({ + name: 'auth-service', + failureThreshold: 3, + halfOpenRetryIntervalMs: 1000, + }); + + service.recordFailure(); + expect(service.getState()).toBe('CLOSED'); + + service.recordFailure(); + expect(service.getState()).toBe('CLOSED'); + + service.recordFailure(); + expect(service.getState()).toBe('OPEN'); + }); + + it('transitions from OPEN to HALF_OPEN after the retry interval', () => { + const service = new CircuitBreakerService({ + name: 'auth-service', + failureThreshold: 2, + halfOpenRetryIntervalMs: 1000, + }); + + service.recordFailure(); + service.recordFailure(); + expect(service.getState()).toBe('OPEN'); + + jest.advanceTimersByTime(999); + expect(service.getState()).toBe('OPEN'); + + jest.advanceTimersByTime(1); + expect(service.getState()).toBe('HALF_OPEN'); + }); + + it('transitions from HALF_OPEN to CLOSED after a successful trial request', () => { + const service = new CircuitBreakerService({ + name: 'auth-service', + failureThreshold: 1, + halfOpenRetryIntervalMs: 1000, + }); + + service.recordFailure(); + expect(service.getState()).toBe('OPEN'); + + jest.advanceTimersByTime(1000); + expect(service.getState()).toBe('HALF_OPEN'); + expect(service.canRequest()).toBe(true); + + service.recordSuccess(); + + expect(service.getState()).toBe('CLOSED'); + expect(service.getSnapshot().failureCount).toBe(0); + }); + + it('transitions from HALF_OPEN back to OPEN when the trial request fails', () => { + const service = new CircuitBreakerService({ + name: 'auth-service', + failureThreshold: 1, + halfOpenRetryIntervalMs: 1000, + }); + + service.recordFailure(); + jest.advanceTimersByTime(1000); + + expect(service.getState()).toBe('HALF_OPEN'); + expect(service.canRequest()).toBe(true); + + service.recordFailure(); + + expect(service.getState()).toBe('OPEN'); + }); + + it('exposes the current circuit state through getSnapshot', () => { + const service = new CircuitBreakerService({ + name: 'auth-service', + failureThreshold: 5, + timeoutWindowMs: 2500, + halfOpenRetryIntervalMs: 7000, + }); + + expect(service.getSnapshot()).toMatchObject({ + name: 'auth-service', + state: 'CLOSED', + failureThreshold: 5, + timeoutWindowMs: 2500, + halfOpenRetryIntervalMs: 7000, + }); + }); +}); + +describe('CircuitBreakerMiddleware', () => { + it('returns 503 while the circuit is OPEN', () => { + const service = new CircuitBreakerService({ + name: 'auth-service', + failureThreshold: 1, + halfOpenRetryIntervalMs: 1000, + }); + const middleware = new CircuitBreakerMiddleware(service); + const next = jest.fn(); + + service.recordFailure(); + + middleware.use( + {} as any, + createResponse(), + next, + ); + + expect(next).toHaveBeenCalledWith(expect.any(ServiceUnavailableException)); + }); +}); + +function createResponse() { + const listeners = new Map void>>(); + + return { + statusCode: 200, + writableEnded: false, + once: jest.fn((event: string, handler: () => void) => { + const current = listeners.get(event) ?? []; + listeners.set(event, [...current, handler]); + }), + removeListener: jest.fn((event: string, handler: () => void) => { + const current = listeners.get(event) ?? []; + listeners.set( + event, + current.filter((candidate) => candidate !== handler), + ); + }), + emit: (event: string) => { + for (const handler of listeners.get(event) ?? []) { + handler(); + } + }, + } as any; +} diff --git a/middleware/tests/unit/timeout.middleware.spec.ts b/middleware/tests/unit/timeout.middleware.spec.ts new file mode 100644 index 0000000..663005f --- /dev/null +++ b/middleware/tests/unit/timeout.middleware.spec.ts @@ -0,0 +1,68 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { TimeoutMiddleware } from '../../src/middleware/advanced/timeout.middleware'; + +describe('TimeoutMiddleware', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns a 503 error when the timeout threshold is exceeded', () => { + const middleware = new TimeoutMiddleware({ + timeoutMs: 100, + message: 'Middleware execution timed out.', + }); + const response = createResponse(); + const next = jest.fn(); + + middleware.use({} as any, response, next); + + expect(next).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(100); + + expect(next).toHaveBeenLastCalledWith( + expect.any(ServiceUnavailableException), + ); + }); + + it('clears the timeout when the response completes in time', () => { + const middleware = new TimeoutMiddleware({ + timeoutMs: 100, + }); + const response = createResponse(); + const next = jest.fn(); + + middleware.use({} as any, response, next); + response.emit('finish'); + jest.advanceTimersByTime(100); + + expect(next).toHaveBeenCalledTimes(1); + }); +}); + +function createResponse() { + const listeners = new Map void>>(); + + return { + headersSent: false, + once: jest.fn((event: string, handler: () => void) => { + const current = listeners.get(event) ?? []; + listeners.set(event, [...current, handler]); + }), + removeListener: jest.fn((event: string, handler: () => void) => { + const current = listeners.get(event) ?? []; + listeners.set( + event, + current.filter((candidate) => candidate !== handler), + ); + }), + emit: (event: string) => { + for (const handler of listeners.get(event) ?? []) { + handler(); + } + }, + } as any; +} From c718b77ec5f68ec540e72641a3a998599fd2bf55 Mon Sep 17 00:00:00 2001 From: nafiuishaaq Date: Thu, 26 Mar 2026 15:10:21 +0100 Subject: [PATCH 5/5] implemented the patches --- .../advanced/circuit-breaker.middleware.ts | 33 ++++++++++--------- .../middleware/advanced/timeout.middleware.ts | 21 ++++++++++++ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/middleware/src/middleware/advanced/circuit-breaker.middleware.ts b/middleware/src/middleware/advanced/circuit-breaker.middleware.ts index c67ab6d..fcefa54 100644 --- a/middleware/src/middleware/advanced/circuit-breaker.middleware.ts +++ b/middleware/src/middleware/advanced/circuit-breaker.middleware.ts @@ -40,7 +40,7 @@ export class CircuitBreakerService { private readonly halfOpenRetryIntervalMs: number; private state: CircuitBreakerState = 'CLOSED'; - private failureCount = 0; + private failureTimestamps: number[] = []; private nextAttemptAt: number | null = null; private halfOpenInFlight = false; @@ -65,7 +65,7 @@ export class CircuitBreakerService { return { name: this.name, state: this.state, - failureCount: this.failureCount, + failureCount: this.failureTimestamps.length, failureThreshold: this.failureThreshold, timeoutWindowMs: this.timeoutWindowMs, halfOpenRetryIntervalMs: this.halfOpenRetryIntervalMs, @@ -95,7 +95,7 @@ export class CircuitBreakerService { const previousState = this.state; this.state = 'CLOSED'; - this.failureCount = 0; + this.failureTimestamps = []; this.nextAttemptAt = null; this.halfOpenInFlight = false; @@ -108,22 +108,25 @@ export class CircuitBreakerService { recordFailure(): void { this.refreshState(); - this.failureCount += 1; + this.failureTimestamps.push(Date.now()); + this.pruneFailures(); if ( this.state === 'HALF_OPEN' || - this.failureCount >= this.failureThreshold + this.failureTimestamps.length >= this.failureThreshold ) { this.openCircuit(); return; } this.logger.warn( - `Circuit "${this.name}" failure count is ${this.failureCount}/${this.failureThreshold}.`, + `Circuit "${this.name}" failure count is ${this.failureTimestamps.length}/${this.failureThreshold}.`, ); } private refreshState(): void { + this.pruneFailures(); + if ( this.state === 'OPEN' && this.nextAttemptAt !== null && @@ -131,18 +134,24 @@ export class CircuitBreakerService { ) { this.state = 'HALF_OPEN'; this.halfOpenInFlight = false; - this.failureCount = Math.max(this.failureCount, this.failureThreshold); this.logger.warn(`Circuit "${this.name}" moved to HALF_OPEN.`); } } + private pruneFailures(): void { + const thresholdTime = Date.now() - this.timeoutWindowMs; + this.failureTimestamps = this.failureTimestamps.filter( + (timestamp) => timestamp >= thresholdTime, + ); + } + private openCircuit(): void { this.state = 'OPEN'; this.nextAttemptAt = Date.now() + this.halfOpenRetryIntervalMs; this.halfOpenInFlight = false; this.logger.error( - `Circuit "${this.name}" opened after ${this.failureCount} failures.`, + `Circuit "${this.name}" opened after ${this.failureTimestamps.length} failures within ${this.timeoutWindowMs}ms.`, ); } } @@ -211,13 +220,7 @@ export class CircuitBreakerMiddleware implements NestMiddleware { res.once('finish', onFinish); res.once('close', onClose); - next((error?: unknown) => { - if (error) { - finalizeFailure(); - } - - next(error as any); - }); + next(); } } diff --git a/middleware/src/middleware/advanced/timeout.middleware.ts b/middleware/src/middleware/advanced/timeout.middleware.ts index 51cc2cf..4da539c 100644 --- a/middleware/src/middleware/advanced/timeout.middleware.ts +++ b/middleware/src/middleware/advanced/timeout.middleware.ts @@ -1,7 +1,10 @@ import { + DynamicModule, + Global, Inject, Injectable, Logger, + Module, NestMiddleware, ServiceUnavailableException, } from '@nestjs/common'; @@ -60,3 +63,21 @@ export class TimeoutMiddleware implements NestMiddleware { next(); } } + +@Global() +@Module({}) +export class TimeoutMiddlewareModule { + static register(options: TimeoutMiddlewareOptions = {}): DynamicModule { + return { + module: TimeoutMiddlewareModule, + providers: [ + { + provide: TIMEOUT_MIDDLEWARE_OPTIONS, + useValue: options, + }, + TimeoutMiddleware, + ], + exports: [TimeoutMiddleware], + }; + } +}