183 lines
4.3 KiB
Markdown
183 lines
4.3 KiB
Markdown
---
|
|
title: Use DTOs and Serialization for API Responses
|
|
impact: MEDIUM
|
|
impactDescription: Response DTOs prevent accidental data exposure and ensure consistency
|
|
tags: api, dto, serialization, class-transformer
|
|
---
|
|
|
|
## Use DTOs and Serialization for API Responses
|
|
|
|
Never return entity objects directly from controllers. Use response DTOs with class-transformer's `@Exclude()` and `@Expose()` decorators to control exactly what data is sent to clients. This prevents accidental exposure of sensitive fields and provides a stable API contract.
|
|
|
|
**Incorrect (returning entities directly or manual spreading):**
|
|
|
|
```typescript
|
|
// Return entities directly
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string): Promise<User> {
|
|
return this.usersService.findById(id);
|
|
// Returns: { id, email, passwordHash, ssn, internalNotes, ... }
|
|
// Exposes sensitive data!
|
|
}
|
|
}
|
|
|
|
// Manual object spreading (error-prone)
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string) {
|
|
const user = await this.usersService.findById(id);
|
|
return {
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
// Easy to forget to exclude sensitive fields
|
|
// Hard to maintain across endpoints
|
|
};
|
|
}
|
|
```
|
|
|
|
**Correct (use class-transformer with @Exclude and response DTOs):**
|
|
|
|
```typescript
|
|
// Enable class-transformer globally
|
|
async function bootstrap() {
|
|
const app = await NestFactory.create(AppModule);
|
|
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
|
|
await app.listen(3000);
|
|
}
|
|
|
|
// Entity with serialization control
|
|
@Entity()
|
|
export class User {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column()
|
|
email: string;
|
|
|
|
@Column()
|
|
name: string;
|
|
|
|
@Column()
|
|
@Exclude() // Never include in responses
|
|
passwordHash: string;
|
|
|
|
@Column({ nullable: true })
|
|
@Exclude()
|
|
ssn: string;
|
|
|
|
@Column({ default: false })
|
|
@Exclude({ toPlainOnly: true }) // Exclude from response, allow in requests
|
|
isAdmin: boolean;
|
|
|
|
@CreateDateColumn()
|
|
createdAt: Date;
|
|
|
|
@Column()
|
|
@Exclude()
|
|
internalNotes: string;
|
|
}
|
|
|
|
// Now returning entity is safe
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string): Promise<User> {
|
|
return this.usersService.findById(id);
|
|
// Returns: { id, email, name, createdAt }
|
|
// Sensitive fields excluded automatically
|
|
}
|
|
}
|
|
|
|
// For different response shapes, use explicit DTOs
|
|
export class UserResponseDto {
|
|
@Expose()
|
|
id: string;
|
|
|
|
@Expose()
|
|
email: string;
|
|
|
|
@Expose()
|
|
name: string;
|
|
|
|
@Expose()
|
|
@Transform(({ obj }) => obj.posts?.length || 0)
|
|
postCount: number;
|
|
|
|
constructor(partial: Partial<User>) {
|
|
Object.assign(this, partial);
|
|
}
|
|
}
|
|
|
|
export class UserDetailResponseDto extends UserResponseDto {
|
|
@Expose()
|
|
createdAt: Date;
|
|
|
|
@Expose()
|
|
@Type(() => PostResponseDto)
|
|
posts: PostResponseDto[];
|
|
}
|
|
|
|
// Controller with explicit DTOs
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get()
|
|
@SerializeOptions({ type: UserResponseDto })
|
|
async findAll(): Promise<UserResponseDto[]> {
|
|
const users = await this.usersService.findAll();
|
|
return users.map(u => plainToInstance(UserResponseDto, u));
|
|
}
|
|
|
|
@Get(':id')
|
|
async findOne(@Param('id') id: string): Promise<UserDetailResponseDto> {
|
|
const user = await this.usersService.findByIdWithPosts(id);
|
|
return plainToInstance(UserDetailResponseDto, user, {
|
|
excludeExtraneousValues: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Groups for conditional serialization
|
|
export class UserDto {
|
|
@Expose()
|
|
id: string;
|
|
|
|
@Expose()
|
|
name: string;
|
|
|
|
@Expose({ groups: ['admin'] })
|
|
email: string;
|
|
|
|
@Expose({ groups: ['admin'] })
|
|
createdAt: Date;
|
|
|
|
@Expose({ groups: ['admin', 'owner'] })
|
|
settings: UserSettings;
|
|
}
|
|
|
|
@Controller('users')
|
|
export class UsersController {
|
|
@Get()
|
|
@SerializeOptions({ groups: ['public'] })
|
|
async findAllPublic(): Promise<UserDto[]> {
|
|
// Returns: { id, name }
|
|
}
|
|
|
|
@Get('admin')
|
|
@UseGuards(AdminGuard)
|
|
@SerializeOptions({ groups: ['admin'] })
|
|
async findAllAdmin(): Promise<UserDto[]> {
|
|
// Returns: { id, name, email, createdAt }
|
|
}
|
|
|
|
@Get('me')
|
|
@SerializeOptions({ groups: ['owner'] })
|
|
async getProfile(@CurrentUser() user: User): Promise<UserDto> {
|
|
// Returns: { id, name, settings }
|
|
}
|
|
}
|
|
```
|
|
|
|
Reference: [NestJS Serialization](https://docs.nestjs.com/techniques/serialization)
|