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
```
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 {}
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -149,6 +150,7 @@ async function bootstrapWorker(): Promise<void> {
});

app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalInterceptors(new ApiVersionInterceptor());
app.useGlobalInterceptors(new ResponseTransformInterceptor());
app.enableCors(corsConfig);

Expand Down
Loading