diff --git a/README.md b/README.md index 92e92584..ffc6a021 100644 --- a/README.md +++ b/README.md @@ -197,9 +197,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: @@ -207,6 +207,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 704133f0..a1df1d3a 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/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 77b7d652..4654aef6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -45,6 +45,7 @@ type SessionRequest = Request & { }; async function bootstrapWorker(): Promise { + initStructuredLogging(process.env.SERVICE_NAME || 'teachlink-backend'); const logger = new Logger('Bootstrap'); const bootstrapStartTime = Date.now(); @@ -192,6 +193,8 @@ async function bootstrapWorker(): Promise { expressApp.set('trust proxy', 1); } + // attach request id and basic HTTP access logs + app.use(requestIdMiddleware); app.use(correlationMiddleware); const auditLogService = app.get(AuditLogService);