Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,16 +192,20 @@ 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:

```bash
curl -H "X-API-Version: 1" http://localhost:3000/users
```

Read more in the API versioning documentation:

- `docs/api/versioning.md`

## πŸ“Š Architecture

## βš™οΈ Tech Stack
Expand Down
5 changes: 5 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
14 changes: 12 additions & 2 deletions docs/api/openapi-spec.yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
72 changes: 72 additions & 0 deletions docs/api/versioning.md
Original file line number Diff line number Diff line change
@@ -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 <token>" \
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: <date>`
- `Link: <https://docs.teachlink.com/api/versioning#migration-guides>; rel="migration"; type="text/html"`
- `X-API-Deprecation-Notice: <message>`

## 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 <token>
X-API-Version: 1
```
11 changes: 11 additions & 0 deletions docs/logging.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 92 additions & 0 deletions src/common/interceptors/api-version.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
const request = context.switchToHttp().getRequest<Request>();
const response = context.switchToHttp().getResponse<Response>();
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}/`));
}
}
7 changes: 7 additions & 0 deletions src/common/modules/api-versioning.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
33 changes: 33 additions & 0 deletions src/logging/request-id.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
82 changes: 82 additions & 0 deletions src/logging/structured-logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export type LogMeta = Record<string, unknown>;

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<string, unknown> = {
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));
}
Loading