From 016e437d8c44376e4ef849099c74bdaf33e52c7c Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 27 May 2026 19:26:14 +0100 Subject: [PATCH] Implement header-based API versioning with deprecation policy and migration guidance --- README.md | 8 +- docs/api/README.md | 5 + docs/api/openapi-spec.yaml.md | 14 ++- docs/api/versioning.md | 72 +++++++++++++++ .../interceptors/api-version.interceptor.ts | 92 +++++++++++++++++++ src/common/modules/api-versioning.module.ts | 7 ++ src/main.ts | 2 + 7 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 docs/api/versioning.md create mode 100644 src/common/interceptors/api-version.interceptor.ts create mode 100644 src/common/modules/api-versioning.module.ts diff --git a/README.md b/README.md index c3b903e4..e14cdd49 100644 --- a/README.md +++ b/README.md @@ -192,9 +192,9 @@ TeachLink uses a header-based API versioning strategy for application endpoints. - Send `X-API-Version: 1` with every versioned API request. - Supported versions are configured through `API_SUPPORTED_VERSIONS` and default to `1`. -- `API_DEFAULT_VERSION` controls the currently active route version and defaults to `1`. -- Health checks, metrics endpoints, the root route, and payment webhooks are version-neutral. +- Deprecated versions return `Deprecation`, `Sunset`, `Link`, and `X-API-Deprecation-Notice` headers. - Requests with a missing or invalid API version header return a client error before the request reaches the controller. +- Deprecated versions remain available until sunset and then return HTTP `410 Gone` at end of life. Example: @@ -202,6 +202,10 @@ Example: curl -H "X-API-Version: 1" http://localhost:3000/users ``` +Read more in the API versioning documentation: + +- `docs/api/versioning.md` + ## 📊 Architecture ## ⚙️ Tech Stack diff --git a/docs/api/README.md b/docs/api/README.md index f4c23808..2faff9c4 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -24,6 +24,11 @@ Welcome to the comprehensive API documentation for the TeachLink platform. This **API Version**: v1.0.0 +**Versioning**: Header-based versioning is enforced with `X-API-Version`. +- Use `X-API-Version: 1` for current versioned requests. +- Deprecated version headers return `Deprecation`, `Sunset`, `Link`, and `X-API-Deprecation-Notice` response headers. +- For migration guidance, see [API Versioning and Deprecation Policy](./versioning.md). + **Interactive Documentation**: - Swagger UI: http://localhost:3000/api/docs diff --git a/docs/api/openapi-spec.yaml.md b/docs/api/openapi-spec.yaml.md index f1e9736b..8a9fa74c 100644 --- a/docs/api/openapi-spec.yaml.md +++ b/docs/api/openapi-spec.yaml.md @@ -489,11 +489,21 @@ The interactive documentation provides: Current API version: **v1.0.0** -All API endpoints are versioned. The version is included in the base URL: +TeachLink uses header-based API versioning. Include the `X-API-Version` header with every versioned request: + ``` -https://api.teachlink.com/v1/{endpoint} +X-API-Version: 1 ``` +Deprecated versions are communicated with response headers: + +- `Deprecation` +- `Sunset` +- `Link` +- `X-API-Deprecation-Notice` + +Requests to missing or invalid API version headers return a client error before reaching the controller. + ## Rate Limiting API endpoints have rate limits applied: diff --git a/docs/api/versioning.md b/docs/api/versioning.md new file mode 100644 index 00000000..e238169d --- /dev/null +++ b/docs/api/versioning.md @@ -0,0 +1,72 @@ +# API Versioning and Deprecation Policy + +TeachLink uses header-based API versioning to support stable evolution without changing existing URLs. + +## Version header support + +Include the `X-API-Version` header with every versioned API request. + +Example: + +```bash +curl -H "X-API-Version: 1" \ + -H "Authorization: Bearer " \ + https://api.teachlink.com/users +``` + +## Supported versions + +- `1` — current supported version + +The API rejects requests with missing or invalid `X-API-Version` values for versioned endpoints. + +## Deprecation notices + +Deprecated API versions are announced with response headers when a request is still accepted. + +Response headers include: + +- `Deprecation: true` +- `Sunset: ` +- `Link: ; rel="migration"; type="text/html"` +- `X-API-Deprecation-Notice: ` + +## Migration guides + +Migration instructions and version transition notes are documented here in this file. + +### Example migration path + +- Migrate from `0` to `1` by updating clients to send `X-API-Version: 1` +- Use the current API schema for version `1` +- Verify request and response contracts against the latest OpenAPI documentation + +## End-of-life policy + +Deprecated versions remain available until the sunset date. + +Once a sunset date passes, the API rejects requests to the deprecated version with HTTP `410 Gone`. + +### Example lifecycle + +- `0` deprecated on `2025-12-31` +- `0` sunset and end-of-life on `2026-06-30` + +## Version-neutral endpoints + +Certain system routes do not require version headers and remain available without `X-API-Version`: + +- `/` +- `/health` +- `/metrics` + +## Quick reference + +Required headers for versioned endpoints: + +``` +Content-Type: application/json +Accept: application/json +Authorization: Bearer +X-API-Version: 1 +``` diff --git a/src/common/interceptors/api-version.interceptor.ts b/src/common/interceptors/api-version.interceptor.ts new file mode 100644 index 00000000..bc3232f3 --- /dev/null +++ b/src/common/interceptors/api-version.interceptor.ts @@ -0,0 +1,92 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import type { Request, Response } from 'express'; + +export const API_VERSION_HEADER = 'X-API-Version'; +export const DEFAULT_API_VERSION = '1'; +export const SUPPORTED_API_VERSIONS = ['1']; + +export type DeprecatedApiVersion = { + version: string; + deprecatedAt: string; + sunsetAt: string; + migrationGuide: string; + message: string; +}; + +export const DEPRECATED_API_VERSIONS: DeprecatedApiVersion[] = [ + { + version: '0', + deprecatedAt: '2025-12-31', + sunsetAt: '2026-06-30', + migrationGuide: 'https://docs.teachlink.com/api/versioning#migration-guides', + message: + 'Version 0 is deprecated and will sunset on 2026-06-30. Upgrade to version 1 using the migration guide.', + }, +]; + +const VERSION_NEUTRAL_PATHS = ['/health', '/metrics', '/']; + +@Injectable() +export class ApiVersionInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const headerValue = request.headers[API_VERSION_HEADER.toLowerCase()]; + const apiVersion = Array.isArray(headerValue) ? headerValue[0] : headerValue; + const requestPath = request.path || '/'; + + if (!this.isVersionNeutralPath(requestPath) && !apiVersion) { + throw new HttpException( + `Missing ${API_VERSION_HEADER} header. Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}.`, + HttpStatus.BAD_REQUEST, + ); + } + + const resolvedVersion = apiVersion || DEFAULT_API_VERSION; + + if (!this.isVersionNeutralPath(requestPath) && !SUPPORTED_API_VERSIONS.includes(resolvedVersion)) { + throw new HttpException( + `Invalid ${API_VERSION_HEADER} header '${resolvedVersion}'. Supported versions: ${SUPPORTED_API_VERSIONS.join(', ')}.`, + HttpStatus.BAD_REQUEST, + ); + } + + const deprecatedVersion = DEPRECATED_API_VERSIONS.find((entry) => entry.version === resolvedVersion); + if (deprecatedVersion) { + const sunsetTimestamp = Date.parse(deprecatedVersion.sunsetAt); + + if (!Number.isNaN(sunsetTimestamp) && Date.now() >= sunsetTimestamp) { + throw new HttpException( + `API version ${resolvedVersion} has reached end of life on ${deprecatedVersion.sunsetAt}. Please migrate to version ${DEFAULT_API_VERSION}.`, + HttpStatus.GONE, + ); + } + + response.setHeader('Deprecation', 'true'); + response.setHeader('Sunset', new Date(deprecatedVersion.sunsetAt).toUTCString()); + response.setHeader( + 'Link', + `<${deprecatedVersion.migrationGuide}>; rel="migration"; type="text/html"`, + ); + response.setHeader('X-API-Deprecation-Notice', deprecatedVersion.message); + } + + if (resolvedVersion) { + response.setHeader(API_VERSION_HEADER, resolvedVersion); + } + + return next.handle(); + } + + private isVersionNeutralPath(path: string): boolean { + return VERSION_NEUTRAL_PATHS.some((neutralPath) => path === neutralPath || path.startsWith(`${neutralPath}/`)); + } +} diff --git a/src/common/modules/api-versioning.module.ts b/src/common/modules/api-versioning.module.ts new file mode 100644 index 00000000..155b4990 --- /dev/null +++ b/src/common/modules/api-versioning.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; + +export const API_VERSIONING_DOCUMENTATION = + 'Header-based API versioning with formal deprecation, migration guides, and end-of-life policy.'; + +@Module({}) +export class ApiVersioningModule {} diff --git a/src/main.ts b/src/main.ts index 8b03debc..50b0ef89 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { GlobalExceptionFilter } from './common/interceptors/global-exception.fi import { ResponseTransformInterceptor } from './common/interceptors/response-transform.interceptor'; import { correlationMiddleware } from './common/utils/correlation.utils'; import { + ApiVersionInterceptor, API_VERSION_HEADER, DEFAULT_API_VERSION, SUPPORTED_API_VERSIONS, @@ -149,6 +150,7 @@ async function bootstrapWorker(): Promise { }); app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalInterceptors(new ApiVersionInterceptor()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); app.enableCors(corsConfig);