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..556c944 --- /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 { Transform, 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() + @Transform(({ value }) => value === true || value === 'true') + @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; + } +}