diff --git a/backend/docs/API_VERSIONING.md b/backend/docs/API_VERSIONING.md deleted file mode 100644 index bdef3e2..0000000 --- a/backend/docs/API_VERSIONING.md +++ /dev/null @@ -1,59 +0,0 @@ -# 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 deleted file mode 100644 index 6b2b616..0000000 --- a/backend/docs/migrations/v0-to-v1.md +++ /dev/null @@ -1,9 +0,0 @@ -# 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 deleted file mode 100644 index 280f97d..0000000 --- a/backend/docs/migrations/v1-to-v2.md +++ /dev/null @@ -1,37 +0,0 @@ -# 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 756f436..5da1b31 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -22,11 +22,6 @@ 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); @@ -103,13 +98,13 @@ import { DocsController } from './docs/docs.controller'; redisClient: redisClient, validateUser: async (userId: string) => await usersService.findOneById(userId), logging: true, - publicRoutes: ['/api/auth', '/api/docs', '/health'], + publicRoutes: ['/auth', '/api', '/docs', '/health'], }), }), HealthModule, ], - controllers: [AppController, DocsController], - providers: [AppService, ApiVersionService], + controllers: [AppController], + providers: [AppService], }) export class AppModule implements NestModule { /** @@ -120,15 +115,10 @@ export class AppModule implements NestModule { .apply(GeolocationMiddleware) .forRoutes('*'); - consumer - .apply(ApiVersionMiddleware) - .forRoutes({ path: 'api/*path', method: RequestMethod.ALL }); - consumer .apply(JwtAuthMiddleware) .exclude( - { path: 'api/auth/(.*)', method: RequestMethod.ALL }, - { path: 'api/docs/(.*)', method: RequestMethod.GET }, + { path: 'auth/(.*)', method: RequestMethod.ALL }, { 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 deleted file mode 100644 index d9baabe..0000000 --- a/backend/src/common/versioning/api-version.constants.ts +++ /dev/null @@ -1,39 +0,0 @@ -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 deleted file mode 100644 index 2fc92b7..0000000 --- a/backend/src/common/versioning/api-version.interceptor.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index 5691629..0000000 --- a/backend/src/common/versioning/api-version.middleware.spec.ts +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 2c6b318..0000000 --- a/backend/src/common/versioning/api-version.middleware.ts +++ /dev/null @@ -1,158 +0,0 @@ -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 deleted file mode 100644 index d41c0dd..0000000 --- a/backend/src/common/versioning/api-version.service.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index aedf10c..0000000 --- a/backend/src/common/versioning/api-version.types.ts +++ /dev/null @@ -1,24 +0,0 @@ -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 deleted file mode 100644 index c54e21c..0000000 --- a/backend/src/common/versioning/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index b72f4ff..0000000 --- a/backend/src/common/versioning/swagger-versioning.ts +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index 93c16e4..0000000 --- a/backend/src/docs/docs.controller.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 031886f..ec9a1de 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,22 +1,15 @@ -import { ValidationPipe, VersioningType } from '@nestjs/common'; +import { ValidationPipe } 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 { - API_VERSION_HEADER, - API_VERSION_QUERY_PARAM, - ApiVersionInterceptor, - ApiVersionService, - buildVersionedSwaggerDocument, -} from './common/versioning'; +import { AppModule } from './app.module'; 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, @@ -25,62 +18,42 @@ async function bootstrap() { }), ); + // Stamp every request with a correlation ID before any other handler runs app.use(new CorrelationIdMiddleware().use.bind(new CorrelationIdMiddleware())); - app.setGlobalPrefix('api', { - exclude: ['health', 'health/*path'], - }); - - app.enableVersioning({ - type: VersioningType.URI, - prefix: 'v', - defaultVersion: apiVersionService.getLatestVersion(), - }); - + // Enable global exception handling (catches ALL errors, not just HttpExceptions) 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. 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()}`) + .setDescription('API documentation for MindBlock Backend') + .setVersion('1.0') .build(); const document = SwaggerModule.createDocument(app, config); - 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); + SwaggerModule.setup('api', app, document); app.enableCors({ origin: '*', methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - 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', - ], + allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, }); + // Graceful shutdown handling const healthService = app.get(HealthService); - + const gracefulShutdown = async (signal: string) => { - console.log(`Received ${signal}. Starting graceful shutdown...`); + console.log(`\nšŸ›‘ Received ${signal}. Starting graceful shutdown...`); + + // Signal health checks that we're shutting down 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); }; @@ -89,7 +62,6 @@ 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 deleted file mode 100644 index e5529c2..0000000 --- a/backend/src/puzzles/controllers/puzzles-v1.controller.ts +++ /dev/null @@ -1,73 +0,0 @@ -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 deleted file mode 100644 index 556c944..0000000 --- a/backend/src/puzzles/controllers/puzzles-v2.controller.ts +++ /dev/null @@ -1,185 +0,0 @@ -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 c194961..276d4f5 100644 --- a/backend/src/puzzles/puzzles.module.ts +++ b/backend/src/puzzles/puzzles.module.ts @@ -2,15 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Puzzle } from './entities/puzzle.entity'; import { Category } from '../categories/entities/category.entity'; -import { PuzzlesV1Controller } from './controllers/puzzles-v1.controller'; -import { PuzzlesV2Controller } from './controllers/puzzles-v2.controller'; +import { PuzzlesController } from './controllers/puzzles.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: [PuzzlesV1Controller, PuzzlesV2Controller], + controllers: [PuzzlesController], providers: [PuzzlesService, CreatePuzzleProvider, GetAllPuzzlesProvider], exports: [TypeOrmModule, PuzzlesService], }) diff --git a/backend/src/types/express.d.ts b/backend/src/types/express.d.ts deleted file mode 100644 index c95a0f4..0000000 --- a/backend/src/types/express.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import 'express'; -import { ApiVersionContext } from '../common/versioning'; - -declare module 'express-serve-static-core' { - interface Request { - apiVersionContext?: ApiVersionContext; - } -}