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/docs/logging.md b/docs/logging.md new file mode 100644 index 00000000..9cc27770 --- /dev/null +++ b/docs/logging.md @@ -0,0 +1,11 @@ +**Structured Logging** + +- **Format**: JSON per-line logs with fields: `timestamp`, `level`, `service`, `pid`, `message`, `meta`, `data`. +- **Initialization**: The application initializes structured logging on startup via `initStructuredLogging()` in `src/main.ts`. +- **Request tracing**: Each HTTP request gets an `x-request-id` header and two logs: `request_start` and `request_end` with `durationMs` and `statusCode`. + +Recommendations for aggregation and parsing: +- Send stdout/stderr to your log collector (CloudWatch, Datadog, ELK, Splunk). The logs are JSON so they can be indexed and searched. +- Use `service` and `requestId` fields to correlate traces across services. + +If you want to switch to a production logger (pino/winston), replace `src/logging/structured-logging.ts` with an adapter that writes structured JSON and preserves these fields. 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/logging/request-id.middleware.ts b/src/logging/request-id.middleware.ts new file mode 100644 index 00000000..b3fa61f0 --- /dev/null +++ b/src/logging/request-id.middleware.ts @@ -0,0 +1,33 @@ +import { type Request, type Response, type NextFunction } from 'express'; + +function makeId(): string { + // simple fast random id + return Math.random().toString(36).slice(2, 10); +} + +export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void { + const header = req.headers['x-request-id'] as string | undefined; + const requestId = header || `${Date.now().toString(36)}-${makeId()}`; + // attach to request for handlers + (req as any).requestId = requestId; + res.setHeader('x-request-id', requestId); + + const started = Date.now(); + const remoteAddr = req.headers['x-forwarded-for'] || req.socket.remoteAddress; + + console.info({ event: 'request_start', method: req.method, url: req.originalUrl || req.url, requestId, remoteAddr }); + + res.on('finish', () => { + const duration = Date.now() - started; + console.info({ + event: 'request_end', + method: req.method, + url: req.originalUrl || req.url, + statusCode: res.statusCode, + durationMs: duration, + requestId, + }); + }); + + next(); +} diff --git a/src/logging/structured-logging.ts b/src/logging/structured-logging.ts new file mode 100644 index 00000000..9d834014 --- /dev/null +++ b/src/logging/structured-logging.ts @@ -0,0 +1,82 @@ +export type LogMeta = Record; + +function timestamp(): string { + return new Date().toISOString(); +} + +function safeSerialize(arg: unknown): unknown { + if (arg instanceof Error) { + return { message: arg.message, stack: arg.stack }; + } + return arg; +} + +function formatStructured(level: string, service: string, args: IArguments, meta: LogMeta = {}) { + const msgParts: unknown[] = Array.prototype.slice.call(args); + const message = typeof msgParts[0] === 'string' ? msgParts.shift() : undefined; + const extra = msgParts.length === 1 ? safeSerialize(msgParts[0]) : msgParts.map(safeSerialize); + + const out: Record = { + timestamp: timestamp(), + level, + service, + pid: process.pid, + }; + + if (message) out.message = message; + if (meta && Object.keys(meta).length > 0) out.meta = meta; + if (extra !== undefined && (Array.isArray(extra) ? extra.length > 0 : Object.keys((extra as any) || {}).length > 0)) { + out.data = extra; + } + + try { + return JSON.stringify(out); + } catch (err) { + return JSON.stringify({ timestamp: timestamp(), level, service, pid: process.pid, message: 'failed to stringify log' }); + } +} + +let _serviceName = 'teachlink-backend'; + +export function initStructuredLogging(serviceName?: string): void { + if (serviceName) _serviceName = serviceName; + + const originalLog = console.log.bind(console); + const originalInfo = console.info.bind(console); + const originalWarn = console.warn.bind(console); + const originalError = console.error.bind(console); + const originalDebug = console.debug ? console.debug.bind(console) : originalLog; + + console.log = function log() { + originalLog(formatStructured('info', _serviceName, arguments)); + } as typeof console.log; + + console.info = function info() { + originalInfo(formatStructured('info', _serviceName, arguments)); + } as typeof console.info; + + console.warn = function warn() { + originalWarn(formatStructured('warn', _serviceName, arguments)); + } as typeof console.warn; + + console.error = function error() { + originalError(formatStructured('error', _serviceName, arguments)); + } as typeof console.error; + + console.debug = function debug() { + originalDebug(formatStructured('debug', _serviceName, arguments)); + } as typeof console.debug; + + process.on('uncaughtException', (err) => { + console.error('uncaughtException', { error: safeSerialize(err) }); + process.exit(1); + }); + + process.on('unhandledRejection', (reason) => { + console.error('unhandledRejection', { reason: safeSerialize(reason) }); + }); +} + +export function buildLogObject(level: string, message: string, meta: LogMeta = {}) { + return JSON.parse(formatStructured(level, _serviceName, [message], meta)); +} diff --git a/src/main.ts b/src/main.ts index 8b03debc..325b8876 100644 --- a/src/main.ts +++ b/src/main.ts @@ -8,10 +8,13 @@ import session, { type Session, type SessionData } from 'express-session'; import { RedisStore } from 'connect-redis'; import Redis from 'ioredis'; import { AppModule } from './app.module'; +import { initStructuredLogging } from './logging/structured-logging'; +import { requestIdMiddleware } from './logging/request-id.middleware'; import { GlobalExceptionFilter } from './common/interceptors/global-exception.filter'; 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, @@ -30,6 +33,7 @@ type SessionRequest = Request & { }; async function bootstrapWorker(): Promise { + initStructuredLogging(process.env.SERVICE_NAME || 'teachlink-backend'); const logger = new Logger('Bootstrap'); const bootstrapStartTime = Date.now(); const requestBodyLimit = process.env.REQUEST_BODY_LIMIT || '1mb'; @@ -105,6 +109,8 @@ async function bootstrapWorker(): Promise { expressApp.set('trust proxy', 1); } + // attach request id and basic HTTP access logs + app.use(requestIdMiddleware); app.use(correlationMiddleware); app.use( @@ -149,6 +155,7 @@ async function bootstrapWorker(): Promise { }); app.useGlobalFilters(new GlobalExceptionFilter()); + app.useGlobalInterceptors(new ApiVersionInterceptor()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); app.enableCors(corsConfig);