Skip to content
Merged
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
59 changes: 59 additions & 0 deletions backend/docs/API_VERSIONING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# API Versioning

The backend now supports concurrent API versions for versioned resources.

## Supported selectors

- URL path: `/api/v1/puzzles`, `/api/v2/puzzles`
- Header: `X-API-Version: 1`
- Query parameter: `?api_version=1`

If more than one selector is provided, all explicit values must match. Conflicts return `400 Bad Request`.

## Resolution order

1. URL path version
2. Header version
3. Query parameter version
4. Latest active version when no version is supplied

Current default version: `v2`

## Lifecycle

- `v2`: active
- `v1`: deprecated, sunset scheduled for `2026-06-24T00:00:00.000Z`
- `v0`: removed and returns `410 Gone`

Deprecated responses include:

- `X-API-Deprecation: true`
- `Warning: 299 - "..."`
- `Sunset: <rfc1123 date>` when a sunset date exists

Every versioned response includes:

- `X-API-Version`
- `X-API-Latest-Version`
- `X-API-Version-Status`

## Version differences

### v1 puzzles

- Pagination uses `page` and `limit`
- Item endpoints return the raw puzzle object
- Collection endpoints return `{ data, meta: { page, limit, total } }`

### v2 puzzles

- Pagination uses `page` and `pageSize`
- `pageSize` is capped at 50
- Item endpoints return `{ data, version }`
- Collection endpoints return `{ data, meta: { page, pageSize, total, version, includeCategorySummary } }`

## Auto-generated docs

- Latest: `/api/docs/latest`
- v1: `/api/docs/v1`
- v2: `/api/docs/v2`
9 changes: 9 additions & 0 deletions backend/docs/migrations/v0-to-v1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Migration Guide: v0 to v1

Version 0 has been removed and now returns `410 Gone`.

## Required action

1. Move all clients to `/api/v1/*`
2. Update any fallback version headers or query parameters to `1`
3. Re-test integrations against the maintained v1 contract
37 changes: 37 additions & 0 deletions backend/docs/migrations/v1-to-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Migration Guide: v1 to v2

This guide covers the breaking changes between puzzle API v1 and v2.

## Routing

- Preferred: move from `/api/v1/puzzles` to `/api/v2/puzzles`
- Alternative negotiation also works with `X-API-Version: 2` or `?api_version=2`

## Response contract changes

- `GET /puzzles/:id`
- v1: returns a puzzle object directly
- v2: returns `{ data: <puzzle>, version: "2" }`

- `GET /puzzles`
- v1: returns `{ data, meta: { page, limit, total } }`
- v2: returns `{ data, meta: { page, pageSize, total, version, includeCategorySummary } }`

- `GET /puzzles/daily-quest`
- v1: returns `Puzzle[]`
- v2: returns `{ data: Puzzle[], meta: ... }`

## Request contract changes

- Replace `limit` with `pageSize`
- Expect stricter validation in v2:
- `pageSize` max is `50`
- unsupported legacy query fields are rejected by the validation pipe

## Suggested frontend rollout

1. Update API client defaults to send `X-API-Version: 2`
2. Adjust response mappers for the new envelope format
3. Replace any `limit` usage with `pageSize`
4. Monitor deprecation headers while v1 traffic drains
5. Remove v1-specific parsing before the sunset date
18 changes: 14 additions & 4 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import jwtConfig from './auth/authConfig/jwt.config';
import { UsersService } from './users/providers/users.service';
import { GeolocationMiddleware } from './common/middleware/geolocation.middleware';
import { HealthModule } from './health/health.module';
import {
ApiVersionMiddleware,
ApiVersionService,
} from './common/versioning';
import { DocsController } from './docs/docs.controller';

// const ENV = process.env.NODE_ENV;
// console.log('NODE_ENV:', process.env.NODE_ENV);
Expand Down Expand Up @@ -98,13 +103,13 @@ import { HealthModule } from './health/health.module';
redisClient: redisClient,
validateUser: async (userId: string) => await usersService.findOneById(userId),
logging: true,
publicRoutes: ['/auth', '/api', '/docs', '/health'],
publicRoutes: ['/api/auth', '/api/docs', '/health'],
}),
}),
HealthModule,
],
controllers: [AppController],
providers: [AppService],
controllers: [AppController, DocsController],
providers: [AppService, ApiVersionService],
})
export class AppModule implements NestModule {
/**
Expand All @@ -115,10 +120,15 @@ export class AppModule implements NestModule {
.apply(GeolocationMiddleware)
.forRoutes('*');

consumer
.apply(ApiVersionMiddleware)
.forRoutes({ path: 'api/*path', method: RequestMethod.ALL });

consumer
.apply(JwtAuthMiddleware)
.exclude(
{ path: 'auth/(.*)', method: RequestMethod.ALL },
{ path: 'api/auth/(.*)', method: RequestMethod.ALL },
{ path: 'api/docs/(.*)', method: RequestMethod.GET },
{ path: 'api', method: RequestMethod.GET },
{ path: 'docs', method: RequestMethod.GET },
{ path: 'health', method: RequestMethod.GET },
Expand Down
39 changes: 39 additions & 0 deletions backend/src/common/versioning/api-version.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ApiVersionDefinition } from './api-version.types';

export const API_VERSION_HEADER = 'x-api-version';
export const API_VERSION_QUERY_PARAM = 'api_version';
export const API_VERSION_ROUTE_PREFIX = 'v';

export const VERSIONED_RESOURCES = ['puzzles'] as const;

export const API_VERSION_DEFINITIONS: ApiVersionDefinition[] = [
{
version: '0',
status: 'removed',
releaseDate: '2025-01-15T00:00:00.000Z',
deprecated: true,
deprecationMessage:
'Version 0 has been removed. Upgrade to v1 or v2 immediately.',
removedAt: '2025-12-31T23:59:59.000Z',
successorVersion: '1',
supportedResources: ['puzzles'],
},
{
version: '1',
status: 'deprecated',
releaseDate: '2025-06-01T00:00:00.000Z',
deprecated: true,
deprecationMessage:
'Version 1 remains available during the migration window. Plan your upgrade to v2.',
sunsetDate: '2026-06-24T00:00:00.000Z',
successorVersion: '2',
supportedResources: ['puzzles'],
},
{
version: '2',
status: 'active',
releaseDate: '2026-03-26T00:00:00.000Z',
deprecated: false,
supportedResources: ['puzzles'],
},
];
49 changes: 49 additions & 0 deletions backend/src/common/versioning/api-version.interceptor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ApiVersionService } from './api-version.service';

@Injectable()
export class ApiVersionInterceptor implements NestInterceptor {
constructor(private readonly apiVersionService: ApiVersionService) {}

intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest();
const response = httpContext.getResponse();
const versionContext = request.apiVersionContext;

if (versionContext) {
const { definition, resolvedVersion, latestVersion } = versionContext;

response.setHeader('X-API-Version', resolvedVersion);
response.setHeader('X-API-Latest-Version', latestVersion);
response.setHeader('X-API-Version-Status', definition.status);

if (this.apiVersionService.isDeprecated(definition)) {
response.setHeader('X-API-Deprecation', 'true');
response.setHeader(
'Warning',
`299 - "${this.apiVersionService.buildDeprecationNotice(definition, versionContext.source)}"`,
);
}

if (definition.sunsetDate) {
response.setHeader('Sunset', new Date(definition.sunsetDate).toUTCString());
}

if (definition.successorVersion) {
response.setHeader(
'Link',
`</api/docs/migrations/v${definition.version}-to-v${definition.successorVersion}>; rel="successor-version"`,
);
}
}

return next.handle();
}
}
107 changes: 107 additions & 0 deletions backend/src/common/versioning/api-version.middleware.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { BadRequestException, GoneException } from '@nestjs/common';
import { Request, Response } from 'express';
import { ApiVersionMiddleware } from './api-version.middleware';
import { ApiVersionService } from './api-version.service';

describe('ApiVersionMiddleware', () => {
let middleware: ApiVersionMiddleware;

beforeEach(() => {
middleware = new ApiVersionMiddleware(new ApiVersionService());
});

it('rewrites unversioned versioned-resource URLs to the latest version', () => {
const request = createRequest({
path: '/api/puzzles',
url: '/api/puzzles?page=1',
});
const next = jest.fn();

middleware.use(request as Request, {} as Response, next);

expect(request.url).toBe('/api/v2/puzzles?page=1');
expect(request.apiVersionContext?.resolvedVersion).toBe('2');
expect(request.apiVersionContext?.source).toBe('default');
expect(next).toHaveBeenCalled();
});

it('accepts header-based version selection', () => {
const request = createRequest({
path: '/api/puzzles',
url: '/api/puzzles',
headers: { 'x-api-version': '1' },
});
const next = jest.fn();

middleware.use(request as Request, {} as Response, next);

expect(request.url).toBe('/api/v1/puzzles');
expect(request.apiVersionContext?.resolvedVersion).toBe('1');
expect(request.apiVersionContext?.source).toBe('header');
expect(next).toHaveBeenCalled();
});

it('accepts query-based version selection', () => {
const request = createRequest({
path: '/api/puzzles',
url: '/api/puzzles?api_version=1',
query: { api_version: '1' },
});
const next = jest.fn();

middleware.use(request as Request, {} as Response, next);

expect(request.url).toBe('/api/v1/puzzles?api_version=1');
expect(request.apiVersionContext?.resolvedVersion).toBe('1');
expect(request.apiVersionContext?.source).toBe('query');
expect(next).toHaveBeenCalled();
});

it('rejects conflicting version selectors', () => {
const request = createRequest({
path: '/api/puzzles',
url: '/api/puzzles?api_version=2',
headers: { 'x-api-version': '1' },
query: { api_version: '2' },
});

expect(() =>
middleware.use(request as Request, {} as Response, jest.fn()),
).toThrow(BadRequestException);
});

it('returns 410 for removed versions', () => {
const request = createRequest({
path: '/api/v0/puzzles',
url: '/api/v0/puzzles',
});

expect(() =>
middleware.use(request as Request, {} as Response, jest.fn()),
).toThrow(GoneException);
});

it('skips non-versioned resources', () => {
const request = createRequest({
path: '/api/auth/login',
url: '/api/auth/login',
});
const next = jest.fn();

middleware.use(request as Request, {} as Response, next);

expect(request.apiVersionContext).toBeUndefined();
expect(request.url).toBe('/api/auth/login');
expect(next).toHaveBeenCalled();
});
});

function createRequest(overrides: Partial<Request>): Partial<Request> {
return {
path: '/api/puzzles',
url: '/api/puzzles',
query: {},
headers: {},
...overrides,
};
}
Loading
Loading