192 lines
4.9 KiB
Markdown
192 lines
4.9 KiB
Markdown
---
|
|
title: Use API Versioning for Breaking Changes
|
|
impact: MEDIUM
|
|
impactDescription: Versioning allows you to evolve APIs without breaking existing clients
|
|
tags: api, versioning, breaking-changes, compatibility
|
|
---
|
|
|
|
## Use API Versioning for Breaking Changes
|
|
|
|
Use NestJS built-in versioning when making breaking changes to your API. Choose a versioning strategy (URI, header, or media type) and apply it consistently. This allows old clients to continue working while new clients use updated endpoints.
|
|
|
|
**Incorrect (breaking changes without versioning):**
|
|
|
|
```typescript
|
|
// Breaking changes without versioning
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string): Promise<User> {
|
|
// Original response: { id, name, email }
|
|
// Later changed to: { id, firstName, lastName, emailAddress }
|
|
// Old clients break!
|
|
return this.usersService.findOne(id);
|
|
}
|
|
}
|
|
|
|
// Manual versioning in routes
|
|
@Controller('v1/users')
|
|
export class UsersV1Controller {}
|
|
|
|
@Controller('v2/users')
|
|
export class UsersV2Controller {}
|
|
// Inconsistent, error-prone, hard to maintain
|
|
```
|
|
|
|
**Correct (use NestJS built-in versioning):**
|
|
|
|
```typescript
|
|
// Enable versioning in main.ts
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create(AppModule);
|
|
|
|
// URI versioning: /v1/users, /v2/users
|
|
app.enableVersioning({
|
|
type: VersioningType.URI,
|
|
defaultVersion: '1',
|
|
});
|
|
|
|
// Or header versioning: X-API-Version: 1
|
|
app.enableVersioning({
|
|
type: VersioningType.HEADER,
|
|
header: 'X-API-Version',
|
|
defaultVersion: '1',
|
|
});
|
|
|
|
// Or media type: Accept: application/json;v=1
|
|
app.enableVersioning({
|
|
type: VersioningType.MEDIA_TYPE,
|
|
key: 'v=',
|
|
defaultVersion: '1',
|
|
});
|
|
|
|
await app.listen(3000);
|
|
}
|
|
|
|
// Version-specific controllers
|
|
@Controller('users')
|
|
@Version('1')
|
|
export class UsersV1Controller {
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string): Promise<UserV1Response> {
|
|
const user = await this.usersService.findOne(id);
|
|
// V1 response format
|
|
return {
|
|
id: user.id,
|
|
name: user.name,
|
|
email: user.email,
|
|
};
|
|
}
|
|
}
|
|
|
|
@Controller('users')
|
|
@Version('2')
|
|
export class UsersV2Controller {
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string): Promise<UserV2Response> {
|
|
const user = await this.usersService.findOne(id);
|
|
// V2 response format with breaking changes
|
|
return {
|
|
id: user.id,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
emailAddress: user.email,
|
|
createdAt: user.createdAt,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Per-route versioning - different versions for different routes
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get()
|
|
@Version('1')
|
|
findAllV1(): Promise<UserV1Response[]> {
|
|
return this.usersService.findAllV1();
|
|
}
|
|
|
|
@Get()
|
|
@Version('2')
|
|
findAllV2(): Promise<UserV2Response[]> {
|
|
return this.usersService.findAllV2();
|
|
}
|
|
|
|
@Get(':id')
|
|
@Version(['1', '2']) // Same handler for multiple versions
|
|
findOne(@Param('id') id: string): Promise<User> {
|
|
return this.usersService.findOne(id);
|
|
}
|
|
|
|
@Post()
|
|
@Version(VERSION_NEUTRAL) // Available in all versions
|
|
create(@Body() dto: CreateUserDto): Promise<User> {
|
|
return this.usersService.create(dto);
|
|
}
|
|
}
|
|
|
|
// Shared service with version-specific logic
|
|
@Injectable()
|
|
export class UsersService {
|
|
async findOne(id: string, version: string): Promise<any> {
|
|
const user = await this.repo.findOne({ where: { id } });
|
|
|
|
if (version === '1') {
|
|
return this.toV1Response(user);
|
|
}
|
|
return this.toV2Response(user);
|
|
}
|
|
|
|
private toV1Response(user: User): UserV1Response {
|
|
return {
|
|
id: user.id,
|
|
name: `${user.firstName} ${user.lastName}`,
|
|
email: user.email,
|
|
};
|
|
}
|
|
|
|
private toV2Response(user: User): UserV2Response {
|
|
return {
|
|
id: user.id,
|
|
firstName: user.firstName,
|
|
lastName: user.lastName,
|
|
emailAddress: user.email,
|
|
createdAt: user.createdAt,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Controller extracts version
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get(':id')
|
|
async findOne(
|
|
@Param('id') id: string,
|
|
@Headers('X-API-Version') version: string = '1',
|
|
): Promise<any> {
|
|
return this.usersService.findOne(id, version);
|
|
}
|
|
}
|
|
|
|
// Deprecation strategy - mark old versions as deprecated
|
|
@Controller('users')
|
|
@Version('1')
|
|
@UseInterceptors(DeprecationInterceptor)
|
|
export class UsersV1Controller {
|
|
// All V1 routes will include deprecation warning
|
|
}
|
|
|
|
@Injectable()
|
|
export class DeprecationInterceptor implements NestInterceptor {
|
|
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
const response = context.switchToHttp().getResponse();
|
|
response.setHeader('Deprecation', 'true');
|
|
response.setHeader('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT');
|
|
response.setHeader('Link', '</v2/users>; rel="successor-version"');
|
|
|
|
return next.handle();
|
|
}
|
|
}
|
|
```
|
|
|
|
Reference: [NestJS Versioning](https://docs.nestjs.com/techniques/versioning)
|