feat: Expand fleet to 23 skills across all domains
New skills (14): - nestjs-best-practices: 40 priority-ranked rules (kadajett) - fastapi: Pydantic v2, async SQLAlchemy, JWT auth (jezweb) - architecture-patterns: Clean Architecture, Hexagonal, DDD (wshobson) - python-performance-optimization: Profiling and optimization (wshobson) - ai-sdk: Vercel AI SDK streaming and agent patterns (vercel) - create-agent: Modular agent architecture with OpenRouter (openrouterteam) - proactive-agent: WAL Protocol, compaction recovery, self-improvement (halthelobster) - brand-guidelines: Brand identity enforcement (anthropics) - ui-animation: Motion design with accessibility (mblode) - marketing-ideas: 139 ideas across 14 categories (coreyhaines31) - pricing-strategy: SaaS pricing and tier design (coreyhaines31) - programmatic-seo: SEO at scale with playbooks (coreyhaines31) - competitor-alternatives: Comparison page architecture (coreyhaines31) - referral-program: Referral and affiliate programs (coreyhaines31) README reorganized by domain: Code Quality, Frontend, Backend, Auth, AI/Agent Building, Marketing, Design, Meta. Mosaic Stack is not limited to coding — the Orchestrator serves coding, business, design, marketing, writing, logistics, and analysis. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
182
skills/nestjs-best-practices/rules/api-use-dto-serialization.md
Normal file
182
skills/nestjs-best-practices/rules/api-use-dto-serialization.md
Normal file
@@ -0,0 +1,182 @@
|
||||
---
|
||||
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)
|
||||
Reference in New Issue
Block a user