Update skill metadata and enhance documentation across multiple skills
- Change 'dependencies' to 'compatibility' in various skills for consistency - Add detailed examples and best practices to improve clarity in api-design, api-gateway-advanced, data-consistency-patterns, database-prisma, deployment-kubernetes, event-driven-architecture, inter-service-communication, observability-monitoring, security, and testing-patterns - Refine Common Mistakes sections with BAD/GOOD code examples for better learning All skills now feature improved structure and comprehensive guidance for developers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: api-design
|
||||
description: RESTful API design standards for GoodGo microservices. Use for new API endpoints, DTOs, controllers, OpenAPI documentation, or standardized responses.
|
||||
dependencies: "express>=4.18, zod>=3, @types/express"
|
||||
compatibility: "express>=4.18, zod>=3, @types/express"
|
||||
---
|
||||
|
||||
# RESTful API Design Standards
|
||||
@@ -16,7 +16,6 @@ Use this skill when:
|
||||
- Standardizing error responses
|
||||
- Implementing pagination, filtering, and sorting
|
||||
- Setting up API versioning
|
||||
- Designing resource relationships
|
||||
|
||||
## Core Principles
|
||||
|
||||
@@ -31,14 +30,12 @@ Use this skill when:
|
||||
```
|
||||
https://api.goodgo.com/v1/{resource}/{id}/{sub-resource}
|
||||
|
||||
Examples:
|
||||
GET /v1/users # List users
|
||||
POST /v1/users # Create user
|
||||
GET /v1/users/123 # Get user by ID
|
||||
PUT /v1/users/123 # Update user
|
||||
DELETE /v1/users/123 # Delete user
|
||||
GET /v1/users/123/orders # Get user's orders
|
||||
POST /v1/users/123/orders # Create order for user
|
||||
```
|
||||
|
||||
## HTTP Methods
|
||||
@@ -51,482 +48,94 @@ POST /v1/users/123/orders # Create order for user
|
||||
|
||||
## Standard Response Format
|
||||
|
||||
### Success Response
|
||||
|
||||
```typescript
|
||||
// Success
|
||||
interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
metadata?: {
|
||||
timestamp: string;
|
||||
version: string;
|
||||
requestId: string;
|
||||
};
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
pagination?: { page: number; limit: number; total: number; totalPages: number };
|
||||
}
|
||||
|
||||
// Example
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "123",
|
||||
"email": "user@example.com",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"metadata": {
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"version": "1.0.0",
|
||||
"requestId": "req_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```typescript
|
||||
// Error
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
field?: string;
|
||||
stack?: string; // Only in development
|
||||
};
|
||||
metadata?: {
|
||||
timestamp: string;
|
||||
requestId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Example
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Invalid email format",
|
||||
"field": "email",
|
||||
"details": {
|
||||
"provided": "invalid-email",
|
||||
"expected": "valid email address"
|
||||
}
|
||||
}
|
||||
error: { code: string; message: string; details?: any; field?: string };
|
||||
}
|
||||
```
|
||||
|
||||
## Status Codes
|
||||
## Key Patterns
|
||||
|
||||
### Request DTO
|
||||
|
||||
```typescript
|
||||
// Success codes
|
||||
200 OK // GET, PUT, PATCH success
|
||||
201 Created // POST success with resource creation
|
||||
204 No Content // DELETE success
|
||||
|
||||
// Client errors
|
||||
400 Bad Request // Invalid request data
|
||||
401 Unauthorized // Missing/invalid authentication
|
||||
403 Forbidden // Valid auth but no permission
|
||||
404 Not Found // Resource doesn't exist
|
||||
409 Conflict // Resource conflict (duplicate)
|
||||
422 Unprocessable // Validation errors
|
||||
|
||||
// Server errors
|
||||
500 Internal Error // Unexpected server error
|
||||
502 Bad Gateway // External service error
|
||||
503 Service Unavailable // Service temporarily down
|
||||
504 Gateway Timeout // External service timeout
|
||||
```
|
||||
|
||||
## DTOs (Data Transfer Objects)
|
||||
|
||||
### Request DTOs
|
||||
|
||||
```typescript
|
||||
// create.dto.ts
|
||||
import { IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@MinLength(6)
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
@IsEmail() @IsNotEmpty() email: string;
|
||||
@MinLength(6) @IsNotEmpty() password: string;
|
||||
@IsOptional() name?: string;
|
||||
}
|
||||
|
||||
// update.dto.ts
|
||||
export class UpdateUserDto {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
// query.dto.ts
|
||||
export class QueryUsersDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['createdAt', 'name', 'email'])
|
||||
sortBy?: string = 'createdAt';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
order?: 'asc' | 'desc' = 'desc';
|
||||
@IsOptional() @Min(1) page?: number = 1;
|
||||
@IsOptional() @Min(1) @Max(100) limit?: number = 10;
|
||||
@IsOptional() search?: string;
|
||||
@IsOptional() @IsIn(['createdAt', 'name']) sortBy?: string = 'createdAt';
|
||||
@IsOptional() @IsIn(['asc', 'desc']) order?: 'asc' | 'desc' = 'desc';
|
||||
}
|
||||
```
|
||||
|
||||
### Response DTOs
|
||||
### Controller
|
||||
|
||||
```typescript
|
||||
// user.response.dto.ts
|
||||
export class UserResponseDto {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Hide sensitive data
|
||||
static fromEntity(user: User): UserResponseDto {
|
||||
const { password, ...data } = user;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// paginated.response.dto.ts
|
||||
export class PaginatedResponseDto<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
@Get()
|
||||
async list(@Query() query: QueryUsersDto) {
|
||||
const { data, total } = await this.userService.findAll(query);
|
||||
return {
|
||||
success: true,
|
||||
data: data.map(UserResponseDto.fromEntity),
|
||||
pagination: { page: query.page, limit: query.limit, total, totalPages: Math.ceil(total / query.limit) }
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Controller Implementation
|
||||
|
||||
```typescript
|
||||
// user.controller.ts
|
||||
@Controller('users')
|
||||
@ApiTags('Users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List users' })
|
||||
@ApiQuery({ type: QueryUsersDto })
|
||||
@ApiResponse({ status: 200, type: PaginatedResponseDto })
|
||||
async list(@Query() query: QueryUsersDto): Promise<ResponseDto> {
|
||||
const { data, total } = await this.userService.findAll(query);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.map(UserResponseDto.fromEntity),
|
||||
pagination: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / query.limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get user by ID' })
|
||||
@ApiParam({ name: 'id', type: 'string' })
|
||||
@ApiResponse({ status: 200, type: UserResponseDto })
|
||||
@ApiResponse({ status: 404, description: 'User not found' })
|
||||
async getById(@Param('id') id: string): Promise<ResponseDto> {
|
||||
const user = await this.userService.findById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: `User with ID ${id} not found`
|
||||
}
|
||||
},
|
||||
HttpStatus.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: UserResponseDto.fromEntity(user)
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create user' })
|
||||
@ApiBody({ type: CreateUserDto })
|
||||
@ApiResponse({ status: 201, type: UserResponseDto })
|
||||
async create(@Body() dto: CreateUserDto): Promise<ResponseDto> {
|
||||
const user = await this.userService.create(dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: UserResponseDto.fromEntity(user)
|
||||
};
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update user' })
|
||||
@UseGuards(AuthGuard)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserDto
|
||||
): Promise<ResponseDto> {
|
||||
const user = await this.userService.update(id, dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: UserResponseDto.fromEntity(user)
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete user' })
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
async delete(@Param('id') id: string): Promise<ResponseDto> {
|
||||
await this.userService.delete(id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { deleted: true }
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI/Swagger Documentation
|
||||
|
||||
```yaml
|
||||
# openapi/user-service.yaml
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: User Service API
|
||||
version: 1.0.0
|
||||
description: User management endpoints
|
||||
servers:
|
||||
- url: https://api.goodgo.com/v1
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 10
|
||||
responses:
|
||||
'200':
|
||||
description: List of users
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserListResponse'
|
||||
post:
|
||||
summary: Create user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUserRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: User created
|
||||
'400':
|
||||
description: Validation error
|
||||
```
|
||||
|
||||
## Pagination Pattern
|
||||
|
||||
```typescript
|
||||
// pagination.service.ts
|
||||
export class PaginationService {
|
||||
paginate<T>(
|
||||
query: any,
|
||||
options: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
) {
|
||||
const skip = (options.page - 1) * options.limit;
|
||||
|
||||
return {
|
||||
skip,
|
||||
take: options.limit,
|
||||
orderBy: options.sortBy ? {
|
||||
[options.sortBy]: options.order || 'desc'
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// error.middleware.ts
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
// Known errors
|
||||
if (err instanceof ValidationError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: err.message,
|
||||
details: err.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Authentication required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown errors
|
||||
logger.error('Unhandled error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: isDev ? err.message : 'Internal server error',
|
||||
stack: isDev ? err.stack : undefined
|
||||
}
|
||||
});
|
||||
@Get(':id')
|
||||
async getById(@Param('id') id: string) {
|
||||
const user = await this.userService.findById(id);
|
||||
if (!user) throw new NotFoundException({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
||||
return { success: true, data: UserResponseDto.fromEntity(user) };
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Resource Naming**
|
||||
- Use plural nouns (`/users` not `/user`)
|
||||
- Use kebab-case for multi-word resources
|
||||
- Keep URLs as short as possible
|
||||
|
||||
2. **Versioning**
|
||||
- Include version in URL (`/v1/users`)
|
||||
- Maintain backward compatibility
|
||||
- Deprecate old versions gracefully
|
||||
|
||||
3. **Security**
|
||||
- Always use HTTPS
|
||||
- Implement rate limiting
|
||||
- Validate all inputs
|
||||
- Use proper authentication/authorization
|
||||
|
||||
4. **Performance**
|
||||
- Implement pagination for lists
|
||||
- Use field filtering when possible
|
||||
- Cache responses appropriately
|
||||
- Compress responses (gzip)
|
||||
|
||||
5. **Documentation**
|
||||
- Keep OpenAPI spec up to date
|
||||
- Include examples in documentation
|
||||
- Document error responses
|
||||
- Version your documentation
|
||||
- **Resource Naming**: Use plural nouns (`/users`), kebab-case for multi-word
|
||||
- **Versioning**: Include version in URL (`/v1/users`), maintain backward compatibility
|
||||
- **Security**: Use HTTPS, implement rate limiting, validate all inputs
|
||||
- **Performance**: Implement pagination, use field filtering, cache responses
|
||||
- **Documentation**: Keep OpenAPI spec up to date, include examples
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Using Verbs in URLs**: Non-RESTful endpoints
|
||||
```
|
||||
# ❌ BAD: Verb in URL
|
||||
POST /api/v1/createUser
|
||||
GET /api/v1/getUserById/123
|
||||
|
||||
# ✅ GOOD: RESTful resources
|
||||
POST /api/v1/users
|
||||
GET /api/v1/users/123
|
||||
# BAD: POST /api/v1/createUser
|
||||
# GOOD: POST /api/v1/users
|
||||
```
|
||||
|
||||
2. **Inconsistent Response Format**: Different structures for different endpoints
|
||||
```typescript
|
||||
// ❌ BAD: Inconsistent
|
||||
res.json({ user: data }); // One endpoint
|
||||
res.json({ result: data }); // Another endpoint
|
||||
|
||||
// ✅ GOOD: Consistent structure
|
||||
res.json({ success: true, data }); // All endpoints
|
||||
// BAD: res.json({ user: data }) vs res.json({ result: data })
|
||||
// GOOD: res.json({ success: true, data })
|
||||
```
|
||||
|
||||
3. **Wrong HTTP Status Codes**: Using 200 for errors
|
||||
```typescript
|
||||
// ❌ BAD: 200 with error
|
||||
res.status(200).json({ error: 'Not found' });
|
||||
|
||||
// ✅ GOOD: Appropriate status
|
||||
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'User not found' } });
|
||||
// BAD: res.status(200).json({ error: 'Not found' });
|
||||
// GOOD: res.status(404).json({ success: false, error: { code: 'NOT_FOUND' } });
|
||||
```
|
||||
|
||||
4. **Missing Pagination**: Returning all records
|
||||
```typescript
|
||||
// ❌ BAD: Returns everything
|
||||
const users = await prisma.user.findMany();
|
||||
|
||||
// ✅ GOOD: Paginated
|
||||
const users = await prisma.user.findMany({
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
});
|
||||
// BAD: prisma.user.findMany()
|
||||
// GOOD: prisma.user.findMany({ skip: (page - 1) * limit, take: limit })
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
@@ -541,21 +150,8 @@ export function errorHandler(
|
||||
|
||||
**Response Format:**
|
||||
```typescript
|
||||
// Success
|
||||
{ success: true, data: T, pagination?: {...} }
|
||||
|
||||
// Error
|
||||
{ success: false, error: { code: string, message: string, details?: any } }
|
||||
```
|
||||
|
||||
**URL Patterns:**
|
||||
```
|
||||
GET /v1/users # List
|
||||
POST /v1/users # Create
|
||||
GET /v1/users/:id # Get by ID
|
||||
PUT /v1/users/:id # Update
|
||||
DELETE /v1/users/:id # Delete
|
||||
GET /v1/users/:id/orders # Sub-resource
|
||||
// Success: { success: true, data: T, pagination?: {...} }
|
||||
// Error: { success: false, error: { code: string, message: string } }
|
||||
```
|
||||
|
||||
**Common Error Codes:**
|
||||
@@ -571,7 +167,7 @@ GET /v1/users/:id/orders # Sub-resource
|
||||
|
||||
- [OpenAPI Specification](https://spec.openapis.org/oas/latest.html) - Official OpenAPI docs
|
||||
- [REST API Design](https://restfulapi.net/) - REST best practices
|
||||
- [Detailed Code Examples](./references/REFERENCE.md)
|
||||
- [API Versioning Strategy](../api-versioning-strategy/SKILL.md) - Versioning patterns
|
||||
- [API Gateway Advanced](../api-gateway-advanced/SKILL.md) - Gateway patterns
|
||||
- [Middleware Patterns](../middleware-patterns/SKILL.md) - Request handling
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo standards
|
||||
418
.cursor/skills/api-design/references/REFERENCE.md
Normal file
418
.cursor/skills/api-design/references/REFERENCE.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# API Design - Detailed Reference
|
||||
|
||||
This reference contains detailed code examples for RESTful API design patterns.
|
||||
|
||||
## Standard Response Format
|
||||
|
||||
### Success Response
|
||||
|
||||
```typescript
|
||||
interface SuccessResponse<T> {
|
||||
success: true;
|
||||
data: T;
|
||||
metadata?: {
|
||||
timestamp: string;
|
||||
version: string;
|
||||
requestId: string;
|
||||
};
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Example
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"id": "123",
|
||||
"email": "user@example.com",
|
||||
"name": "John Doe"
|
||||
},
|
||||
"metadata": {
|
||||
"timestamp": "2024-01-01T00:00:00Z",
|
||||
"version": "1.0.0",
|
||||
"requestId": "req_abc123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response
|
||||
|
||||
```typescript
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
details?: any;
|
||||
field?: string;
|
||||
stack?: string; // Only in development
|
||||
};
|
||||
metadata?: {
|
||||
timestamp: string;
|
||||
requestId: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Example
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Invalid email format",
|
||||
"field": "email",
|
||||
"details": {
|
||||
"provided": "invalid-email",
|
||||
"expected": "valid email address"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## DTOs (Data Transfer Objects)
|
||||
|
||||
### Request DTOs
|
||||
|
||||
```typescript
|
||||
// create.dto.ts
|
||||
import { IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateUserDto {
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@MinLength(6)
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
}
|
||||
|
||||
// update.dto.ts
|
||||
export class UpdateUserDto {
|
||||
@IsEmail()
|
||||
@IsOptional()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
avatar?: string;
|
||||
}
|
||||
|
||||
// query.dto.ts
|
||||
export class QueryUsersDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number = 10;
|
||||
|
||||
@IsOptional()
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['createdAt', 'name', 'email'])
|
||||
sortBy?: string = 'createdAt';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
order?: 'asc' | 'desc' = 'desc';
|
||||
}
|
||||
```
|
||||
|
||||
### Response DTOs
|
||||
|
||||
```typescript
|
||||
// user.response.dto.ts
|
||||
export class UserResponseDto {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
role: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
// Hide sensitive data
|
||||
static fromEntity(user: User): UserResponseDto {
|
||||
const { password, ...data } = user;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// paginated.response.dto.ts
|
||||
export class PaginatedResponseDto<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Controller Implementation
|
||||
|
||||
```typescript
|
||||
// user.controller.ts
|
||||
@Controller('users')
|
||||
@ApiTags('Users')
|
||||
export class UserController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List users' })
|
||||
@ApiQuery({ type: QueryUsersDto })
|
||||
@ApiResponse({ status: 200, type: PaginatedResponseDto })
|
||||
async list(@Query() query: QueryUsersDto): Promise<ResponseDto> {
|
||||
const { data, total } = await this.userService.findAll(query);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: data.map(UserResponseDto.fromEntity),
|
||||
pagination: {
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / query.limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get user by ID' })
|
||||
@ApiParam({ name: 'id', type: 'string' })
|
||||
@ApiResponse({ status: 200, type: UserResponseDto })
|
||||
@ApiResponse({ status: 404, description: 'User not found' })
|
||||
async getById(@Param('id') id: string): Promise<ResponseDto> {
|
||||
const user = await this.userService.findById(id);
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: 'USER_NOT_FOUND',
|
||||
message: `User with ID ${id} not found`
|
||||
}
|
||||
},
|
||||
HttpStatus.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: UserResponseDto.fromEntity(user)
|
||||
};
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create user' })
|
||||
@ApiBody({ type: CreateUserDto })
|
||||
@ApiResponse({ status: 201, type: UserResponseDto })
|
||||
async create(@Body() dto: CreateUserDto): Promise<ResponseDto> {
|
||||
const user = await this.userService.create(dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: UserResponseDto.fromEntity(user)
|
||||
};
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update user' })
|
||||
@UseGuards(AuthGuard)
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateUserDto
|
||||
): Promise<ResponseDto> {
|
||||
const user = await this.userService.update(id, dto);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: UserResponseDto.fromEntity(user)
|
||||
};
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete user' })
|
||||
@UseGuards(AuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
async delete(@Param('id') id: string): Promise<ResponseDto> {
|
||||
await this.userService.delete(id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { deleted: true }
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## OpenAPI/Swagger Documentation
|
||||
|
||||
```yaml
|
||||
# openapi/user-service.yaml
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: User Service API
|
||||
version: 1.0.0
|
||||
description: User management endpoints
|
||||
servers:
|
||||
- url: https://api.goodgo.com/v1
|
||||
paths:
|
||||
/users:
|
||||
get:
|
||||
summary: List users
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
- name: limit
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 10
|
||||
responses:
|
||||
'200':
|
||||
description: List of users
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UserListResponse'
|
||||
post:
|
||||
summary: Create user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateUserRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: User created
|
||||
'400':
|
||||
description: Validation error
|
||||
```
|
||||
|
||||
## Pagination Pattern
|
||||
|
||||
```typescript
|
||||
// pagination.service.ts
|
||||
export class PaginationService {
|
||||
paginate<T>(
|
||||
query: any,
|
||||
options: {
|
||||
page: number;
|
||||
limit: number;
|
||||
sortBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
) {
|
||||
const skip = (options.page - 1) * options.limit;
|
||||
|
||||
return {
|
||||
skip,
|
||||
take: options.limit,
|
||||
orderBy: options.sortBy ? {
|
||||
[options.sortBy]: options.order || 'desc'
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// error.middleware.ts
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
// Known errors
|
||||
if (err instanceof ValidationError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: err.message,
|
||||
details: err.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof UnauthorizedError) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Authentication required'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown errors
|
||||
logger.error('Unhandled error:', err);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: isDev ? err.message : 'Internal server error',
|
||||
stack: isDev ? err.stack : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Zod Validation Alternative
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateUserSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(6),
|
||||
name: z.string().optional()
|
||||
});
|
||||
|
||||
const QueryUsersSchema = z.object({
|
||||
page: z.coerce.number().min(1).default(1),
|
||||
limit: z.coerce.number().min(1).max(100).default(10),
|
||||
search: z.string().optional(),
|
||||
sortBy: z.enum(['createdAt', 'name', 'email']).default('createdAt'),
|
||||
order: z.enum(['asc', 'desc']).default('desc')
|
||||
});
|
||||
|
||||
// Usage in controller
|
||||
async create(req: Request, res: Response) {
|
||||
const result = CreateUserSchema.safeParse(req.body);
|
||||
if (!result.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: { code: 'VALIDATION_ERROR', details: result.error.issues }
|
||||
});
|
||||
}
|
||||
// ... create user with result.data
|
||||
}
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: api-gateway-advanced
|
||||
description: Advanced API Gateway patterns for GoodGo. Use for API composition, request/response transformation, service mesh, or gateway resilience.
|
||||
dependencies: "traefik>=2.10"
|
||||
compatibility: "traefik>=2.10"
|
||||
---
|
||||
|
||||
# API Gateway Advanced Patterns
|
||||
@@ -15,10 +15,7 @@ Use this skill when:
|
||||
- Implementing advanced routing strategies
|
||||
- Adding gateway-level circuit breakers
|
||||
- Implementing API versioning at gateway
|
||||
- Building API composition services
|
||||
- Optimizing gateway performance
|
||||
- Implementing request/response caching at gateway
|
||||
- Managing complex routing rules
|
||||
|
||||
## Core Concepts
|
||||
|
||||
@@ -34,418 +31,91 @@ Use this skill when:
|
||||
|
||||
### API Composition Patterns
|
||||
|
||||
**Aggregation**: Combine multiple service responses into single response
|
||||
**Chaining**: Call services sequentially, use previous response in next call
|
||||
**Fan-out/Fan-in**: Call multiple services in parallel, aggregate results
|
||||
- **Aggregation**: Combine multiple service responses into single response
|
||||
- **Chaining**: Call services sequentially, use previous response in next call
|
||||
- **Fan-out/Fan-in**: Call multiple services in parallel, aggregate results
|
||||
|
||||
## API Composition Service
|
||||
## Key Patterns
|
||||
|
||||
### API Composition
|
||||
|
||||
```typescript
|
||||
// src/modules/gateway/api-composition.service.ts
|
||||
// EN: API composition service
|
||||
// VI: Service API composition
|
||||
import { ServiceClient } from '../../core/clients/service-client';
|
||||
import { logger } from '@goodgo/logger';
|
||||
// Fan-out: Call multiple services in parallel
|
||||
async getUserProfile(userId: string) {
|
||||
const [user, orders, payments] = await Promise.all([
|
||||
this.userClient.get(`/users/${userId}`),
|
||||
this.orderClient.get(`/orders?userId=${userId}`),
|
||||
this.paymentClient.get(`/payments?userId=${userId}`),
|
||||
]);
|
||||
|
||||
// EN: Type definitions for API composition
|
||||
// VI: Định nghĩa types cho API composition
|
||||
interface CreateOrderInput {
|
||||
userId: string;
|
||||
items: Array<{ productId: string; quantity: number }>;
|
||||
paymentMethod: string;
|
||||
}
|
||||
|
||||
interface OrderWithPaymentResult {
|
||||
success: boolean;
|
||||
data: {
|
||||
order: { id: string; total: number };
|
||||
payment: { id: string; status: string };
|
||||
return {
|
||||
user: user.data,
|
||||
orders: orders.data.orders,
|
||||
paymentHistory: payments.data.payments,
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiCompositionService {
|
||||
private userClient: ServiceClient;
|
||||
private orderClient: ServiceClient;
|
||||
private paymentClient: ServiceClient;
|
||||
|
||||
constructor() {
|
||||
this.userClient = new ServiceClient({
|
||||
baseURL: process.env.USER_SERVICE_URL || 'http://user-service:5002',
|
||||
serviceName: 'user-service',
|
||||
});
|
||||
|
||||
this.orderClient = new ServiceClient({
|
||||
baseURL: process.env.ORDER_SERVICE_URL || 'http://order-service:5003',
|
||||
serviceName: 'order-service',
|
||||
});
|
||||
|
||||
this.paymentClient = new ServiceClient({
|
||||
baseURL: process.env.PAYMENT_SERVICE_URL || 'http://payment-service:5004',
|
||||
serviceName: 'payment-service',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Aggregate user profile with orders and payment history
|
||||
* VI: Tổng hợp profile user với orders và payment history
|
||||
*/
|
||||
async getUserProfile(userId: string): Promise<any> {
|
||||
try {
|
||||
// EN: Fan-out: Call multiple services in parallel
|
||||
// VI: Fan-out: Gọi nhiều services song song
|
||||
const [user, orders, payments] = await Promise.all([
|
||||
this.userClient.get(`/api/v1/users/${userId}`),
|
||||
this.orderClient.get(`/api/v1/orders?userId=${userId}`),
|
||||
this.paymentClient.get(`/api/v1/payments?userId=${userId}`),
|
||||
]);
|
||||
|
||||
// EN: Transform and aggregate response
|
||||
// VI: Transform và tổng hợp response
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: user.data,
|
||||
orders: orders.data.orders,
|
||||
paymentHistory: payments.data.payments,
|
||||
summary: {
|
||||
totalOrders: orders.data.total,
|
||||
totalSpent: payments.data.totalAmount,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('API composition failed', { userId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Chain services: Create order then process payment
|
||||
* VI: Chain services: Tạo order rồi xử lý payment
|
||||
*/
|
||||
async createOrderWithPayment(orderData: CreateOrderInput): Promise<OrderWithPaymentResult> {
|
||||
// EN: Step 1: Create order
|
||||
// VI: Bước 1: Tạo order
|
||||
const order = await this.orderClient.post('/api/v1/orders', orderData);
|
||||
|
||||
try {
|
||||
// EN: Step 2: Process payment using order data
|
||||
// VI: Bước 2: Xử lý payment sử dụng order data
|
||||
const payment = await this.paymentClient.post('/api/v1/payments', {
|
||||
orderId: order.data.id,
|
||||
amount: order.data.total,
|
||||
paymentMethod: orderData.paymentMethod,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
order: order.data,
|
||||
payment: payment.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// EN: Compensate: Cancel order if payment fails
|
||||
// VI: Compensate: Hủy order nếu payment fails
|
||||
await this.orderClient.delete(`/api/v1/orders/${order.data.id}`);
|
||||
throw error;
|
||||
}
|
||||
// Chaining: Sequential calls with compensation
|
||||
async createOrderWithPayment(data) {
|
||||
const order = await this.orderClient.post('/orders', data);
|
||||
try {
|
||||
const payment = await this.paymentClient.post('/payments', { orderId: order.data.id });
|
||||
return { order: order.data, payment: payment.data };
|
||||
} catch (error) {
|
||||
await this.orderClient.delete(`/orders/${order.data.id}`); // Compensate
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Request/Response Transformation
|
||||
### Gateway Caching
|
||||
|
||||
```typescript
|
||||
// src/middlewares/gateway-transform.middleware.ts
|
||||
// EN: Gateway transformation middleware
|
||||
// VI: Middleware transformation gateway
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export interface TransformRule<T = unknown, R = unknown> {
|
||||
path: string;
|
||||
requestTransform?: (req: Request) => Request;
|
||||
responseTransform?: (res: Response, data: T) => R;
|
||||
}
|
||||
|
||||
export class GatewayTransformMiddleware {
|
||||
private rules: TransformRule[] = [];
|
||||
|
||||
addRule(rule: TransformRule): void {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
middleware() {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
// EN: Find matching transform rule
|
||||
// VI: Tìm transform rule khớp
|
||||
const rule = this.rules.find((r) => req.path.startsWith(r.path));
|
||||
|
||||
if (rule?.requestTransform) {
|
||||
// EN: Transform request
|
||||
// VI: Transform request
|
||||
Object.assign(req, rule.requestTransform(req));
|
||||
}
|
||||
|
||||
// EN: Store original json method
|
||||
// VI: Lưu method json gốc
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// EN: Override json to transform response
|
||||
// VI: Override json để transform response
|
||||
res.json = (data: unknown) => {
|
||||
let transformedData = data;
|
||||
if (rule?.responseTransform) {
|
||||
transformedData = rule.responseTransform(res, data);
|
||||
}
|
||||
return originalJson(transformedData);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const transformer = new GatewayTransformMiddleware();
|
||||
|
||||
transformer.addRule({
|
||||
path: '/api/v1/users',
|
||||
requestTransform: (req) => {
|
||||
// EN: Add default pagination
|
||||
// VI: Thêm pagination mặc định
|
||||
if (!req.query.page) req.query.page = '1';
|
||||
if (!req.query.limit) req.query.limit = '10';
|
||||
return req;
|
||||
},
|
||||
responseTransform: (res, data) => {
|
||||
// EN: Standardize response format
|
||||
// VI: Chuẩn hóa format response
|
||||
return {
|
||||
success: true,
|
||||
data: data.data || data,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Traefik Configuration
|
||||
|
||||
```yaml
|
||||
# infra/traefik/dynamic/routes.yml
|
||||
http:
|
||||
routers:
|
||||
# EN: API composition route
|
||||
# VI: Route API composition
|
||||
user-profile:
|
||||
rule: "Path(`/api/v1/user-profile/{userId}`)"
|
||||
service: api-composition-service
|
||||
middlewares:
|
||||
- cors
|
||||
- compress
|
||||
- rate-limit
|
||||
|
||||
# EN: Versioned routes
|
||||
# VI: Routes có version
|
||||
user-service-v1:
|
||||
rule: "PathPrefix(`/api/v1/users`)"
|
||||
service: user-service-v1
|
||||
priority: 10
|
||||
|
||||
user-service-v2:
|
||||
rule: "PathPrefix(`/api/v2/users`)"
|
||||
service: user-service-v2
|
||||
priority: 5 # EN: Lower priority / VI: Độ ưu tiên thấp hơn
|
||||
|
||||
services:
|
||||
api-composition-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://api-composition-service:5000"
|
||||
|
||||
middlewares:
|
||||
# EN: Request transformation
|
||||
# VI: Transform request
|
||||
add-default-headers:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Request-ID: "{{.RequestHeader.X-Request-ID}}"
|
||||
X-Source: "traefik-gateway"
|
||||
|
||||
# EN: Response transformation
|
||||
# VI: Transform response
|
||||
add-response-headers:
|
||||
headers:
|
||||
customResponseHeaders:
|
||||
X-Response-Time: "{{.ResponseTime}}"
|
||||
|
||||
# EN: Circuit breaker at gateway
|
||||
# VI: Circuit breaker ở gateway
|
||||
circuit-breaker:
|
||||
circuitBreaker:
|
||||
expression: "NetworkErrorRatio() > 0.50"
|
||||
```
|
||||
|
||||
## Service Mesh Integration
|
||||
|
||||
```yaml
|
||||
# EN: Traefik with Istio integration
|
||||
# VI: Traefik với tích hợp Istio
|
||||
apiVersion: networking.istio.io/v1alpha3
|
||||
kind: VirtualService
|
||||
metadata:
|
||||
name: traefik-gateway
|
||||
spec:
|
||||
hosts:
|
||||
- api.goodgo.com
|
||||
gateways:
|
||||
- traefik-gateway
|
||||
http:
|
||||
- match:
|
||||
- uri:
|
||||
prefix: "/api/v1"
|
||||
route:
|
||||
- destination:
|
||||
host: user-service
|
||||
port:
|
||||
number: 5002
|
||||
- destination:
|
||||
host: order-service
|
||||
port:
|
||||
number: 5003
|
||||
fault:
|
||||
delay:
|
||||
percentage:
|
||||
value: 0.1
|
||||
fixedDelay: 5s
|
||||
retries:
|
||||
attempts: 3
|
||||
perTryTimeout: 2s
|
||||
```
|
||||
|
||||
## Gateway-Level Circuit Breaker
|
||||
|
||||
```typescript
|
||||
// src/core/gateway/circuit-breaker.middleware.ts
|
||||
// EN: Gateway-level circuit breaker
|
||||
// VI: Circuit breaker ở gateway level
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { createCircuitBreaker } from '../resilience/circuit-breaker';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
const serviceCircuitBreakers = new Map<string, ReturnType<typeof createCircuitBreaker>>();
|
||||
|
||||
export function gatewayCircuitBreaker(serviceName: string) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!serviceCircuitBreakers.has(serviceName)) {
|
||||
serviceCircuitBreakers.set(
|
||||
serviceName,
|
||||
createCircuitBreaker(
|
||||
async () => {
|
||||
// EN: Continue to next middleware
|
||||
// VI: Tiếp tục tới middleware tiếp theo
|
||||
return next();
|
||||
},
|
||||
`gateway-${serviceName}`,
|
||||
{
|
||||
timeout: 5000,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const breaker = serviceCircuitBreakers.get(serviceName)!;
|
||||
|
||||
breaker.fire().catch((error) => {
|
||||
logger.error('Gateway circuit breaker opened', { serviceName, error });
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: `Service ${serviceName} is currently unavailable`,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Caching at Gateway
|
||||
|
||||
```typescript
|
||||
// src/core/gateway/gateway-cache.middleware.ts
|
||||
// EN: Gateway-level caching
|
||||
// VI: Caching ở gateway level
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { cacheService } from '../cache/cache.service';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export function gatewayCache(ttl: number = 60) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// EN: Only cache GET requests
|
||||
// VI: Chỉ cache GET requests
|
||||
if (req.method !== 'GET') {
|
||||
return next();
|
||||
}
|
||||
return async (req, res, next) => {
|
||||
if (req.method !== 'GET') return next();
|
||||
|
||||
const cacheKey = `gateway:${req.path}:${JSON.stringify(req.query)}`;
|
||||
const cached = await cacheService.get(cacheKey);
|
||||
if (cached) return res.json(cached);
|
||||
|
||||
try {
|
||||
// EN: Check cache
|
||||
// VI: Kiểm tra cache
|
||||
const cached = await cacheService.get(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Gateway cache hit', { path: req.path });
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
// EN: Store original json method
|
||||
// VI: Lưu method json gốc
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// EN: Override to cache response
|
||||
// VI: Override để cache response
|
||||
res.json = (data: unknown) => {
|
||||
cacheService.set(cacheKey, data, ttl).catch((error) => {
|
||||
logger.warn('Gateway cache set failed', { error });
|
||||
});
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Gateway cache error', { error });
|
||||
next(); // EN: Continue on cache error / VI: Tiếp tục khi có lỗi cache
|
||||
}
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (data) => {
|
||||
cacheService.set(cacheKey, data, ttl);
|
||||
return originalJson(data);
|
||||
};
|
||||
next();
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik Circuit Breaker
|
||||
|
||||
```yaml
|
||||
middlewares:
|
||||
circuit-breaker:
|
||||
circuitBreaker:
|
||||
expression: "NetworkErrorRatio() > 0.50"
|
||||
timeout:
|
||||
forwardingTimeouts:
|
||||
dialTimeout: 5s
|
||||
responseHeaderTimeout: 10s
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **API Composition**: Use for aggregating related data
|
||||
2. **Caching**: Cache at gateway for frequently accessed data
|
||||
3. **Circuit Breaker**: Implement at gateway to protect services
|
||||
4. **Transformation**: Keep transformations simple and testable
|
||||
5. **Versioning**: Use path-based or header-based versioning
|
||||
6. **Monitoring**: Monitor gateway metrics (latency, error rate)
|
||||
- **API Composition**: Use for aggregating related data from multiple services
|
||||
- **Caching**: Cache at gateway for frequently accessed data
|
||||
- **Circuit Breaker**: Implement at gateway to protect downstream services
|
||||
- **Transformation**: Keep transformations simple and testable
|
||||
- **Versioning**: Use path-based or header-based versioning
|
||||
- **Monitoring**: Monitor gateway metrics (latency, error rate)
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **No Timeout at Gateway**: Requests hanging forever
|
||||
```yaml
|
||||
# ❌ BAD: No timeout
|
||||
services:
|
||||
my-service:
|
||||
loadBalancer: ...
|
||||
|
||||
# ✅ GOOD: Set timeout
|
||||
# GOOD: Set timeout
|
||||
middlewares:
|
||||
timeout:
|
||||
forwardingTimeouts:
|
||||
@@ -455,7 +125,7 @@ export function gatewayCache(ttl: number = 60) {
|
||||
|
||||
2. **Missing Circuit Breaker**: Cascading failures
|
||||
```yaml
|
||||
# ✅ Add circuit breaker for each service
|
||||
# GOOD: Add circuit breaker for each service
|
||||
middlewares:
|
||||
circuit-breaker:
|
||||
circuitBreaker:
|
||||
@@ -464,22 +134,14 @@ export function gatewayCache(ttl: number = 60) {
|
||||
|
||||
3. **No Caching for Static Data**: Unnecessary service load
|
||||
```typescript
|
||||
// ❌ BAD: Every request hits service
|
||||
app.get('/api/config', handler);
|
||||
|
||||
// ✅ GOOD: Cache at gateway
|
||||
// GOOD: Cache at gateway
|
||||
app.get('/api/config', gatewayCache(3600), handler);
|
||||
```
|
||||
|
||||
4. **N+1 API Calls from Client**: Multiple round trips
|
||||
```typescript
|
||||
// ❌ BAD: Client makes 3 calls
|
||||
GET /users/123
|
||||
GET /orders?userId=123
|
||||
GET /payments?userId=123
|
||||
|
||||
// ✅ GOOD: Use API composition
|
||||
GET /user-profile/123 // Aggregated response
|
||||
// BAD: Client makes 3 calls
|
||||
// GOOD: Use API composition GET /user-profile/123
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
@@ -505,35 +167,18 @@ export function gatewayCache(ttl: number = 60) {
|
||||
**API Composition Patterns:**
|
||||
```typescript
|
||||
// Fan-out (parallel)
|
||||
const [users, orders] = await Promise.all([
|
||||
userClient.get('/users'),
|
||||
orderClient.get('/orders')
|
||||
]);
|
||||
const [users, orders] = await Promise.all([userClient.get('/users'), orderClient.get('/orders')]);
|
||||
|
||||
// Chaining (sequential)
|
||||
const order = await orderClient.create(data);
|
||||
const payment = await paymentClient.process(order.id);
|
||||
```
|
||||
|
||||
**Gateway Config Template:**
|
||||
```yaml
|
||||
http:
|
||||
routers:
|
||||
service-route:
|
||||
rule: "PathPrefix(`/api/v1/service`)"
|
||||
middlewares: [rate-limit, auth, circuit-breaker]
|
||||
service: backend-service
|
||||
middlewares:
|
||||
circuit-breaker:
|
||||
circuitBreaker:
|
||||
expression: "NetworkErrorRatio() > 0.5"
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Traefik Documentation](https://doc.traefik.io/traefik/) - Official Traefik docs
|
||||
- [API Gateway Pattern](https://microservices.io/patterns/apigateway.html) - Gateway patterns
|
||||
- [Service Mesh](https://istio.io/) - Istio service mesh
|
||||
- [Detailed Code Examples](./references/REFERENCE.md)
|
||||
- [Middleware Patterns](../middleware-patterns/SKILL.md) - Middleware patterns
|
||||
- [Resilience Patterns](../resilience-patterns/SKILL.md) - Circuit breaker patterns
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo coding standards
|
||||
|
||||
357
.cursor/skills/api-gateway-advanced/references/REFERENCE.md
Normal file
357
.cursor/skills/api-gateway-advanced/references/REFERENCE.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# API Gateway Advanced - Detailed Reference
|
||||
|
||||
This reference contains detailed code examples for advanced API Gateway patterns.
|
||||
|
||||
## API Composition Service
|
||||
|
||||
```typescript
|
||||
// src/modules/gateway/api-composition.service.ts
|
||||
import { ServiceClient } from '../../core/clients/service-client';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
interface CreateOrderInput {
|
||||
userId: string;
|
||||
items: Array<{ productId: string; quantity: number }>;
|
||||
paymentMethod: string;
|
||||
}
|
||||
|
||||
interface OrderWithPaymentResult {
|
||||
success: boolean;
|
||||
data: {
|
||||
order: { id: string; total: number };
|
||||
payment: { id: string; status: string };
|
||||
};
|
||||
}
|
||||
|
||||
export class ApiCompositionService {
|
||||
private userClient: ServiceClient;
|
||||
private orderClient: ServiceClient;
|
||||
private paymentClient: ServiceClient;
|
||||
|
||||
constructor() {
|
||||
this.userClient = new ServiceClient({
|
||||
baseURL: process.env.USER_SERVICE_URL || 'http://user-service:5002',
|
||||
serviceName: 'user-service',
|
||||
});
|
||||
|
||||
this.orderClient = new ServiceClient({
|
||||
baseURL: process.env.ORDER_SERVICE_URL || 'http://order-service:5003',
|
||||
serviceName: 'order-service',
|
||||
});
|
||||
|
||||
this.paymentClient = new ServiceClient({
|
||||
baseURL: process.env.PAYMENT_SERVICE_URL || 'http://payment-service:5004',
|
||||
serviceName: 'payment-service',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate user profile with orders and payment history
|
||||
*/
|
||||
async getUserProfile(userId: string): Promise<any> {
|
||||
try {
|
||||
// Fan-out: Call multiple services in parallel
|
||||
const [user, orders, payments] = await Promise.all([
|
||||
this.userClient.get(`/api/v1/users/${userId}`),
|
||||
this.orderClient.get(`/api/v1/orders?userId=${userId}`),
|
||||
this.paymentClient.get(`/api/v1/payments?userId=${userId}`),
|
||||
]);
|
||||
|
||||
// Transform and aggregate response
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: user.data,
|
||||
orders: orders.data.orders,
|
||||
paymentHistory: payments.data.payments,
|
||||
summary: {
|
||||
totalOrders: orders.data.total,
|
||||
totalSpent: payments.data.totalAmount,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('API composition failed', { userId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain services: Create order then process payment
|
||||
*/
|
||||
async createOrderWithPayment(orderData: CreateOrderInput): Promise<OrderWithPaymentResult> {
|
||||
// Step 1: Create order
|
||||
const order = await this.orderClient.post('/api/v1/orders', orderData);
|
||||
|
||||
try {
|
||||
// Step 2: Process payment using order data
|
||||
const payment = await this.paymentClient.post('/api/v1/payments', {
|
||||
orderId: order.data.id,
|
||||
amount: order.data.total,
|
||||
paymentMethod: orderData.paymentMethod,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
order: order.data,
|
||||
payment: payment.data,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Compensate: Cancel order if payment fails
|
||||
await this.orderClient.delete(`/api/v1/orders/${order.data.id}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Request/Response Transformation
|
||||
|
||||
```typescript
|
||||
// src/middlewares/gateway-transform.middleware.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export interface TransformRule<T = unknown, R = unknown> {
|
||||
path: string;
|
||||
requestTransform?: (req: Request) => Request;
|
||||
responseTransform?: (res: Response, data: T) => R;
|
||||
}
|
||||
|
||||
export class GatewayTransformMiddleware {
|
||||
private rules: TransformRule[] = [];
|
||||
|
||||
addRule(rule: TransformRule): void {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
middleware() {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
// Find matching transform rule
|
||||
const rule = this.rules.find((r) => req.path.startsWith(r.path));
|
||||
|
||||
if (rule?.requestTransform) {
|
||||
// Transform request
|
||||
Object.assign(req, rule.requestTransform(req));
|
||||
}
|
||||
|
||||
// Store original json method
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// Override json to transform response
|
||||
res.json = (data: unknown) => {
|
||||
let transformedData = data;
|
||||
if (rule?.responseTransform) {
|
||||
transformedData = rule.responseTransform(res, data);
|
||||
}
|
||||
return originalJson(transformedData);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const transformer = new GatewayTransformMiddleware();
|
||||
|
||||
transformer.addRule({
|
||||
path: '/api/v1/users',
|
||||
requestTransform: (req) => {
|
||||
// Add default pagination
|
||||
if (!req.query.page) req.query.page = '1';
|
||||
if (!req.query.limit) req.query.limit = '10';
|
||||
return req;
|
||||
},
|
||||
responseTransform: (res, data) => {
|
||||
// Standardize response format
|
||||
return {
|
||||
success: true,
|
||||
data: data.data || data,
|
||||
metadata: {
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Advanced Traefik Configuration
|
||||
|
||||
```yaml
|
||||
# infra/traefik/dynamic/routes.yml
|
||||
http:
|
||||
routers:
|
||||
# API composition route
|
||||
user-profile:
|
||||
rule: "Path(`/api/v1/user-profile/{userId}`)"
|
||||
service: api-composition-service
|
||||
middlewares:
|
||||
- cors
|
||||
- compress
|
||||
- rate-limit
|
||||
|
||||
# Versioned routes
|
||||
user-service-v1:
|
||||
rule: "PathPrefix(`/api/v1/users`)"
|
||||
service: user-service-v1
|
||||
priority: 10
|
||||
|
||||
user-service-v2:
|
||||
rule: "PathPrefix(`/api/v2/users`)"
|
||||
service: user-service-v2
|
||||
priority: 5
|
||||
|
||||
services:
|
||||
api-composition-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://api-composition-service:5000"
|
||||
|
||||
middlewares:
|
||||
# Request transformation
|
||||
add-default-headers:
|
||||
headers:
|
||||
customRequestHeaders:
|
||||
X-Request-ID: "{{.RequestHeader.X-Request-ID}}"
|
||||
X-Source: "traefik-gateway"
|
||||
|
||||
# Response transformation
|
||||
add-response-headers:
|
||||
headers:
|
||||
customResponseHeaders:
|
||||
X-Response-Time: "{{.ResponseTime}}"
|
||||
|
||||
# Circuit breaker at gateway
|
||||
circuit-breaker:
|
||||
circuitBreaker:
|
||||
expression: "NetworkErrorRatio() > 0.50"
|
||||
```
|
||||
|
||||
## Service Mesh Integration
|
||||
|
||||
```yaml
|
||||
# Traefik with Istio integration
|
||||
apiVersion: networking.istio.io/v1alpha3
|
||||
kind: VirtualService
|
||||
metadata:
|
||||
name: traefik-gateway
|
||||
spec:
|
||||
hosts:
|
||||
- api.goodgo.com
|
||||
gateways:
|
||||
- traefik-gateway
|
||||
http:
|
||||
- match:
|
||||
- uri:
|
||||
prefix: "/api/v1"
|
||||
route:
|
||||
- destination:
|
||||
host: user-service
|
||||
port:
|
||||
number: 5002
|
||||
- destination:
|
||||
host: order-service
|
||||
port:
|
||||
number: 5003
|
||||
fault:
|
||||
delay:
|
||||
percentage:
|
||||
value: 0.1
|
||||
fixedDelay: 5s
|
||||
retries:
|
||||
attempts: 3
|
||||
perTryTimeout: 2s
|
||||
```
|
||||
|
||||
## Gateway-Level Circuit Breaker
|
||||
|
||||
```typescript
|
||||
// src/core/gateway/circuit-breaker.middleware.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { createCircuitBreaker } from '../resilience/circuit-breaker';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
const serviceCircuitBreakers = new Map<string, ReturnType<typeof createCircuitBreaker>>();
|
||||
|
||||
export function gatewayCircuitBreaker(serviceName: string) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!serviceCircuitBreakers.has(serviceName)) {
|
||||
serviceCircuitBreakers.set(
|
||||
serviceName,
|
||||
createCircuitBreaker(
|
||||
async () => {
|
||||
return next();
|
||||
},
|
||||
`gateway-${serviceName}`,
|
||||
{
|
||||
timeout: 5000,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const breaker = serviceCircuitBreakers.get(serviceName)!;
|
||||
|
||||
breaker.fire().catch((error) => {
|
||||
logger.error('Gateway circuit breaker opened', { serviceName, error });
|
||||
res.status(503).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'SERVICE_UNAVAILABLE',
|
||||
message: `Service ${serviceName} is currently unavailable`,
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Caching at Gateway
|
||||
|
||||
```typescript
|
||||
// src/core/gateway/gateway-cache.middleware.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { cacheService } from '../cache/cache.service';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export function gatewayCache(ttl: number = 60) {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// Only cache GET requests
|
||||
if (req.method !== 'GET') {
|
||||
return next();
|
||||
}
|
||||
|
||||
const cacheKey = `gateway:${req.path}:${JSON.stringify(req.query)}`;
|
||||
|
||||
try {
|
||||
// Check cache
|
||||
const cached = await cacheService.get(cacheKey);
|
||||
if (cached) {
|
||||
logger.debug('Gateway cache hit', { path: req.path });
|
||||
return res.json(cached);
|
||||
}
|
||||
|
||||
// Store original json method
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// Override to cache response
|
||||
res.json = (data: unknown) => {
|
||||
cacheService.set(cacheKey, data, ttl).catch((error) => {
|
||||
logger.warn('Gateway cache set failed', { error });
|
||||
});
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Gateway cache error', { error });
|
||||
next();
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: data-consistency-patterns
|
||||
description: Data consistency patterns for distributed systems. Use for Saga patterns, distributed transactions, eventual consistency, or cross-service data sync.
|
||||
dependencies: "prisma>=5"
|
||||
compatibility: "prisma>=5"
|
||||
---
|
||||
|
||||
# Data Consistency Patterns
|
||||
@@ -37,9 +37,12 @@ Use this skill when:
|
||||
|
||||
### Consistency Models
|
||||
|
||||
1. **Strong Consistency**: All nodes see same data at same time
|
||||
2. **Weak Consistency**: No guarantees about when consistency occurs
|
||||
3. **Eventual Consistency**: System becomes consistent over time
|
||||
| Model | Description | Use Case |
|
||||
|-------|-------------|----------|
|
||||
| **Strong** | All nodes see same data at same time | Financial transactions |
|
||||
| **Weak** | No guarantees about when consistency occurs | Caching |
|
||||
| **Eventual** | System becomes consistent over time | Read models, analytics |
|
||||
| **Causal** | Related operations maintain order | User sessions |
|
||||
|
||||
### Distributed Transaction Challenges
|
||||
|
||||
@@ -53,686 +56,193 @@ Use this skill when:
|
||||
|
||||
### Orchestration vs Choreography
|
||||
|
||||
**Orchestration (Centralized):**
|
||||
- Central orchestrator coordinates steps
|
||||
- Easier to understand and debug
|
||||
- Single point of failure
|
||||
- Use when: Complex workflows, need central control
|
||||
| Approach | Central Control | Resilience | Complexity | Best For |
|
||||
|----------|-----------------|------------|------------|----------|
|
||||
| **Orchestration** | Yes (single coordinator) | Lower (SPOF) | Easier to debug | Complex workflows |
|
||||
| **Choreography** | No (event-driven) | Higher | Harder to trace | Simple flows, loose coupling |
|
||||
|
||||
**Choreography (Decentralized):**
|
||||
- Each service knows what to do next
|
||||
- More resilient, no single point of failure
|
||||
- Harder to understand overall flow
|
||||
- Use when: Simple workflows, loose coupling preferred
|
||||
### Saga Execution Flow
|
||||
|
||||
### Saga Orchestrator Pattern
|
||||
```
|
||||
Execute: Step1 -> Step2 -> Step3 -> Complete
|
||||
| | |
|
||||
v v v
|
||||
Compensate: <- Step2.undo <- Step1.undo (on failure)
|
||||
```
|
||||
|
||||
### Key Saga Interfaces
|
||||
|
||||
```typescript
|
||||
// src/core/saga/saga-orchestrator.ts
|
||||
// EN: Saga orchestrator for distributed transactions
|
||||
// VI: Saga orchestrator cho distributed transactions
|
||||
import { logger } from '@goodgo/logger';
|
||||
import { eventPublisher } from '../events/event-publisher';
|
||||
|
||||
export interface SagaStep {
|
||||
interface SagaStep {
|
||||
name: string;
|
||||
execute: () => Promise<any>;
|
||||
compensate: (context: any) => Promise<void>;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export interface SagaContext {
|
||||
interface SagaContext {
|
||||
sagaId: string;
|
||||
steps: SagaStep[];
|
||||
currentStep: number;
|
||||
data: Record<string, any>;
|
||||
status: 'pending' | 'running' | 'completed' | 'compensating' | 'failed';
|
||||
}
|
||||
|
||||
export class SagaOrchestrator {
|
||||
/**
|
||||
* EN: Execute saga with all steps
|
||||
* VI: Thực thi saga với tất cả các bước
|
||||
*/
|
||||
async execute(context: SagaContext): Promise<void> {
|
||||
context.status = 'running';
|
||||
|
||||
try {
|
||||
for (let i = 0; i < context.steps.length; i++) {
|
||||
context.currentStep = i;
|
||||
const step = context.steps[i];
|
||||
|
||||
logger.info('Executing saga step', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.executeWithRetry(step, step.retry || 3);
|
||||
context.data[step.name] = result;
|
||||
|
||||
// EN: Publish step completed event
|
||||
// VI: Publish event step đã hoàn thành
|
||||
await eventPublisher.publish('saga.step.completed', {
|
||||
eventType: 'saga.step.completed',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Saga step failed', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// EN: Compensate all completed steps
|
||||
// VI: Compensate tất cả các bước đã hoàn thành
|
||||
await this.compensate(context, i - 1);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
context.status = 'completed';
|
||||
logger.info('Saga completed successfully', { sagaId: context.sagaId });
|
||||
} catch (error) {
|
||||
context.status = 'failed';
|
||||
logger.error('Saga failed', {
|
||||
sagaId: context.sagaId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeWithRetry(step: SagaStep, maxRetries: number): Promise<any> {
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await step.execute();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Compensate completed steps in reverse order
|
||||
* VI: Compensate các bước đã hoàn thành theo thứ tự ngược
|
||||
*/
|
||||
private async compensate(context: SagaContext, lastCompletedStep: number): Promise<void> {
|
||||
context.status = 'compensating';
|
||||
|
||||
for (let i = lastCompletedStep; i >= 0; i--) {
|
||||
const step = context.steps[i];
|
||||
|
||||
try {
|
||||
logger.info('Compensating saga step', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
});
|
||||
|
||||
await step.compensate(context.data);
|
||||
|
||||
await eventPublisher.publish('saga.step.compensated', {
|
||||
eventType: 'saga.step.compensated',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Compensation failed', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
error: error.message,
|
||||
});
|
||||
// EN: Log but continue compensating other steps
|
||||
// VI: Log nhưng tiếp tục compensate các bước khác
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Saga Example: Order Processing
|
||||
See [./references/REFERENCE.md](./references/REFERENCE.md) for full Saga orchestrator implementation.
|
||||
|
||||
## Idempotency Pattern
|
||||
|
||||
Ensures operations can be safely retried without side effects.
|
||||
|
||||
### Key Concepts
|
||||
|
||||
- **Idempotency Key**: Unique identifier for each operation
|
||||
- **Result Storage**: Cache results to return on duplicates
|
||||
- **TTL**: Time-to-live for idempotency records
|
||||
|
||||
### Pattern
|
||||
|
||||
```typescript
|
||||
// src/modules/order/order.saga.ts
|
||||
// EN: Order processing saga
|
||||
// VI: Saga xử lý order
|
||||
import { SagaOrchestrator, SagaContext, SagaStep } from '../../core/saga/saga-orchestrator';
|
||||
import { orderService } from './order.service';
|
||||
import { paymentService } from '../payment/payment.service';
|
||||
import { inventoryService } from '../inventory/inventory.service';
|
||||
import { notificationService } from '../notification/notification.service';
|
||||
|
||||
export class OrderProcessingSaga {
|
||||
private orchestrator: SagaOrchestrator;
|
||||
|
||||
constructor() {
|
||||
this.orchestrator = new SagaOrchestrator();
|
||||
}
|
||||
|
||||
async processOrder(orderData: any): Promise<void> {
|
||||
const sagaId = `saga_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const steps: SagaStep[] = [
|
||||
{
|
||||
name: 'create-order',
|
||||
execute: async () => {
|
||||
return await orderService.create(orderData);
|
||||
},
|
||||
compensate: async (context) => {
|
||||
await orderService.cancel(context.data['create-order'].id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'reserve-inventory',
|
||||
execute: async () => {
|
||||
const order = context.data['create-order'];
|
||||
return await inventoryService.reserve(order.items);
|
||||
},
|
||||
compensate: async (context) => {
|
||||
const reservation = context.data['reserve-inventory'];
|
||||
await inventoryService.release(reservation.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'process-payment',
|
||||
execute: async () => {
|
||||
const order = context.data['create-order'];
|
||||
return await paymentService.charge(order.total, order.paymentMethod);
|
||||
},
|
||||
compensate: async (context) => {
|
||||
const payment = context.data['process-payment'];
|
||||
await paymentService.refund(payment.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'send-confirmation',
|
||||
execute: async () => {
|
||||
const order = context.data['create-order'];
|
||||
await notificationService.sendOrderConfirmation(order.userId, order.id);
|
||||
// EN: Non-critical step, no compensation needed
|
||||
// VI: Bước không quan trọng, không cần compensation
|
||||
},
|
||||
compensate: async () => {
|
||||
// EN: No compensation needed for notification
|
||||
// VI: Không cần compensation cho notification
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const context: SagaContext = {
|
||||
sagaId,
|
||||
steps,
|
||||
currentStep: 0,
|
||||
data: {},
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
await this.orchestrator.execute(context);
|
||||
}
|
||||
}
|
||||
const key = `${operation}:${userId}:${hash(requestData)}`;
|
||||
await idempotencyHandler.execute(key, () => operation());
|
||||
```
|
||||
|
||||
### Saga Choreography Pattern
|
||||
|
||||
```typescript
|
||||
// EN: Choreography pattern - services react to events
|
||||
// VI: Pattern choreography - services phản ứng với events
|
||||
// src/modules/order/order.service.ts
|
||||
export class OrderService {
|
||||
async createOrder(data: CreateOrderDto): Promise<Order> {
|
||||
const order = await this.orderRepository.create(data);
|
||||
|
||||
// EN: Publish event for next step
|
||||
// VI: Publish event cho bước tiếp theo
|
||||
await eventPublisher.publish('order.created', {
|
||||
eventType: 'order.created',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: order.id,
|
||||
items: order.items,
|
||||
total: order.total,
|
||||
},
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: string): Promise<void> {
|
||||
await this.orderRepository.update(orderId, { status: 'cancelled' });
|
||||
}
|
||||
}
|
||||
|
||||
// src/modules/inventory/inventory.consumer.ts
|
||||
// EN: Inventory service reacts to order.created event
|
||||
// VI: Inventory service phản ứng với order.created event
|
||||
eventConsumer.on('order.created', {
|
||||
handle: async (event) => {
|
||||
try {
|
||||
const reservation = await inventoryService.reserve(event.data.items);
|
||||
|
||||
// EN: Publish next step event
|
||||
// VI: Publish event bước tiếp theo
|
||||
await eventPublisher.publish('inventory.reserved', {
|
||||
eventType: 'inventory.reserved',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
reservationId: reservation.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// EN: Publish compensation event
|
||||
// VI: Publish event compensation
|
||||
await eventPublisher.publish('order.cancelled', {
|
||||
eventType: 'order.cancelled',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
reason: 'inventory_reservation_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// src/modules/payment/payment.consumer.ts
|
||||
eventConsumer.on('inventory.reserved', {
|
||||
handle: async (event) => {
|
||||
try {
|
||||
const order = await orderService.findById(event.data.orderId);
|
||||
const payment = await paymentService.charge(order.total, order.paymentMethod);
|
||||
|
||||
await eventPublisher.publish('payment.processed', {
|
||||
eventType: 'payment.processed',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
paymentId: payment.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// EN: Trigger compensation
|
||||
// VI: Kích hoạt compensation
|
||||
await eventPublisher.publish('order.cancelled', {
|
||||
eventType: 'order.cancelled',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
reason: 'payment_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Idempotency Patterns
|
||||
|
||||
### Idempotent Operations
|
||||
|
||||
```typescript
|
||||
// src/core/idempotency/idempotency.ts
|
||||
// EN: Idempotency handler
|
||||
// VI: Handler idempotency
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '@goodgo/logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class IdempotencyHandler {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* EN: Execute operation with idempotency check
|
||||
* VI: Thực thi operation với idempotency check
|
||||
*/
|
||||
async execute<T>(
|
||||
idempotencyKey: string,
|
||||
operation: () => Promise<T>,
|
||||
ttl: number = 3600 // EN: 1 hour default / VI: Mặc định 1 giờ
|
||||
): Promise<T> {
|
||||
// EN: Check if already processed
|
||||
// VI: Kiểm tra xem đã được xử lý chưa
|
||||
const existing = await this.prisma.idempotencyRecord.findUnique({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.info('Idempotent operation skipped', { idempotencyKey });
|
||||
return existing.result as T;
|
||||
}
|
||||
|
||||
// EN: Execute operation
|
||||
// VI: Thực thi operation
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
// EN: Store result
|
||||
// VI: Lưu kết quả
|
||||
await this.prisma.idempotencyRecord.create({
|
||||
data: {
|
||||
idempotencyKey,
|
||||
result: result as any,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// EN: Store error for idempotency (optional)
|
||||
// VI: Lưu lỗi cho idempotency (tùy chọn)
|
||||
logger.error('Idempotent operation failed', { idempotencyKey, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const idempotencyHandler = new IdempotencyHandler(prisma);
|
||||
|
||||
await idempotencyHandler.execute(
|
||||
`create-user-${userId}`,
|
||||
async () => {
|
||||
return await userService.create(userData);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Idempotency Key Generation
|
||||
|
||||
```typescript
|
||||
// EN: Generate idempotency key from request
|
||||
// VI: Tạo idempotency key từ request
|
||||
export function generateIdempotencyKey(
|
||||
userId: string,
|
||||
operation: string,
|
||||
requestData: any
|
||||
): string {
|
||||
const hash = createHash('sha256')
|
||||
.update(JSON.stringify({ userId, operation, requestData }))
|
||||
.digest('hex');
|
||||
|
||||
return `${operation}:${userId}:${hash.substr(0, 16)}`;
|
||||
}
|
||||
```
|
||||
See [./references/REFERENCE.md](./references/REFERENCE.md) for full implementation.
|
||||
|
||||
## Optimistic Locking
|
||||
|
||||
### Version-Based Locking
|
||||
Prevents lost updates in concurrent scenarios using version fields.
|
||||
|
||||
### Pattern
|
||||
|
||||
```typescript
|
||||
// src/core/locking/optimistic-lock.ts
|
||||
// EN: Optimistic locking with version field
|
||||
// VI: Optimistic locking với trường version
|
||||
export class OptimisticLockService {
|
||||
/**
|
||||
* EN: Update with optimistic lock
|
||||
* VI: Update với optimistic lock
|
||||
*/
|
||||
async updateWithLock<T extends { version: number }>(
|
||||
repository: any,
|
||||
id: string,
|
||||
updateFn: (current: T) => Partial<T>,
|
||||
maxRetries: number = 3
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
// EN: Read current version
|
||||
// VI: Đọc version hiện tại
|
||||
const current = await repository.findById(id);
|
||||
|
||||
if (!current) {
|
||||
throw new Error('Resource not found');
|
||||
}
|
||||
|
||||
// EN: Apply update
|
||||
// VI: Áp dụng update
|
||||
const updates = updateFn(current);
|
||||
|
||||
try {
|
||||
// EN: Update with version check
|
||||
// VI: Update với kiểm tra version
|
||||
const updated = await repository.update(id, {
|
||||
...updates,
|
||||
version: current.version + 1,
|
||||
}, {
|
||||
where: {
|
||||
id,
|
||||
version: current.version, // EN: Only update if version matches / VI: Chỉ update nếu version khớp
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1) {
|
||||
throw new Error('Optimistic lock conflict - max retries exceeded');
|
||||
}
|
||||
|
||||
// EN: Retry with exponential backoff
|
||||
// VI: Retry với exponential backoff
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.pow(2, attempt) * 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Optimistic lock failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Eventual Consistency Strategies
|
||||
|
||||
### Read Models and CQRS
|
||||
|
||||
```typescript
|
||||
// EN: Separate read and write models
|
||||
// VI: Tách biệt read và write models
|
||||
// src/modules/user/user.write.service.ts
|
||||
export class UserWriteService {
|
||||
async createUser(data: CreateUserDto): Promise<User> {
|
||||
const user = await this.userRepository.create(data);
|
||||
|
||||
// EN: Publish event for read model update
|
||||
// VI: Publish event để cập nhật read model
|
||||
await eventPublisher.publish('user.created', {
|
||||
eventType: 'user.created',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// src/modules/user/user.read.service.ts
|
||||
// EN: Read model updated via events
|
||||
// VI: Read model được cập nhật qua events
|
||||
export class UserReadService {
|
||||
async getUser(userId: string): Promise<UserReadModel> {
|
||||
return await this.userReadRepository.findById(userId);
|
||||
}
|
||||
}
|
||||
|
||||
// src/modules/user/user.read.consumer.ts
|
||||
eventConsumer.on('user.created', {
|
||||
handle: async (event) => {
|
||||
// EN: Update read model
|
||||
// VI: Cập nhật read model
|
||||
await userReadRepository.create({
|
||||
userId: event.data.userId,
|
||||
email: event.data.email,
|
||||
name: event.data.name,
|
||||
// EN: Additional denormalized data for queries
|
||||
// VI: Dữ liệu denormalized thêm cho queries
|
||||
});
|
||||
},
|
||||
await prisma.entity.update({
|
||||
where: { id, version: currentVersion },
|
||||
data: { ...updates, version: { increment: 1 } }
|
||||
});
|
||||
```
|
||||
|
||||
### Conflict Resolution
|
||||
### Prisma Schema
|
||||
|
||||
```typescript
|
||||
// src/core/consistency/conflict-resolver.ts
|
||||
// EN: Conflict resolution strategies
|
||||
// VI: Các chiến lược giải quyết conflict
|
||||
export enum ConflictResolutionStrategy {
|
||||
LAST_WRITE_WINS = 'last_write_wins',
|
||||
FIRST_WRITE_WINS = 'first_write_wins',
|
||||
MERGE = 'merge',
|
||||
MANUAL = 'manual',
|
||||
}
|
||||
|
||||
export class ConflictResolver {
|
||||
resolve(
|
||||
strategy: ConflictResolutionStrategy,
|
||||
current: any,
|
||||
incoming: any
|
||||
): any {
|
||||
switch (strategy) {
|
||||
case ConflictResolutionStrategy.LAST_WRITE_WINS:
|
||||
return incoming.timestamp > current.timestamp ? incoming : current;
|
||||
|
||||
case ConflictResolutionStrategy.FIRST_WRITE_WINS:
|
||||
return incoming.timestamp < current.timestamp ? incoming : current;
|
||||
|
||||
case ConflictResolutionStrategy.MERGE:
|
||||
return { ...current, ...incoming, timestamp: Date.now() };
|
||||
|
||||
case ConflictResolutionStrategy.MANUAL:
|
||||
// EN: Store conflict for manual resolution
|
||||
// VI: Lưu conflict để giải quyết thủ công
|
||||
this.storeConflict(current, incoming);
|
||||
return current;
|
||||
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
}
|
||||
```prisma
|
||||
model Entity {
|
||||
id String @id @default(cuid())
|
||||
version Int @default(1)
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
See [./references/REFERENCE.md](./references/REFERENCE.md) for full optimistic locking service.
|
||||
|
||||
## CQRS Pattern
|
||||
|
||||
Command Query Responsibility Segregation separates read and write operations.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Write Path: Command -> Write Model -> Event -> Event Store
|
||||
Read Path: Query -> Read Model (denormalized, optimized for reads)
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
- Optimized read models for query performance
|
||||
- Independent scaling of read/write operations
|
||||
- Eventual consistency between models
|
||||
|
||||
See [./references/REFERENCE.md](./references/REFERENCE.md) for implementation details.
|
||||
|
||||
## Conflict Resolution Strategies
|
||||
|
||||
| Strategy | Description | Use Case |
|
||||
|----------|-------------|----------|
|
||||
| **Last Write Wins** | Latest timestamp wins | Simple scenarios |
|
||||
| **First Write Wins** | Earliest timestamp wins | Preserving original data |
|
||||
| **Merge** | Combine both versions | Non-conflicting fields |
|
||||
| **Manual** | Store for human review | Critical data conflicts |
|
||||
|
||||
## Outbox Pattern
|
||||
|
||||
Ensures reliable event publishing by storing events in the same transaction as business data.
|
||||
|
||||
### Flow
|
||||
|
||||
1. Execute business operation in transaction
|
||||
2. Store event in outbox table (same transaction)
|
||||
3. Background processor publishes and marks as sent
|
||||
|
||||
See [./references/REFERENCE.md](./references/REFERENCE.md) for implementation.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Saga Pattern
|
||||
|
||||
1. **Design Compensations**: Every step needs compensation
|
||||
2. **Idempotent Steps**: Make steps idempotent for retries
|
||||
3. **Timeout Handling**: Set timeouts for saga execution
|
||||
4. **Monitoring**: Track saga execution and compensation
|
||||
5. **Choreography vs Orchestration**: Choose based on complexity
|
||||
- Design compensations for every step
|
||||
- Make steps idempotent for retries
|
||||
- Set timeouts for saga execution
|
||||
- Monitor saga execution and compensation
|
||||
- Choose orchestration for complex workflows, choreography for simple ones
|
||||
|
||||
### Idempotency
|
||||
|
||||
1. **Idempotency Keys**: Generate from request data
|
||||
2. **Key Storage**: Store keys with results
|
||||
3. **TTL**: Set appropriate TTL for idempotency records
|
||||
4. **Cleanup**: Regularly clean expired records
|
||||
- Generate keys from request data
|
||||
- Store keys with results
|
||||
- Set appropriate TTL for records
|
||||
- Regularly clean expired records
|
||||
|
||||
### Eventual Consistency
|
||||
|
||||
1. **Accept Delays**: Accept that consistency is eventual
|
||||
2. **Read Models**: Use separate read models for queries
|
||||
3. **Conflict Resolution**: Define resolution strategies
|
||||
4. **Monitoring**: Monitor consistency lag
|
||||
- Accept that consistency is eventual
|
||||
- Use separate read models for queries
|
||||
- Define resolution strategies upfront
|
||||
- Monitor consistency lag
|
||||
|
||||
### Optimistic Locking
|
||||
|
||||
1. **Version Fields**: Add version fields to entities
|
||||
2. **Retry Logic**: Implement retry with backoff
|
||||
3. **Conflict Handling**: Handle conflicts gracefully
|
||||
4. **Performance**: Better than pessimistic locking for read-heavy workloads
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Testing Sagas
|
||||
|
||||
```typescript
|
||||
// src/__tests__/saga/order-processing.saga.test.ts
|
||||
describe('OrderProcessingSaga', () => {
|
||||
it('should complete saga successfully', async () => {
|
||||
const saga = new OrderProcessingSaga();
|
||||
await saga.processOrder(orderData);
|
||||
|
||||
expect(orderService.create).toHaveBeenCalled();
|
||||
expect(inventoryService.reserve).toHaveBeenCalled();
|
||||
expect(paymentService.charge).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compensate on payment failure', async () => {
|
||||
paymentService.charge.mockRejectedValue(new Error('Payment failed'));
|
||||
|
||||
await expect(saga.processOrder(orderData)).rejects.toThrow();
|
||||
|
||||
expect(orderService.cancel).toHaveBeenCalled();
|
||||
expect(inventoryService.release).toHaveBeenCalled();
|
||||
expect(paymentService.refund).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
- Add version fields to entities
|
||||
- Implement retry with exponential backoff
|
||||
- Handle conflicts gracefully
|
||||
- Use for read-heavy workloads
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **No Compensation Logic**: Saga steps without rollback
|
||||
```typescript
|
||||
// ❌ BAD: No compensation
|
||||
steps: [{ execute: () => createOrder() }]
|
||||
### No Compensation Logic
|
||||
```typescript
|
||||
// BAD: No compensation
|
||||
steps: [{ execute: () => createOrder() }]
|
||||
|
||||
// ✅ GOOD: Always define compensation
|
||||
steps: [{
|
||||
execute: () => createOrder(),
|
||||
compensate: (ctx) => cancelOrder(ctx.orderId)
|
||||
}]
|
||||
```
|
||||
// GOOD: Always define compensation
|
||||
steps: [{
|
||||
execute: () => createOrder(),
|
||||
compensate: (ctx) => cancelOrder(ctx.orderId)
|
||||
}]
|
||||
```
|
||||
|
||||
2. **Missing Idempotency**: Duplicate processing on retry
|
||||
```typescript
|
||||
// ❌ BAD: Creates duplicate
|
||||
await createPayment(orderId);
|
||||
### Missing Idempotency
|
||||
```typescript
|
||||
// BAD: Creates duplicate on retry
|
||||
await createPayment(orderId);
|
||||
|
||||
// ✅ GOOD: Idempotent check
|
||||
const existing = await findPayment(idempotencyKey);
|
||||
if (existing) return existing;
|
||||
await createPayment(orderId);
|
||||
```
|
||||
// GOOD: Idempotent check
|
||||
const existing = await findPayment(idempotencyKey);
|
||||
if (existing) return existing;
|
||||
await createPayment(orderId);
|
||||
```
|
||||
|
||||
3. **Ignoring Partial Failures**: Not handling step failures
|
||||
```typescript
|
||||
// ❌ BAD: No error handling
|
||||
await step1(); await step2(); await step3();
|
||||
### Ignoring Partial Failures
|
||||
```typescript
|
||||
// BAD: No error handling
|
||||
await step1(); await step2(); await step3();
|
||||
|
||||
// ✅ GOOD: Saga orchestration
|
||||
await sagaOrchestrator.execute(context);
|
||||
```
|
||||
// GOOD: Saga orchestration
|
||||
await sagaOrchestrator.execute(context);
|
||||
```
|
||||
|
||||
4. **No Version Field**: Concurrent update conflicts
|
||||
```prisma
|
||||
// ✅ Add version for optimistic locking
|
||||
model Entity {
|
||||
version Int @default(1)
|
||||
}
|
||||
```
|
||||
### No Version Field
|
||||
```prisma
|
||||
// GOOD: Add version for optimistic locking
|
||||
model Entity {
|
||||
version Int @default(1)
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
@@ -743,40 +253,19 @@ describe('OrderProcessingSaga', () => {
|
||||
| **Outbox Pattern** | Guaranteed event publishing | Medium |
|
||||
| **Idempotency** | Retry-safe operations | Low |
|
||||
| **Optimistic Lock** | Concurrent updates | Low |
|
||||
|
||||
**Saga Steps:**
|
||||
```
|
||||
Execute: Step1 → Step2 → Step3 → Complete
|
||||
Compensate: ← Step2.undo ← Step1.undo (on failure)
|
||||
```
|
||||
|
||||
**Idempotency Key Pattern:**
|
||||
```typescript
|
||||
const key = `${operation}:${userId}:${hash(requestData)}`;
|
||||
await idempotencyHandler.execute(key, () => operation());
|
||||
```
|
||||
|
||||
**Optimistic Lock Query:**
|
||||
```typescript
|
||||
await prisma.entity.update({
|
||||
where: { id, version: currentVersion },
|
||||
data: { ...updates, version: { increment: 1 } }
|
||||
});
|
||||
```
|
||||
|
||||
**Consistency Models:**
|
||||
| Model | Latency | Use Case |
|
||||
|-------|---------|----------|
|
||||
| **Strong** | High | Financial transactions |
|
||||
| **Eventual** | Low | Read models, analytics |
|
||||
| **Causal** | Medium | User sessions |
|
||||
| **CQRS** | Read/write optimization | High |
|
||||
| **Dead Letter Queue** | Failed message handling | Medium |
|
||||
|
||||
## Resources
|
||||
|
||||
- [Saga Pattern](https://microservices.io/patterns/data/saga.html) - Saga pattern overview
|
||||
- [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) - Event sourcing pattern
|
||||
- [CQRS Pattern](https://martinfowler.com/bliki/CQRS.html) - CQRS pattern
|
||||
### Internal References
|
||||
- [Detailed Code Examples](./references/REFERENCE.md) - Full implementations for all patterns
|
||||
- [Event-Driven Architecture](../event-driven-architecture/SKILL.md) - Event patterns
|
||||
- [Error Handling Patterns](../error-handling-patterns/SKILL.md) - Error handling
|
||||
- [Database & Prisma](../database-prisma/SKILL.md) - Database patterns
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo coding standards
|
||||
|
||||
### External Resources
|
||||
- [Saga Pattern](https://microservices.io/patterns/data/saga.html) - Saga pattern overview
|
||||
- [Event Sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) - Event sourcing pattern
|
||||
- [CQRS Pattern](https://martinfowler.com/bliki/CQRS.html) - CQRS pattern
|
||||
|
||||
926
.cursor/skills/data-consistency-patterns/references/REFERENCE.md
Normal file
926
.cursor/skills/data-consistency-patterns/references/REFERENCE.md
Normal file
@@ -0,0 +1,926 @@
|
||||
# Data Consistency Patterns - Reference
|
||||
|
||||
This reference contains detailed implementations and code examples for data consistency patterns in distributed systems.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Saga Orchestrator Implementation](#saga-orchestrator-implementation)
|
||||
- [Saga Example: Order Processing](#saga-example-order-processing)
|
||||
- [Saga Choreography Pattern](#saga-choreography-pattern)
|
||||
- [Idempotency Handler](#idempotency-handler)
|
||||
- [Idempotency Key Generation](#idempotency-key-generation)
|
||||
- [Optimistic Locking](#optimistic-locking)
|
||||
- [CQRS Pattern](#cqrs-pattern)
|
||||
- [Conflict Resolution](#conflict-resolution)
|
||||
- [Testing Patterns](#testing-patterns)
|
||||
|
||||
---
|
||||
|
||||
## Saga Orchestrator Implementation
|
||||
|
||||
```typescript
|
||||
// src/core/saga/saga-orchestrator.ts
|
||||
// EN: Saga orchestrator for distributed transactions
|
||||
// VI: Saga orchestrator cho distributed transactions
|
||||
import { logger } from '@goodgo/logger';
|
||||
import { eventPublisher } from '../events/event-publisher';
|
||||
|
||||
export interface SagaStep {
|
||||
name: string;
|
||||
execute: () => Promise<any>;
|
||||
compensate: (context: any) => Promise<void>;
|
||||
retry?: number;
|
||||
}
|
||||
|
||||
export interface SagaContext {
|
||||
sagaId: string;
|
||||
steps: SagaStep[];
|
||||
currentStep: number;
|
||||
data: Record<string, any>;
|
||||
status: 'pending' | 'running' | 'completed' | 'compensating' | 'failed';
|
||||
}
|
||||
|
||||
export class SagaOrchestrator {
|
||||
/**
|
||||
* EN: Execute saga with all steps
|
||||
* VI: Thuc thi saga voi tat ca cac buoc
|
||||
*/
|
||||
async execute(context: SagaContext): Promise<void> {
|
||||
context.status = 'running';
|
||||
|
||||
try {
|
||||
for (let i = 0; i < context.steps.length; i++) {
|
||||
context.currentStep = i;
|
||||
const step = context.steps[i];
|
||||
|
||||
logger.info('Executing saga step', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await this.executeWithRetry(step, step.retry || 3);
|
||||
context.data[step.name] = result;
|
||||
|
||||
// EN: Publish step completed event
|
||||
// VI: Publish event step da hoan thanh
|
||||
await eventPublisher.publish('saga.step.completed', {
|
||||
eventType: 'saga.step.completed',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Saga step failed', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
error: error.message,
|
||||
});
|
||||
|
||||
// EN: Compensate all completed steps
|
||||
// VI: Compensate tat ca cac buoc da hoan thanh
|
||||
await this.compensate(context, i - 1);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
context.status = 'completed';
|
||||
logger.info('Saga completed successfully', { sagaId: context.sagaId });
|
||||
} catch (error) {
|
||||
context.status = 'failed';
|
||||
logger.error('Saga failed', {
|
||||
sagaId: context.sagaId,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeWithRetry(step: SagaStep, maxRetries: number): Promise<any> {
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await step.execute();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 1000; // Exponential backoff
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
* EN: Compensate completed steps in reverse order
|
||||
* VI: Compensate cac buoc da hoan thanh theo thu tu nguoc
|
||||
*/
|
||||
private async compensate(context: SagaContext, lastCompletedStep: number): Promise<void> {
|
||||
context.status = 'compensating';
|
||||
|
||||
for (let i = lastCompletedStep; i >= 0; i--) {
|
||||
const step = context.steps[i];
|
||||
|
||||
try {
|
||||
logger.info('Compensating saga step', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
});
|
||||
|
||||
await step.compensate(context.data);
|
||||
|
||||
await eventPublisher.publish('saga.step.compensated', {
|
||||
eventType: 'saga.step.compensated',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
stepIndex: i,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Compensation failed', {
|
||||
sagaId: context.sagaId,
|
||||
step: step.name,
|
||||
error: error.message,
|
||||
});
|
||||
// EN: Log but continue compensating other steps
|
||||
// VI: Log nhung tiep tuc compensate cac buoc khac
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Saga Example: Order Processing
|
||||
|
||||
```typescript
|
||||
// src/modules/order/order.saga.ts
|
||||
// EN: Order processing saga
|
||||
// VI: Saga xu ly order
|
||||
import { SagaOrchestrator, SagaContext, SagaStep } from '../../core/saga/saga-orchestrator';
|
||||
import { orderService } from './order.service';
|
||||
import { paymentService } from '../payment/payment.service';
|
||||
import { inventoryService } from '../inventory/inventory.service';
|
||||
import { notificationService } from '../notification/notification.service';
|
||||
|
||||
export class OrderProcessingSaga {
|
||||
private orchestrator: SagaOrchestrator;
|
||||
|
||||
constructor() {
|
||||
this.orchestrator = new SagaOrchestrator();
|
||||
}
|
||||
|
||||
async processOrder(orderData: any): Promise<void> {
|
||||
const sagaId = `saga_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const steps: SagaStep[] = [
|
||||
{
|
||||
name: 'create-order',
|
||||
execute: async () => {
|
||||
return await orderService.create(orderData);
|
||||
},
|
||||
compensate: async (context) => {
|
||||
await orderService.cancel(context.data['create-order'].id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'reserve-inventory',
|
||||
execute: async () => {
|
||||
const order = context.data['create-order'];
|
||||
return await inventoryService.reserve(order.items);
|
||||
},
|
||||
compensate: async (context) => {
|
||||
const reservation = context.data['reserve-inventory'];
|
||||
await inventoryService.release(reservation.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'process-payment',
|
||||
execute: async () => {
|
||||
const order = context.data['create-order'];
|
||||
return await paymentService.charge(order.total, order.paymentMethod);
|
||||
},
|
||||
compensate: async (context) => {
|
||||
const payment = context.data['process-payment'];
|
||||
await paymentService.refund(payment.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'send-confirmation',
|
||||
execute: async () => {
|
||||
const order = context.data['create-order'];
|
||||
await notificationService.sendOrderConfirmation(order.userId, order.id);
|
||||
// EN: Non-critical step, no compensation needed
|
||||
// VI: Buoc khong quan trong, khong can compensation
|
||||
},
|
||||
compensate: async () => {
|
||||
// EN: No compensation needed for notification
|
||||
// VI: Khong can compensation cho notification
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const context: SagaContext = {
|
||||
sagaId,
|
||||
steps,
|
||||
currentStep: 0,
|
||||
data: {},
|
||||
status: 'pending',
|
||||
};
|
||||
|
||||
await this.orchestrator.execute(context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Saga Choreography Pattern
|
||||
|
||||
```typescript
|
||||
// EN: Choreography pattern - services react to events
|
||||
// VI: Pattern choreography - services phan ung voi events
|
||||
// src/modules/order/order.service.ts
|
||||
export class OrderService {
|
||||
async createOrder(data: CreateOrderDto): Promise<Order> {
|
||||
const order = await this.orderRepository.create(data);
|
||||
|
||||
// EN: Publish event for next step
|
||||
// VI: Publish event cho buoc tiep theo
|
||||
await eventPublisher.publish('order.created', {
|
||||
eventType: 'order.created',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: order.id,
|
||||
items: order.items,
|
||||
total: order.total,
|
||||
},
|
||||
});
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: string): Promise<void> {
|
||||
await this.orderRepository.update(orderId, { status: 'cancelled' });
|
||||
}
|
||||
}
|
||||
|
||||
// src/modules/inventory/inventory.consumer.ts
|
||||
// EN: Inventory service reacts to order.created event
|
||||
// VI: Inventory service phan ung voi order.created event
|
||||
eventConsumer.on('order.created', {
|
||||
handle: async (event) => {
|
||||
try {
|
||||
const reservation = await inventoryService.reserve(event.data.items);
|
||||
|
||||
// EN: Publish next step event
|
||||
// VI: Publish event buoc tiep theo
|
||||
await eventPublisher.publish('inventory.reserved', {
|
||||
eventType: 'inventory.reserved',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
reservationId: reservation.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// EN: Publish compensation event
|
||||
// VI: Publish event compensation
|
||||
await eventPublisher.publish('order.cancelled', {
|
||||
eventType: 'order.cancelled',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
reason: 'inventory_reservation_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// src/modules/payment/payment.consumer.ts
|
||||
eventConsumer.on('inventory.reserved', {
|
||||
handle: async (event) => {
|
||||
try {
|
||||
const order = await orderService.findById(event.data.orderId);
|
||||
const payment = await paymentService.charge(order.total, order.paymentMethod);
|
||||
|
||||
await eventPublisher.publish('payment.processed', {
|
||||
eventType: 'payment.processed',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
paymentId: payment.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// EN: Trigger compensation
|
||||
// VI: Kich hoat compensation
|
||||
await eventPublisher.publish('order.cancelled', {
|
||||
eventType: 'order.cancelled',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
orderId: event.data.orderId,
|
||||
reason: 'payment_failed',
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Idempotency Handler
|
||||
|
||||
```typescript
|
||||
// src/core/idempotency/idempotency.ts
|
||||
// EN: Idempotency handler
|
||||
// VI: Handler idempotency
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '@goodgo/logger';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class IdempotencyHandler {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* EN: Execute operation with idempotency check
|
||||
* VI: Thuc thi operation voi idempotency check
|
||||
*/
|
||||
async execute<T>(
|
||||
idempotencyKey: string,
|
||||
operation: () => Promise<T>,
|
||||
ttl: number = 3600 // EN: 1 hour default / VI: Mac dinh 1 gio
|
||||
): Promise<T> {
|
||||
// EN: Check if already processed
|
||||
// VI: Kiem tra xem da duoc xu ly chua
|
||||
const existing = await this.prisma.idempotencyRecord.findUnique({
|
||||
where: { idempotencyKey },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
logger.info('Idempotent operation skipped', { idempotencyKey });
|
||||
return existing.result as T;
|
||||
}
|
||||
|
||||
// EN: Execute operation
|
||||
// VI: Thuc thi operation
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
// EN: Store result
|
||||
// VI: Luu ket qua
|
||||
await this.prisma.idempotencyRecord.create({
|
||||
data: {
|
||||
idempotencyKey,
|
||||
result: result as any,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// EN: Store error for idempotency (optional)
|
||||
// VI: Luu loi cho idempotency (tuy chon)
|
||||
logger.error('Idempotent operation failed', { idempotencyKey, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const idempotencyHandler = new IdempotencyHandler(prisma);
|
||||
|
||||
await idempotencyHandler.execute(
|
||||
`create-user-${userId}`,
|
||||
async () => {
|
||||
return await userService.create(userData);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Idempotency Key Generation
|
||||
|
||||
```typescript
|
||||
// EN: Generate idempotency key from request
|
||||
// VI: Tao idempotency key tu request
|
||||
export function generateIdempotencyKey(
|
||||
userId: string,
|
||||
operation: string,
|
||||
requestData: any
|
||||
): string {
|
||||
const hash = createHash('sha256')
|
||||
.update(JSON.stringify({ userId, operation, requestData }))
|
||||
.digest('hex');
|
||||
|
||||
return `${operation}:${userId}:${hash.substr(0, 16)}`;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Optimistic Locking
|
||||
|
||||
### Version-Based Locking
|
||||
|
||||
```typescript
|
||||
// src/core/locking/optimistic-lock.ts
|
||||
// EN: Optimistic locking with version field
|
||||
// VI: Optimistic locking voi truong version
|
||||
export class OptimisticLockService {
|
||||
/**
|
||||
* EN: Update with optimistic lock
|
||||
* VI: Update voi optimistic lock
|
||||
*/
|
||||
async updateWithLock<T extends { version: number }>(
|
||||
repository: any,
|
||||
id: string,
|
||||
updateFn: (current: T) => Partial<T>,
|
||||
maxRetries: number = 3
|
||||
): Promise<T> {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
// EN: Read current version
|
||||
// VI: Doc version hien tai
|
||||
const current = await repository.findById(id);
|
||||
|
||||
if (!current) {
|
||||
throw new Error('Resource not found');
|
||||
}
|
||||
|
||||
// EN: Apply update
|
||||
// VI: Ap dung update
|
||||
const updates = updateFn(current);
|
||||
|
||||
try {
|
||||
// EN: Update with version check
|
||||
// VI: Update voi kiem tra version
|
||||
const updated = await repository.update(id, {
|
||||
...updates,
|
||||
version: current.version + 1,
|
||||
}, {
|
||||
where: {
|
||||
id,
|
||||
version: current.version, // EN: Only update if version matches / VI: Chi update neu version khop
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries - 1) {
|
||||
throw new Error('Optimistic lock conflict - max retries exceeded');
|
||||
}
|
||||
|
||||
// EN: Retry with exponential backoff
|
||||
// VI: Retry voi exponential backoff
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.pow(2, attempt) * 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Optimistic lock failed');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Prisma Schema for Optimistic Locking
|
||||
|
||||
```prisma
|
||||
// Add version field to entities requiring optimistic locking
|
||||
model Entity {
|
||||
id String @id @default(cuid())
|
||||
version Int @default(1)
|
||||
data String
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CQRS Pattern
|
||||
|
||||
### Write Service
|
||||
|
||||
```typescript
|
||||
// EN: Separate read and write models
|
||||
// VI: Tach biet read va write models
|
||||
// src/modules/user/user.write.service.ts
|
||||
export class UserWriteService {
|
||||
async createUser(data: CreateUserDto): Promise<User> {
|
||||
const user = await this.userRepository.create(data);
|
||||
|
||||
// EN: Publish event for read model update
|
||||
// VI: Publish event de cap nhat read model
|
||||
await eventPublisher.publish('user.created', {
|
||||
eventType: 'user.created',
|
||||
eventVersion: '1.0.0',
|
||||
data: {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Read Service
|
||||
|
||||
```typescript
|
||||
// src/modules/user/user.read.service.ts
|
||||
// EN: Read model updated via events
|
||||
// VI: Read model duoc cap nhat qua events
|
||||
export class UserReadService {
|
||||
async getUser(userId: string): Promise<UserReadModel> {
|
||||
return await this.userReadRepository.findById(userId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Read Model Consumer
|
||||
|
||||
```typescript
|
||||
// src/modules/user/user.read.consumer.ts
|
||||
eventConsumer.on('user.created', {
|
||||
handle: async (event) => {
|
||||
// EN: Update read model
|
||||
// VI: Cap nhat read model
|
||||
await userReadRepository.create({
|
||||
userId: event.data.userId,
|
||||
email: event.data.email,
|
||||
name: event.data.name,
|
||||
// EN: Additional denormalized data for queries
|
||||
// VI: Du lieu denormalized them cho queries
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
```typescript
|
||||
// src/core/consistency/conflict-resolver.ts
|
||||
// EN: Conflict resolution strategies
|
||||
// VI: Cac chien luoc giai quyet conflict
|
||||
export enum ConflictResolutionStrategy {
|
||||
LAST_WRITE_WINS = 'last_write_wins',
|
||||
FIRST_WRITE_WINS = 'first_write_wins',
|
||||
MERGE = 'merge',
|
||||
MANUAL = 'manual',
|
||||
}
|
||||
|
||||
export class ConflictResolver {
|
||||
resolve(
|
||||
strategy: ConflictResolutionStrategy,
|
||||
current: any,
|
||||
incoming: any
|
||||
): any {
|
||||
switch (strategy) {
|
||||
case ConflictResolutionStrategy.LAST_WRITE_WINS:
|
||||
return incoming.timestamp > current.timestamp ? incoming : current;
|
||||
|
||||
case ConflictResolutionStrategy.FIRST_WRITE_WINS:
|
||||
return incoming.timestamp < current.timestamp ? incoming : current;
|
||||
|
||||
case ConflictResolutionStrategy.MERGE:
|
||||
return { ...current, ...incoming, timestamp: Date.now() };
|
||||
|
||||
case ConflictResolutionStrategy.MANUAL:
|
||||
// EN: Store conflict for manual resolution
|
||||
// VI: Luu conflict de giai quyet thu cong
|
||||
this.storeConflict(current, incoming);
|
||||
return current;
|
||||
|
||||
default:
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
private storeConflict(current: any, incoming: any): void {
|
||||
// Store conflict in database for manual resolution
|
||||
logger.warn('Conflict stored for manual resolution', { current, incoming });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conflict Resolution Usage Example
|
||||
|
||||
```typescript
|
||||
// Example: Resolving conflicts in a sync operation
|
||||
const resolver = new ConflictResolver();
|
||||
|
||||
async function syncData(localData: any, remoteData: any): Promise<any> {
|
||||
if (localData.version !== remoteData.version) {
|
||||
return resolver.resolve(
|
||||
ConflictResolutionStrategy.LAST_WRITE_WINS,
|
||||
localData,
|
||||
remoteData
|
||||
);
|
||||
}
|
||||
return localData;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Testing Sagas
|
||||
|
||||
```typescript
|
||||
// src/__tests__/saga/order-processing.saga.test.ts
|
||||
describe('OrderProcessingSaga', () => {
|
||||
let saga: OrderProcessingSaga;
|
||||
let orderService: jest.Mocked<OrderService>;
|
||||
let inventoryService: jest.Mocked<InventoryService>;
|
||||
let paymentService: jest.Mocked<PaymentService>;
|
||||
|
||||
beforeEach(() => {
|
||||
orderService = {
|
||||
create: jest.fn(),
|
||||
cancel: jest.fn(),
|
||||
} as any;
|
||||
inventoryService = {
|
||||
reserve: jest.fn(),
|
||||
release: jest.fn(),
|
||||
} as any;
|
||||
paymentService = {
|
||||
charge: jest.fn(),
|
||||
refund: jest.fn(),
|
||||
} as any;
|
||||
|
||||
saga = new OrderProcessingSaga();
|
||||
});
|
||||
|
||||
it('should complete saga successfully', async () => {
|
||||
orderService.create.mockResolvedValue({ id: 'order-1', items: [], total: 100 });
|
||||
inventoryService.reserve.mockResolvedValue({ id: 'reservation-1' });
|
||||
paymentService.charge.mockResolvedValue({ id: 'payment-1' });
|
||||
|
||||
await saga.processOrder(orderData);
|
||||
|
||||
expect(orderService.create).toHaveBeenCalled();
|
||||
expect(inventoryService.reserve).toHaveBeenCalled();
|
||||
expect(paymentService.charge).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compensate on payment failure', async () => {
|
||||
orderService.create.mockResolvedValue({ id: 'order-1', items: [], total: 100 });
|
||||
inventoryService.reserve.mockResolvedValue({ id: 'reservation-1' });
|
||||
paymentService.charge.mockRejectedValue(new Error('Payment failed'));
|
||||
|
||||
await expect(saga.processOrder(orderData)).rejects.toThrow('Payment failed');
|
||||
|
||||
expect(orderService.cancel).toHaveBeenCalled();
|
||||
expect(inventoryService.release).toHaveBeenCalled();
|
||||
expect(paymentService.refund).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should compensate on inventory failure', async () => {
|
||||
orderService.create.mockResolvedValue({ id: 'order-1', items: [], total: 100 });
|
||||
inventoryService.reserve.mockRejectedValue(new Error('Out of stock'));
|
||||
|
||||
await expect(saga.processOrder(orderData)).rejects.toThrow('Out of stock');
|
||||
|
||||
expect(orderService.cancel).toHaveBeenCalled();
|
||||
expect(inventoryService.release).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Idempotency
|
||||
|
||||
```typescript
|
||||
// src/__tests__/idempotency/idempotency-handler.test.ts
|
||||
describe('IdempotencyHandler', () => {
|
||||
let handler: IdempotencyHandler;
|
||||
let prisma: PrismaClient;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = new PrismaClient();
|
||||
handler = new IdempotencyHandler(prisma);
|
||||
});
|
||||
|
||||
it('should execute operation once', async () => {
|
||||
const operation = jest.fn().mockResolvedValue({ id: 'result-1' });
|
||||
|
||||
const result1 = await handler.execute('key-1', operation);
|
||||
const result2 = await handler.execute('key-1', operation);
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(1);
|
||||
expect(result1).toEqual(result2);
|
||||
});
|
||||
|
||||
it('should execute different keys independently', async () => {
|
||||
const operation = jest.fn().mockResolvedValue({ id: 'result' });
|
||||
|
||||
await handler.execute('key-1', operation);
|
||||
await handler.execute('key-2', operation);
|
||||
|
||||
expect(operation).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Optimistic Locking
|
||||
|
||||
```typescript
|
||||
// src/__tests__/locking/optimistic-lock.test.ts
|
||||
describe('OptimisticLockService', () => {
|
||||
let service: OptimisticLockService;
|
||||
let repository: jest.Mocked<Repository>;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: jest.fn(),
|
||||
update: jest.fn(),
|
||||
} as any;
|
||||
service = new OptimisticLockService();
|
||||
});
|
||||
|
||||
it('should update successfully on first attempt', async () => {
|
||||
repository.findById.mockResolvedValue({ id: '1', version: 1, data: 'old' });
|
||||
repository.update.mockResolvedValue({ id: '1', version: 2, data: 'new' });
|
||||
|
||||
const result = await service.updateWithLock(
|
||||
repository,
|
||||
'1',
|
||||
(current) => ({ data: 'new' })
|
||||
);
|
||||
|
||||
expect(result.version).toBe(2);
|
||||
expect(result.data).toBe('new');
|
||||
});
|
||||
|
||||
it('should retry on version conflict', async () => {
|
||||
repository.findById
|
||||
.mockResolvedValueOnce({ id: '1', version: 1, data: 'old' })
|
||||
.mockResolvedValueOnce({ id: '1', version: 2, data: 'concurrent' });
|
||||
repository.update
|
||||
.mockRejectedValueOnce(new Error('Version conflict'))
|
||||
.mockResolvedValueOnce({ id: '1', version: 3, data: 'new' });
|
||||
|
||||
const result = await service.updateWithLock(
|
||||
repository,
|
||||
'1',
|
||||
(current) => ({ data: 'new' })
|
||||
);
|
||||
|
||||
expect(repository.findById).toHaveBeenCalledTimes(2);
|
||||
expect(result.version).toBe(3);
|
||||
});
|
||||
|
||||
it('should throw after max retries', async () => {
|
||||
repository.findById.mockResolvedValue({ id: '1', version: 1, data: 'old' });
|
||||
repository.update.mockRejectedValue(new Error('Version conflict'));
|
||||
|
||||
await expect(
|
||||
service.updateWithLock(repository, '1', (current) => ({ data: 'new' }), 3)
|
||||
).rejects.toThrow('Optimistic lock conflict - max retries exceeded');
|
||||
|
||||
expect(repository.findById).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Additional Patterns
|
||||
|
||||
### Outbox Pattern for Reliable Event Publishing
|
||||
|
||||
```typescript
|
||||
// src/core/outbox/outbox.service.ts
|
||||
// EN: Outbox pattern for reliable event publishing
|
||||
// VI: Outbox pattern cho viec publish event dang tin cay
|
||||
export class OutboxService {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
async publishWithOutbox<T>(
|
||||
operation: () => Promise<T>,
|
||||
event: DomainEvent
|
||||
): Promise<T> {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
// Execute business operation
|
||||
const result = await operation();
|
||||
|
||||
// Store event in outbox (same transaction)
|
||||
await tx.outboxEvent.create({
|
||||
data: {
|
||||
eventType: event.eventType,
|
||||
payload: event.data as any,
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Background processor to publish outbox events
|
||||
export class OutboxProcessor {
|
||||
async processOutbox(): Promise<void> {
|
||||
const events = await this.prisma.outboxEvent.findMany({
|
||||
where: { status: 'pending' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
await eventPublisher.publish(event.eventType, event.payload);
|
||||
await this.prisma.outboxEvent.update({
|
||||
where: { id: event.id },
|
||||
data: { status: 'published', publishedAt: new Date() },
|
||||
});
|
||||
} catch (error) {
|
||||
await this.prisma.outboxEvent.update({
|
||||
where: { id: event.id },
|
||||
data: {
|
||||
status: 'failed',
|
||||
retryCount: { increment: 1 },
|
||||
lastError: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dead Letter Queue Pattern
|
||||
|
||||
```typescript
|
||||
// src/core/dlq/dead-letter-queue.ts
|
||||
// EN: Dead letter queue for failed messages
|
||||
// VI: Dead letter queue cho cac message that bai
|
||||
export class DeadLetterQueue {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
async moveToDeadLetter(
|
||||
originalQueue: string,
|
||||
message: any,
|
||||
error: Error,
|
||||
retryCount: number
|
||||
): Promise<void> {
|
||||
await this.prisma.deadLetterMessage.create({
|
||||
data: {
|
||||
originalQueue,
|
||||
payload: message as any,
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
retryCount,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.warn('Message moved to dead letter queue', {
|
||||
originalQueue,
|
||||
retryCount,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
async reprocessDeadLetters(queue: string): Promise<number> {
|
||||
const messages = await this.prisma.deadLetterMessage.findMany({
|
||||
where: { originalQueue: queue, reprocessed: false },
|
||||
});
|
||||
|
||||
let processed = 0;
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
await eventPublisher.publish(queue, msg.payload);
|
||||
await this.prisma.deadLetterMessage.update({
|
||||
where: { id: msg.id },
|
||||
data: { reprocessed: true, reprocessedAt: new Date() },
|
||||
});
|
||||
processed++;
|
||||
} catch (error) {
|
||||
logger.error('Failed to reprocess dead letter', { id: msg.id, error });
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: database-prisma
|
||||
description: Prisma ORM and database patterns for GoodGo services. Use for schemas, migrations, repositories, or query optimization.
|
||||
dependencies: "prisma>=5, @prisma/client>=5"
|
||||
compatibility: "prisma>=5, @prisma/client>=5"
|
||||
---
|
||||
|
||||
# Prisma Database Patterns
|
||||
@@ -27,547 +27,142 @@ Use this skill when:
|
||||
- Connection pooling for performance
|
||||
- Transaction support for data consistency
|
||||
|
||||
## Prisma Setup
|
||||
## Key Patterns
|
||||
|
||||
### Installation
|
||||
### Prisma Setup
|
||||
|
||||
```bash
|
||||
npm install @prisma/client prisma
|
||||
npm install --save-dev @types/node
|
||||
npx prisma init
|
||||
```
|
||||
|
||||
### Configuration
|
||||
### Schema Definition
|
||||
|
||||
```typescript
|
||||
// prisma/schema.prisma
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Base model with common fields
|
||||
```prisma
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
password String
|
||||
role Role @default(USER)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
posts Post[]
|
||||
profile Profile?
|
||||
|
||||
// Indexes for performance
|
||||
|
||||
@@index([email])
|
||||
@@index([createdAt])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String?
|
||||
published Boolean @default(false)
|
||||
authorId String
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([authorId])
|
||||
@@index([published, createdAt])
|
||||
@@map("posts")
|
||||
}
|
||||
|
||||
model Profile {
|
||||
id String @id @default(cuid())
|
||||
bio String?
|
||||
avatar String?
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("profiles")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
MODERATOR
|
||||
}
|
||||
```
|
||||
|
||||
## Database Connection
|
||||
### Database Connection
|
||||
|
||||
```typescript
|
||||
// src/lib/prisma.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = global as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
const globalForPrisma = global as unknown as { prisma: PrismaClient | undefined };
|
||||
|
||||
export const prisma = globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development'
|
||||
? ['query', 'error', 'warn']
|
||||
: ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
// Middleware for soft delete
|
||||
prisma.$use(async (params, next) => {
|
||||
if (params.model && params.action === 'delete') {
|
||||
return next({
|
||||
...params,
|
||||
action: 'update',
|
||||
args: {
|
||||
...params.args,
|
||||
data: { deletedAt: new Date() }
|
||||
}
|
||||
});
|
||||
}
|
||||
return next(params);
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
```
|
||||
|
||||
## Repository Pattern
|
||||
### Repository Pattern
|
||||
|
||||
```typescript
|
||||
// src/repositories/base.repository.ts
|
||||
export abstract class BaseRepository<T> {
|
||||
constructor(protected prisma: PrismaClient) {}
|
||||
|
||||
abstract findById(id: string): Promise<T | null>;
|
||||
abstract findAll(options?: any): Promise<T[]>;
|
||||
abstract create(data: any): Promise<T>;
|
||||
abstract update(id: string, data: any): Promise<T>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
// src/repositories/user.repository.ts
|
||||
export class UserRepository extends BaseRepository<User> {
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { profile: true }
|
||||
});
|
||||
export class UserRepository {
|
||||
async findById(id: string) {
|
||||
return prisma.user.findUnique({ where: { id }, include: { profile: true } });
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(options: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
} = {}): Promise<{ data: User[]; total: number }> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
search,
|
||||
sortBy = 'createdAt',
|
||||
order = 'desc'
|
||||
} = options;
|
||||
|
||||
const where = search ? {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ name: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
} : {};
|
||||
async findAll({ page = 1, limit = 10, search }: QueryOptions) {
|
||||
const where = search ? { OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ name: { contains: search, mode: 'insensitive' } }
|
||||
]} : {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { [sortBy]: order },
|
||||
include: { profile: true }
|
||||
}),
|
||||
this.prisma.user.count({ where })
|
||||
prisma.user.findMany({ where, skip: (page - 1) * limit, take: limit }),
|
||||
prisma.user.count({ where })
|
||||
]);
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async create(data: CreateUserDto): Promise<User> {
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
profile: data.bio ? {
|
||||
create: { bio: data.bio }
|
||||
} : undefined
|
||||
},
|
||||
include: { profile: true }
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateUserDto): Promise<User> {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { profile: true }
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.user.delete({
|
||||
where: { id }
|
||||
});
|
||||
async create(data: CreateUserDto) {
|
||||
return prisma.user.create({ data });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Transactions
|
||||
### Transactions
|
||||
|
||||
```typescript
|
||||
// Transaction example
|
||||
export class TransferService {
|
||||
async transferFunds(
|
||||
fromAccountId: string,
|
||||
toAccountId: string,
|
||||
amount: number
|
||||
) {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
// Check balance
|
||||
const fromAccount = await tx.account.findUnique({
|
||||
where: { id: fromAccountId }
|
||||
});
|
||||
|
||||
if (!fromAccount || fromAccount.balance < amount) {
|
||||
throw new Error('Insufficient funds');
|
||||
}
|
||||
|
||||
// Deduct from sender
|
||||
const updatedFrom = await tx.account.update({
|
||||
where: { id: fromAccountId },
|
||||
data: { balance: { decrement: amount } }
|
||||
});
|
||||
|
||||
// Add to receiver
|
||||
const updatedTo = await tx.account.update({
|
||||
where: { id: toAccountId },
|
||||
data: { balance: { increment: amount } }
|
||||
});
|
||||
|
||||
// Create transaction record
|
||||
const transaction = await tx.transaction.create({
|
||||
data: {
|
||||
fromAccountId,
|
||||
toAccountId,
|
||||
amount,
|
||||
type: 'TRANSFER',
|
||||
status: 'COMPLETED'
|
||||
}
|
||||
});
|
||||
|
||||
return transaction;
|
||||
}, {
|
||||
maxWait: 5000,
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migrations
|
||||
|
||||
```bash
|
||||
# Create migration
|
||||
npx prisma migrate dev --name add_user_table
|
||||
|
||||
# Apply migrations
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Reset database
|
||||
npx prisma migrate reset
|
||||
|
||||
# Generate Prisma Client
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### Migration Files
|
||||
|
||||
```sql
|
||||
-- migrations/20240101000000_add_user_table/migration.sql
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"password" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'USER',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
CREATE INDEX "users_createdAt_idx" ON "users"("createdAt");
|
||||
```
|
||||
|
||||
## Query Optimization
|
||||
|
||||
```typescript
|
||||
// Optimized queries
|
||||
export class OptimizedUserRepository {
|
||||
// Select only needed fields
|
||||
async findUsersLight() {
|
||||
return this.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Use pagination
|
||||
async findPaginated(cursor?: string) {
|
||||
return this.prisma.user.findMany({
|
||||
take: 10,
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
// Batch operations
|
||||
async createMany(users: CreateUserDto[]) {
|
||||
return this.prisma.user.createMany({
|
||||
data: users,
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
// Use raw SQL for complex queries
|
||||
async getStatistics() {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN role = 'ADMIN' THEN 1 END) as admins,
|
||||
COUNT(CASE WHEN created_at > NOW() - INTERVAL '30 days' THEN 1 END) as new_users
|
||||
FROM users
|
||||
`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Seeding
|
||||
|
||||
```typescript
|
||||
// prisma/seed.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// Create admin user
|
||||
const adminPassword = await bcrypt.hash('admin123', 10);
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@goodgo.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@goodgo.com',
|
||||
name: 'Admin User',
|
||||
password: adminPassword,
|
||||
role: 'ADMIN'
|
||||
}
|
||||
});
|
||||
|
||||
// Create test users
|
||||
const testUsers = Array.from({ length: 10 }, (_, i) => ({
|
||||
email: `user${i}@example.com`,
|
||||
name: `Test User ${i}`,
|
||||
password: bcrypt.hashSync('password123', 10)
|
||||
}));
|
||||
|
||||
await prisma.user.createMany({
|
||||
data: testUsers,
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
console.log('Database seeded successfully');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
```
|
||||
|
||||
## Neon PostgreSQL Configuration
|
||||
|
||||
```typescript
|
||||
// .env
|
||||
DATABASE_URL="postgresql://user:password@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require"
|
||||
|
||||
// Connection pooling for serverless
|
||||
DIRECT_URL="postgresql://user:password@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require"
|
||||
```
|
||||
|
||||
## Testing with Prisma
|
||||
|
||||
```typescript
|
||||
// __tests__/user.repository.test.ts
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
jest.mock('../src/lib/prisma', () => ({
|
||||
__esModule: true,
|
||||
prisma: mockDeep<PrismaClient>()
|
||||
}));
|
||||
|
||||
describe('UserRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockReset(prismaMock);
|
||||
});
|
||||
|
||||
it('should create user', async () => {
|
||||
const user = { id: '1', email: 'test@example.com' };
|
||||
prismaMock.user.create.mockResolvedValue(user);
|
||||
|
||||
const result = await repository.create({
|
||||
email: 'test@example.com',
|
||||
password: 'password'
|
||||
});
|
||||
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
});
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.update({ where: { id: from }, data: { balance: { decrement: amount } } });
|
||||
await tx.account.update({ where: { id: to }, data: { balance: { increment: amount } } });
|
||||
}, { maxWait: 5000, timeout: 10000 });
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Schema Design**
|
||||
- Use appropriate field types
|
||||
- Add indexes for frequently queried fields
|
||||
- Use relations instead of storing JSON
|
||||
- Implement soft deletes when needed
|
||||
|
||||
2. **Performance**
|
||||
- Use select to fetch only needed fields
|
||||
- Implement pagination for large datasets
|
||||
- Use connection pooling
|
||||
- Cache frequently accessed data
|
||||
|
||||
3. **Security**
|
||||
- Never expose sensitive fields
|
||||
- Use parameterized queries
|
||||
- Validate input before database operations
|
||||
- Implement row-level security
|
||||
|
||||
4. **Maintenance**
|
||||
- Keep migrations small and focused
|
||||
- Test migrations before production
|
||||
- Backup before major changes
|
||||
- Monitor query performance
|
||||
- **Schema Design**: Use appropriate field types, add indexes for frequently queried fields
|
||||
- **Performance**: Use select to fetch only needed fields, implement pagination
|
||||
- **Security**: Never expose sensitive fields, use parameterized queries
|
||||
- **Maintenance**: Keep migrations small and focused, test before production
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **N+1 Query Problem**: Fetching related data in a loop
|
||||
```typescript
|
||||
// ❌ BAD: N+1 queries
|
||||
const users = await prisma.user.findMany();
|
||||
for (const user of users) {
|
||||
const posts = await prisma.post.findMany({ where: { authorId: user.id } });
|
||||
}
|
||||
|
||||
// ✅ GOOD: Include relations
|
||||
const users = await prisma.user.findMany({
|
||||
include: { posts: true }
|
||||
});
|
||||
// BAD: N+1 queries
|
||||
for (const user of users) { await prisma.post.findMany({ where: { authorId: user.id } }); }
|
||||
// GOOD: Include relations
|
||||
await prisma.user.findMany({ include: { posts: true } });
|
||||
```
|
||||
|
||||
2. **No Indexes**: Missing indexes on frequently queried columns
|
||||
```prisma
|
||||
// ❌ BAD: No index
|
||||
model User {
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// ✅ GOOD: Add index for queries
|
||||
model User {
|
||||
email String @unique
|
||||
createdAt DateTime @default(now())
|
||||
@@index([createdAt])
|
||||
}
|
||||
// GOOD: Add index
|
||||
@@index([createdAt])
|
||||
```
|
||||
|
||||
3. **Raw Queries Without Parameters**: SQL injection risk
|
||||
```typescript
|
||||
// ❌ BAD: SQL injection risk
|
||||
prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
|
||||
// ✅ GOOD: Parameterized query
|
||||
prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;
|
||||
// BAD: prisma.$queryRawUnsafe(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
// GOOD: prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;
|
||||
```
|
||||
|
||||
4. **Not Using Transactions**: Data inconsistency risk
|
||||
```typescript
|
||||
// ❌ BAD: No transaction
|
||||
await prisma.account.update({ where: { id: from }, data: { balance: { decrement: amount } } });
|
||||
await prisma.account.update({ where: { id: to }, data: { balance: { increment: amount } } });
|
||||
|
||||
// ✅ GOOD: Use transaction
|
||||
await prisma.$transaction([
|
||||
prisma.account.update({ where: { id: from }, data: { balance: { decrement: amount } } }),
|
||||
prisma.account.update({ where: { id: to }, data: { balance: { increment: amount } } })
|
||||
]);
|
||||
// GOOD: Use transaction for related operations
|
||||
await prisma.$transaction([update1, update2]);
|
||||
```
|
||||
|
||||
5. **Exposing Internal IDs**: Leaking database structure
|
||||
```typescript
|
||||
// ❌ BAD: Exposing auto-increment ID
|
||||
model User { id Int @id @default(autoincrement()) }
|
||||
|
||||
// ✅ GOOD: Use CUID or UUID
|
||||
```prisma
|
||||
// GOOD: Use CUID or UUID
|
||||
model User { id String @id @default(cuid()) }
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Operation | Prisma Command |
|
||||
|-----------|----------------|
|
||||
| Operation | Command |
|
||||
|-----------|---------|
|
||||
| **Create migration** | `npx prisma migrate dev --name <name>` |
|
||||
| **Apply migrations** | `npx prisma migrate deploy` |
|
||||
| **Reset database** | `npx prisma migrate reset` |
|
||||
| **Generate client** | `npx prisma generate` |
|
||||
| **Open Prisma Studio** | `npx prisma studio` |
|
||||
| **Open Studio** | `npx prisma studio` |
|
||||
| **Seed database** | `npx prisma db seed` |
|
||||
|
||||
**Common Query Patterns:**
|
||||
```typescript
|
||||
// Find unique
|
||||
await prisma.user.findUnique({ where: { id } });
|
||||
|
||||
// Find with relations
|
||||
await prisma.user.findMany({ include: { posts: true } });
|
||||
|
||||
// Select specific fields
|
||||
await prisma.user.findMany({ select: { id: true, email: true } });
|
||||
|
||||
// Pagination
|
||||
await prisma.user.findMany({ skip: 10, take: 10 });
|
||||
|
||||
// Transaction
|
||||
await prisma.$transaction(async (tx) => { /* operations */ });
|
||||
await prisma.user.findUnique({ where: { id } }); // Find unique
|
||||
await prisma.user.findMany({ include: { posts: true } }); // With relations
|
||||
await prisma.user.findMany({ select: { id: true } }); // Select fields
|
||||
await prisma.user.findMany({ skip: 10, take: 10 }); // Pagination
|
||||
await prisma.$transaction(async (tx) => { ... }); // Transaction
|
||||
```
|
||||
|
||||
**Schema Field Types:**
|
||||
@@ -584,8 +179,8 @@ await prisma.$transaction(async (tx) => { /* operations */ });
|
||||
## Resources
|
||||
|
||||
- [Prisma Documentation](https://www.prisma.io/docs) - Official Prisma docs
|
||||
- [Prisma Schema Reference](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference) - Schema syntax
|
||||
- [Prisma Schema Reference](https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference)
|
||||
- [Detailed Code Examples](./references/REFERENCE.md)
|
||||
- [Repository Pattern](../repository-pattern/SKILL.md) - Data access patterns
|
||||
- [Caching Patterns](../caching-patterns/SKILL.md) - Query caching
|
||||
- [Testing Patterns](../testing-patterns/SKILL.md) - Database mocking
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo standards
|
||||
403
.cursor/skills/database-prisma/references/REFERENCE.md
Normal file
403
.cursor/skills/database-prisma/references/REFERENCE.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Prisma Database Patterns - Detailed Reference
|
||||
|
||||
This reference contains detailed code examples for Prisma ORM and database patterns.
|
||||
|
||||
## Prisma Schema
|
||||
|
||||
```prisma
|
||||
// prisma/schema.prisma
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// Base model with common fields
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
name String?
|
||||
password String
|
||||
role Role @default(USER)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Relations
|
||||
posts Post[]
|
||||
profile Profile?
|
||||
|
||||
// Indexes for performance
|
||||
@@index([email])
|
||||
@@index([createdAt])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model Post {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
content String?
|
||||
published Boolean @default(false)
|
||||
authorId String
|
||||
author User @relation(fields: [authorId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([authorId])
|
||||
@@index([published, createdAt])
|
||||
@@map("posts")
|
||||
}
|
||||
|
||||
model Profile {
|
||||
id String @id @default(cuid())
|
||||
bio String?
|
||||
avatar String?
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@map("profiles")
|
||||
}
|
||||
|
||||
enum Role {
|
||||
USER
|
||||
ADMIN
|
||||
MODERATOR
|
||||
}
|
||||
```
|
||||
|
||||
## Database Connection
|
||||
|
||||
```typescript
|
||||
// src/lib/prisma.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = global as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development'
|
||||
? ['query', 'error', 'warn']
|
||||
: ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
// Middleware for soft delete
|
||||
prisma.$use(async (params, next) => {
|
||||
if (params.model && params.action === 'delete') {
|
||||
return next({
|
||||
...params,
|
||||
action: 'update',
|
||||
args: {
|
||||
...params.args,
|
||||
data: { deletedAt: new Date() }
|
||||
}
|
||||
});
|
||||
}
|
||||
return next(params);
|
||||
});
|
||||
```
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
```typescript
|
||||
// src/repositories/base.repository.ts
|
||||
export abstract class BaseRepository<T> {
|
||||
constructor(protected prisma: PrismaClient) {}
|
||||
|
||||
abstract findById(id: string): Promise<T | null>;
|
||||
abstract findAll(options?: any): Promise<T[]>;
|
||||
abstract create(data: any): Promise<T>;
|
||||
abstract update(id: string, data: any): Promise<T>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
// src/repositories/user.repository.ts
|
||||
export class UserRepository extends BaseRepository<User> {
|
||||
async findById(id: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { id },
|
||||
include: { profile: true }
|
||||
});
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(options: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sortBy?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
} = {}): Promise<{ data: User[]; total: number }> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 10,
|
||||
search,
|
||||
sortBy = 'createdAt',
|
||||
order = 'desc'
|
||||
} = options;
|
||||
|
||||
const where = search ? {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ name: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
} : {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
where,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
orderBy: { [sortBy]: order },
|
||||
include: { profile: true }
|
||||
}),
|
||||
this.prisma.user.count({ where })
|
||||
]);
|
||||
|
||||
return { data, total };
|
||||
}
|
||||
|
||||
async create(data: CreateUserDto): Promise<User> {
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
name: data.name,
|
||||
profile: data.bio ? {
|
||||
create: { bio: data.bio }
|
||||
} : undefined
|
||||
},
|
||||
include: { profile: true }
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateUserDto): Promise<User> {
|
||||
return this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { profile: true }
|
||||
});
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.prisma.user.delete({
|
||||
where: { id }
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Transactions
|
||||
|
||||
```typescript
|
||||
// Transaction example
|
||||
export class TransferService {
|
||||
async transferFunds(
|
||||
fromAccountId: string,
|
||||
toAccountId: string,
|
||||
amount: number
|
||||
) {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
// Check balance
|
||||
const fromAccount = await tx.account.findUnique({
|
||||
where: { id: fromAccountId }
|
||||
});
|
||||
|
||||
if (!fromAccount || fromAccount.balance < amount) {
|
||||
throw new Error('Insufficient funds');
|
||||
}
|
||||
|
||||
// Deduct from sender
|
||||
const updatedFrom = await tx.account.update({
|
||||
where: { id: fromAccountId },
|
||||
data: { balance: { decrement: amount } }
|
||||
});
|
||||
|
||||
// Add to receiver
|
||||
const updatedTo = await tx.account.update({
|
||||
where: { id: toAccountId },
|
||||
data: { balance: { increment: amount } }
|
||||
});
|
||||
|
||||
// Create transaction record
|
||||
const transaction = await tx.transaction.create({
|
||||
data: {
|
||||
fromAccountId,
|
||||
toAccountId,
|
||||
amount,
|
||||
type: 'TRANSFER',
|
||||
status: 'COMPLETED'
|
||||
}
|
||||
});
|
||||
|
||||
return transaction;
|
||||
}, {
|
||||
maxWait: 5000,
|
||||
timeout: 10000,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query Optimization
|
||||
|
||||
```typescript
|
||||
// Optimized queries
|
||||
export class OptimizedUserRepository {
|
||||
// Select only needed fields
|
||||
async findUsersLight() {
|
||||
return this.prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Use pagination
|
||||
async findPaginated(cursor?: string) {
|
||||
return this.prisma.user.findMany({
|
||||
take: 10,
|
||||
skip: cursor ? 1 : 0,
|
||||
cursor: cursor ? { id: cursor } : undefined,
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
|
||||
// Batch operations
|
||||
async createMany(users: CreateUserDto[]) {
|
||||
return this.prisma.user.createMany({
|
||||
data: users,
|
||||
skipDuplicates: true
|
||||
});
|
||||
}
|
||||
|
||||
// Use raw SQL for complex queries
|
||||
async getStatistics() {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN role = 'ADMIN' THEN 1 END) as admins,
|
||||
COUNT(CASE WHEN created_at > NOW() - INTERVAL '30 days' THEN 1 END) as new_users
|
||||
FROM users
|
||||
`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Seeding
|
||||
|
||||
```typescript
|
||||
// prisma/seed.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
// Create admin user
|
||||
const adminPassword = await bcrypt.hash('admin123', 10);
|
||||
const admin = await prisma.user.upsert({
|
||||
where: { email: 'admin@goodgo.com' },
|
||||
update: {},
|
||||
create: {
|
||||
email: 'admin@goodgo.com',
|
||||
name: 'Admin User',
|
||||
password: adminPassword,
|
||||
role: 'ADMIN'
|
||||
}
|
||||
});
|
||||
|
||||
// Create test users
|
||||
const testUsers = Array.from({ length: 10 }, (_, i) => ({
|
||||
email: `user${i}@example.com`,
|
||||
name: `Test User ${i}`,
|
||||
password: bcrypt.hashSync('password123', 10)
|
||||
}));
|
||||
|
||||
await prisma.user.createMany({
|
||||
data: testUsers,
|
||||
skipDuplicates: true
|
||||
});
|
||||
|
||||
console.log('Database seeded successfully');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
```
|
||||
|
||||
## Testing with Prisma
|
||||
|
||||
```typescript
|
||||
// __tests__/user.repository.test.ts
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
jest.mock('../src/lib/prisma', () => ({
|
||||
__esModule: true,
|
||||
prisma: mockDeep<PrismaClient>()
|
||||
}));
|
||||
|
||||
describe('UserRepository', () => {
|
||||
beforeEach(() => {
|
||||
mockReset(prismaMock);
|
||||
});
|
||||
|
||||
it('should create user', async () => {
|
||||
const user = { id: '1', email: 'test@example.com' };
|
||||
prismaMock.user.create.mockResolvedValue(user);
|
||||
|
||||
const result = await repository.create({
|
||||
email: 'test@example.com',
|
||||
password: 'password'
|
||||
});
|
||||
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Migration Files
|
||||
|
||||
```sql
|
||||
-- migrations/20240101000000_add_user_table/migration.sql
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"password" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'USER',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
CREATE INDEX "users_createdAt_idx" ON "users"("createdAt");
|
||||
```
|
||||
|
||||
## Neon PostgreSQL Configuration
|
||||
|
||||
```typescript
|
||||
// .env
|
||||
DATABASE_URL="postgresql://user:password@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require"
|
||||
|
||||
// Connection pooling for serverless
|
||||
DIRECT_URL="postgresql://user:password@ep-xxx.us-east-1.aws.neon.tech/dbname?sslmode=require"
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: deployment-kubernetes
|
||||
description: Kubernetes deployment for GoodGo services. Use for K8s manifests, HPA, ingress, staging/production deployments, or troubleshooting.
|
||||
dependencies: "kubernetes>=1.28, helm>=3"
|
||||
compatibility: "kubernetes>=1.28, helm>=3"
|
||||
---
|
||||
|
||||
# Kubernetes Deployment Patterns
|
||||
@@ -25,103 +25,48 @@ Use this skill when:
|
||||
- Resource limits and requests for stability
|
||||
- Health checks (liveness/readiness probes)
|
||||
- Horizontal Pod Autoscaler (HPA) for auto-scaling
|
||||
- ConfigMaps for configuration
|
||||
- Secrets for sensitive data
|
||||
- ConfigMaps for configuration, Secrets for sensitive data
|
||||
|
||||
## Service Deployment Manifest
|
||||
## Key Patterns
|
||||
|
||||
### Deployment Manifest
|
||||
|
||||
```yaml
|
||||
# kubernetes/auth-service.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: auth-service
|
||||
namespace: goodgo
|
||||
labels:
|
||||
app: auth-service
|
||||
version: v1
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: auth-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: auth-service
|
||||
version: v1
|
||||
spec:
|
||||
containers:
|
||||
- name: auth-service
|
||||
image: goodgo/auth-service:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
image: goodgo/auth-service:v1.0.0
|
||||
resources:
|
||||
requests: { memory: "256Mi", cpu: "250m" }
|
||||
limits: { memory: "512Mi", cpu: "500m" }
|
||||
livenessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 30
|
||||
readinessProbe:
|
||||
httpGet: { path: /ready, port: 3000 }
|
||||
initialDelaySeconds: 5
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: url
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: auth-secrets
|
||||
key: jwt-secret
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: redis-config
|
||||
key: url
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: auth-service
|
||||
namespace: goodgo
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: auth-service
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
secretKeyRef: { name: db-secrets, key: url }
|
||||
```
|
||||
|
||||
## Horizontal Pod Autoscaler
|
||||
### HPA Configuration
|
||||
|
||||
```yaml
|
||||
# kubernetes/hpa.yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: auth-service-hpa
|
||||
namespace: goodgo
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
@@ -133,86 +78,20 @@ spec:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 50
|
||||
periodSeconds: 60
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 0
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 100
|
||||
periodSeconds: 15
|
||||
target: { type: Utilization, averageUtilization: 70 }
|
||||
```
|
||||
|
||||
## ConfigMap & Secrets
|
||||
### Ingress
|
||||
|
||||
```yaml
|
||||
# kubernetes/configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
namespace: goodgo
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
LOG_LEVEL: "info"
|
||||
REDIS_URL: "redis://redis-service:6379"
|
||||
METRICS_ENABLED: "true"
|
||||
|
||||
---
|
||||
# kubernetes/secrets.yaml (example - use sealed-secrets in production)
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: database-secrets
|
||||
namespace: goodgo
|
||||
type: Opaque
|
||||
stringData:
|
||||
url: "postgresql://user:pass@postgres:5432/db"
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: auth-secrets
|
||||
namespace: goodgo
|
||||
type: Opaque
|
||||
stringData:
|
||||
jwt-secret: "your-secret-key"
|
||||
refresh-secret: "your-refresh-secret"
|
||||
```
|
||||
|
||||
## Ingress Configuration
|
||||
|
||||
```yaml
|
||||
# kubernetes/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: api-ingress
|
||||
namespace: goodgo
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/rate-limit: "100"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- api.goodgo.com
|
||||
- hosts: [api.goodgo.com]
|
||||
secretName: api-tls-secret
|
||||
rules:
|
||||
- host: api.goodgo.com
|
||||
@@ -221,321 +100,47 @@ spec:
|
||||
- path: /auth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: auth-service
|
||||
port:
|
||||
number: 80
|
||||
- path: /users
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: user-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
## Database Deployment (Development Only)
|
||||
|
||||
```yaml
|
||||
# kubernetes/postgres.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: goodgo
|
||||
spec:
|
||||
serviceName: postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: goodgo
|
||||
- name: POSTGRES_USER
|
||||
value: postgres
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-secret
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: postgres-storage
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-storage
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
## Deployment Scripts
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/deploy-k8s.sh
|
||||
|
||||
# Set namespace
|
||||
NAMESPACE="goodgo"
|
||||
ENVIRONMENT="${1:-staging}"
|
||||
|
||||
# Create namespace if not exists
|
||||
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# Apply configurations
|
||||
echo "Applying ConfigMaps..."
|
||||
kubectl apply -f kubernetes/configmap-$ENVIRONMENT.yaml
|
||||
|
||||
echo "Applying Secrets..."
|
||||
kubectl apply -f kubernetes/secrets-$ENVIRONMENT.yaml
|
||||
|
||||
echo "Deploying services..."
|
||||
kubectl apply -f kubernetes/auth-service.yaml
|
||||
kubectl apply -f kubernetes/user-service.yaml
|
||||
|
||||
echo "Configuring autoscaling..."
|
||||
kubectl apply -f kubernetes/hpa.yaml
|
||||
|
||||
echo "Setting up ingress..."
|
||||
kubectl apply -f kubernetes/ingress.yaml
|
||||
|
||||
# Wait for rollout
|
||||
kubectl rollout status deployment/auth-service -n $NAMESPACE
|
||||
kubectl rollout status deployment/user-service -n $NAMESPACE
|
||||
|
||||
echo "Deployment complete!"
|
||||
```
|
||||
|
||||
## Health Check Implementation
|
||||
|
||||
```typescript
|
||||
// src/modules/health/health.controller.ts
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis
|
||||
) {}
|
||||
|
||||
// Liveness probe - is the service alive?
|
||||
async liveness(req: Request, res: Response) {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
}
|
||||
|
||||
// Readiness probe - is the service ready to accept traffic?
|
||||
async readiness(req: Request, res: Response) {
|
||||
try {
|
||||
// Check database connection
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
// Check Redis connection
|
||||
await this.redis.ping();
|
||||
|
||||
res.status(200).json({
|
||||
status: 'ready',
|
||||
checks: {
|
||||
database: 'ok',
|
||||
redis: 'ok'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: 'not ready',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring with Prometheus
|
||||
|
||||
```yaml
|
||||
# kubernetes/servicemonitor.yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: auth-service-monitor
|
||||
namespace: goodgo
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: auth-service
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Deploy to staging
|
||||
kubectl apply -f kubernetes/ -n goodgo-staging
|
||||
|
||||
# Check deployment status
|
||||
kubectl get deployments -n goodgo
|
||||
kubectl get pods -n goodgo
|
||||
kubectl get svc -n goodgo
|
||||
|
||||
# View logs
|
||||
kubectl logs -f deployment/auth-service -n goodgo
|
||||
kubectl logs -f pod-name -n goodgo --tail=100
|
||||
|
||||
# Scale manually
|
||||
kubectl scale deployment auth-service --replicas=5 -n goodgo
|
||||
|
||||
# Update image
|
||||
kubectl set image deployment/auth-service auth-service=goodgo/auth-service:v1.2.3 -n goodgo
|
||||
|
||||
# Rollback
|
||||
kubectl rollout undo deployment/auth-service -n goodgo
|
||||
|
||||
# Port forward for debugging
|
||||
kubectl port-forward service/auth-service 3000:80 -n goodgo
|
||||
|
||||
# Execute command in pod
|
||||
kubectl exec -it pod-name -n goodgo -- /bin/sh
|
||||
|
||||
# View HPA status
|
||||
kubectl get hpa -n goodgo
|
||||
kubectl describe hpa auth-service-hpa -n goodgo
|
||||
|
||||
# View resource usage
|
||||
kubectl top nodes
|
||||
kubectl top pods -n goodgo
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pod Not Starting
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl describe pod pod-name -n goodgo
|
||||
|
||||
# Check events
|
||||
kubectl get events -n goodgo --sort-by='.lastTimestamp'
|
||||
|
||||
# Check logs
|
||||
kubectl logs pod-name -n goodgo --previous
|
||||
```
|
||||
|
||||
### ImagePullBackOff
|
||||
|
||||
```bash
|
||||
# Check image name and tag
|
||||
kubectl describe pod pod-name -n goodgo | grep -i image
|
||||
|
||||
# Check image pull secrets
|
||||
kubectl get secrets -n goodgo
|
||||
```
|
||||
|
||||
### CrashLoopBackOff
|
||||
|
||||
```bash
|
||||
# Check logs of crashed container
|
||||
kubectl logs pod-name -n goodgo --previous
|
||||
|
||||
# Check resource limits
|
||||
kubectl describe pod pod-name -n goodgo | grep -A 5 Limits
|
||||
service: { name: auth-service, port: { number: 80 } }
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Resource Management**
|
||||
- Always set resource requests and limits
|
||||
- Monitor actual usage and adjust accordingly
|
||||
- Use HPA for automatic scaling
|
||||
|
||||
2. **Configuration**
|
||||
- Use ConfigMaps for non-sensitive config
|
||||
- Use Secrets for sensitive data
|
||||
- Never hardcode configuration in images
|
||||
|
||||
3. **Health Checks**
|
||||
- Implement both liveness and readiness probes
|
||||
- Set appropriate timeouts and thresholds
|
||||
- Include dependency checks in readiness probe
|
||||
|
||||
4. **Deployment**
|
||||
- Use rolling updates for zero-downtime
|
||||
- Set maxSurge and maxUnavailable appropriately
|
||||
- Test deployments in staging first
|
||||
|
||||
5. **Security**
|
||||
- Run containers as non-root user
|
||||
- Use network policies to restrict traffic
|
||||
- Regularly update base images
|
||||
- Use sealed-secrets or external secret manager
|
||||
|
||||
6. **Monitoring**
|
||||
- Expose metrics endpoint
|
||||
- Set up alerts for critical issues
|
||||
- Monitor resource usage and performance
|
||||
- **Resource Management**: Always set resource requests and limits, use HPA for scaling
|
||||
- **Configuration**: Use ConfigMaps for config, Secrets for sensitive data
|
||||
- **Health Checks**: Implement both liveness and readiness probes
|
||||
- **Deployment**: Use rolling updates, set maxSurge/maxUnavailable appropriately
|
||||
- **Security**: Run as non-root, use network policies, update base images regularly
|
||||
- **Monitoring**: Expose metrics endpoint, set up alerts
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **No Resource Limits**: Pods consuming all node resources
|
||||
```yaml
|
||||
# ❌ BAD: No limits
|
||||
containers:
|
||||
- name: app
|
||||
image: app:latest
|
||||
|
||||
# ✅ GOOD: Set limits
|
||||
containers:
|
||||
- name: app
|
||||
image: app:latest
|
||||
resources:
|
||||
requests: { memory: "256Mi", cpu: "250m" }
|
||||
limits: { memory: "512Mi", cpu: "500m" }
|
||||
# GOOD: Set limits
|
||||
resources:
|
||||
requests: { memory: "256Mi", cpu: "250m" }
|
||||
limits: { memory: "512Mi", cpu: "500m" }
|
||||
```
|
||||
|
||||
2. **Missing Health Checks**: K8s can't detect unhealthy pods
|
||||
```yaml
|
||||
# ✅ Always add liveness and readiness probes
|
||||
# GOOD: Add probes
|
||||
livenessProbe:
|
||||
httpGet: { path: /health, port: 3000 }
|
||||
initialDelaySeconds: 30
|
||||
readinessProbe:
|
||||
httpGet: { path: /ready, port: 3000 }
|
||||
initialDelaySeconds: 5
|
||||
```
|
||||
|
||||
3. **Hardcoded Secrets**: Exposing sensitive data
|
||||
```yaml
|
||||
# ❌ BAD: Hardcoded
|
||||
env:
|
||||
- name: DB_PASSWORD
|
||||
value: "secret123"
|
||||
|
||||
# ✅ GOOD: Use secrets
|
||||
env:
|
||||
- name: DB_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef: { name: db-secrets, key: password }
|
||||
# BAD: value: "secret123"
|
||||
# GOOD: valueFrom: secretKeyRef: { name: secrets, key: password }
|
||||
```
|
||||
|
||||
4. **Using `latest` Tag**: Unpredictable deployments
|
||||
```yaml
|
||||
# ❌ BAD
|
||||
image: app:latest
|
||||
|
||||
# ✅ GOOD
|
||||
image: app:v1.2.3
|
||||
# BAD: image: app:latest
|
||||
# GOOD: image: app:v1.2.3
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
@@ -574,7 +179,7 @@ readinessProbe:
|
||||
|
||||
- [Kubernetes Documentation](https://kubernetes.io/docs/) - Official K8s docs
|
||||
- [Helm](https://helm.sh/docs/) - K8s package manager
|
||||
- [Detailed Manifests](./references/REFERENCE.md)
|
||||
- [Infrastructure as Code](../infrastructure-as-code/SKILL.md) - Terraform patterns
|
||||
- [Observability & Monitoring](../observability-monitoring/SKILL.md) - Health checks
|
||||
- [Service Discovery](../service-discovery-registry/SKILL.md) - K8s DNS patterns
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo standards
|
||||
428
.cursor/skills/deployment-kubernetes/references/REFERENCE.md
Normal file
428
.cursor/skills/deployment-kubernetes/references/REFERENCE.md
Normal file
@@ -0,0 +1,428 @@
|
||||
# Kubernetes Deployment - Detailed Reference
|
||||
|
||||
This reference contains detailed Kubernetes manifests and deployment patterns.
|
||||
|
||||
## Service Deployment Manifest
|
||||
|
||||
```yaml
|
||||
# kubernetes/auth-service.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: auth-service
|
||||
namespace: goodgo
|
||||
labels:
|
||||
app: auth-service
|
||||
version: v1
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: auth-service
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: auth-service
|
||||
version: v1
|
||||
spec:
|
||||
containers:
|
||||
- name: auth-service
|
||||
image: goodgo/auth-service:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
name: http
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "production"
|
||||
- name: PORT
|
||||
value: "3000"
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: database-secrets
|
||||
key: url
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: auth-secrets
|
||||
key: jwt-secret
|
||||
- name: REDIS_URL
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: redis-config
|
||||
key: url
|
||||
resources:
|
||||
requests:
|
||||
memory: "256Mi"
|
||||
cpu: "250m"
|
||||
limits:
|
||||
memory: "512Mi"
|
||||
cpu: "500m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 3000
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /ready
|
||||
port: 3000
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: auth-service
|
||||
namespace: goodgo
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app: auth-service
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 3000
|
||||
protocol: TCP
|
||||
```
|
||||
|
||||
## Horizontal Pod Autoscaler
|
||||
|
||||
```yaml
|
||||
# kubernetes/hpa.yaml
|
||||
apiVersion: autoscaling/v2
|
||||
kind: HorizontalPodAutoscaler
|
||||
metadata:
|
||||
name: auth-service-hpa
|
||||
namespace: goodgo
|
||||
spec:
|
||||
scaleTargetRef:
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
name: auth-service
|
||||
minReplicas: 2
|
||||
maxReplicas: 10
|
||||
metrics:
|
||||
- type: Resource
|
||||
resource:
|
||||
name: cpu
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 70
|
||||
- type: Resource
|
||||
resource:
|
||||
name: memory
|
||||
target:
|
||||
type: Utilization
|
||||
averageUtilization: 80
|
||||
behavior:
|
||||
scaleDown:
|
||||
stabilizationWindowSeconds: 300
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 50
|
||||
periodSeconds: 60
|
||||
scaleUp:
|
||||
stabilizationWindowSeconds: 0
|
||||
policies:
|
||||
- type: Percent
|
||||
value: 100
|
||||
periodSeconds: 15
|
||||
```
|
||||
|
||||
## ConfigMap & Secrets
|
||||
|
||||
```yaml
|
||||
# kubernetes/configmap.yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: app-config
|
||||
namespace: goodgo
|
||||
data:
|
||||
NODE_ENV: "production"
|
||||
LOG_LEVEL: "info"
|
||||
REDIS_URL: "redis://redis-service:6379"
|
||||
METRICS_ENABLED: "true"
|
||||
|
||||
---
|
||||
# kubernetes/secrets.yaml (example - use sealed-secrets in production)
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: database-secrets
|
||||
namespace: goodgo
|
||||
type: Opaque
|
||||
stringData:
|
||||
url: "postgresql://user:pass@postgres:5432/db"
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: auth-secrets
|
||||
namespace: goodgo
|
||||
type: Opaque
|
||||
stringData:
|
||||
jwt-secret: "your-secret-key"
|
||||
refresh-secret: "your-refresh-secret"
|
||||
```
|
||||
|
||||
## Ingress Configuration
|
||||
|
||||
```yaml
|
||||
# kubernetes/ingress.yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: api-ingress
|
||||
namespace: goodgo
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: nginx
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
nginx.ingress.kubernetes.io/rate-limit: "100"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- api.goodgo.com
|
||||
secretName: api-tls-secret
|
||||
rules:
|
||||
- host: api.goodgo.com
|
||||
http:
|
||||
paths:
|
||||
- path: /auth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: auth-service
|
||||
port:
|
||||
number: 80
|
||||
- path: /users
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: user-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
## Database Deployment (Development Only)
|
||||
|
||||
```yaml
|
||||
# kubernetes/postgres.yaml
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: goodgo
|
||||
spec:
|
||||
serviceName: postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:14-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
env:
|
||||
- name: POSTGRES_DB
|
||||
value: goodgo
|
||||
- name: POSTGRES_USER
|
||||
value: postgres
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-secret
|
||||
key: password
|
||||
volumeMounts:
|
||||
- name: postgres-storage
|
||||
mountPath: /var/lib/postgresql/data
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: postgres-storage
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
```
|
||||
|
||||
## Deployment Scripts
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# scripts/deploy-k8s.sh
|
||||
|
||||
# Set namespace
|
||||
NAMESPACE="goodgo"
|
||||
ENVIRONMENT="${1:-staging}"
|
||||
|
||||
# Create namespace if not exists
|
||||
kubectl create namespace $NAMESPACE --dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
# Apply configurations
|
||||
echo "Applying ConfigMaps..."
|
||||
kubectl apply -f kubernetes/configmap-$ENVIRONMENT.yaml
|
||||
|
||||
echo "Applying Secrets..."
|
||||
kubectl apply -f kubernetes/secrets-$ENVIRONMENT.yaml
|
||||
|
||||
echo "Deploying services..."
|
||||
kubectl apply -f kubernetes/auth-service.yaml
|
||||
kubectl apply -f kubernetes/user-service.yaml
|
||||
|
||||
echo "Configuring autoscaling..."
|
||||
kubectl apply -f kubernetes/hpa.yaml
|
||||
|
||||
echo "Setting up ingress..."
|
||||
kubectl apply -f kubernetes/ingress.yaml
|
||||
|
||||
# Wait for rollout
|
||||
kubectl rollout status deployment/auth-service -n $NAMESPACE
|
||||
kubectl rollout status deployment/user-service -n $NAMESPACE
|
||||
|
||||
echo "Deployment complete!"
|
||||
```
|
||||
|
||||
## Health Check Implementation
|
||||
|
||||
```typescript
|
||||
// src/modules/health/health.controller.ts
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis
|
||||
) {}
|
||||
|
||||
// Liveness probe - is the service alive?
|
||||
async liveness(req: Request, res: Response) {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
}
|
||||
|
||||
// Readiness probe - is the service ready to accept traffic?
|
||||
async readiness(req: Request, res: Response) {
|
||||
try {
|
||||
// Check database connection
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
// Check Redis connection
|
||||
await this.redis.ping();
|
||||
|
||||
res.status(200).json({
|
||||
status: 'ready',
|
||||
checks: {
|
||||
database: 'ok',
|
||||
redis: 'ok'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: 'not ready',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Monitoring with Prometheus
|
||||
|
||||
```yaml
|
||||
# kubernetes/servicemonitor.yaml
|
||||
apiVersion: monitoring.coreos.com/v1
|
||||
kind: ServiceMonitor
|
||||
metadata:
|
||||
name: auth-service-monitor
|
||||
namespace: goodgo
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: auth-service
|
||||
endpoints:
|
||||
- port: http
|
||||
path: /metrics
|
||||
interval: 30s
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Deploy to staging
|
||||
kubectl apply -f kubernetes/ -n goodgo-staging
|
||||
|
||||
# Check deployment status
|
||||
kubectl get deployments -n goodgo
|
||||
kubectl get pods -n goodgo
|
||||
kubectl get svc -n goodgo
|
||||
|
||||
# View logs
|
||||
kubectl logs -f deployment/auth-service -n goodgo
|
||||
kubectl logs -f pod-name -n goodgo --tail=100
|
||||
|
||||
# Scale manually
|
||||
kubectl scale deployment auth-service --replicas=5 -n goodgo
|
||||
|
||||
# Update image
|
||||
kubectl set image deployment/auth-service auth-service=goodgo/auth-service:v1.2.3 -n goodgo
|
||||
|
||||
# Rollback
|
||||
kubectl rollout undo deployment/auth-service -n goodgo
|
||||
|
||||
# Port forward for debugging
|
||||
kubectl port-forward service/auth-service 3000:80 -n goodgo
|
||||
|
||||
# Execute command in pod
|
||||
kubectl exec -it pod-name -n goodgo -- /bin/sh
|
||||
|
||||
# View HPA status
|
||||
kubectl get hpa -n goodgo
|
||||
kubectl describe hpa auth-service-hpa -n goodgo
|
||||
|
||||
# View resource usage
|
||||
kubectl top nodes
|
||||
kubectl top pods -n goodgo
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pod Not Starting
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl describe pod pod-name -n goodgo
|
||||
|
||||
# Check events
|
||||
kubectl get events -n goodgo --sort-by='.lastTimestamp'
|
||||
|
||||
# Check logs
|
||||
kubectl logs pod-name -n goodgo --previous
|
||||
```
|
||||
|
||||
### ImagePullBackOff
|
||||
|
||||
```bash
|
||||
# Check image name and tag
|
||||
kubectl describe pod pod-name -n goodgo | grep -i image
|
||||
|
||||
# Check image pull secrets
|
||||
kubectl get secrets -n goodgo
|
||||
```
|
||||
|
||||
### CrashLoopBackOff
|
||||
|
||||
```bash
|
||||
# Check logs of crashed container
|
||||
kubectl logs pod-name -n goodgo --previous
|
||||
|
||||
# Check resource limits
|
||||
kubectl describe pod pod-name -n goodgo | grep -A 5 Limits
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
1207
.cursor/skills/event-driven-architecture/references/REFERENCE.md
Normal file
1207
.cursor/skills/event-driven-architecture/references/REFERENCE.md
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,891 @@
|
||||
# Inter-Service Communication - Detailed Reference
|
||||
|
||||
This document contains detailed code examples and implementation patterns for inter-service communication.
|
||||
|
||||
## HTTP/REST Service Client
|
||||
|
||||
### Service Client Setup
|
||||
|
||||
```typescript
|
||||
// src/core/clients/service-client.ts
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import { logger } from '@goodgo/logger';
|
||||
import { createCircuitBreaker } from '../resilience/circuit-breaker';
|
||||
|
||||
export interface ServiceClientConfig {
|
||||
baseURL: string;
|
||||
serviceName: string;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
enableCircuitBreaker?: boolean;
|
||||
}
|
||||
|
||||
export class ServiceClient {
|
||||
private client: AxiosInstance;
|
||||
private serviceName: string;
|
||||
private circuitBreaker?: ReturnType<typeof createCircuitBreaker>;
|
||||
|
||||
constructor(config: ServiceClientConfig) {
|
||||
this.serviceName = config.serviceName;
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
timeout: config.timeout || 5000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': `${process.env.SERVICE_NAME || 'unknown'}/1.0`,
|
||||
},
|
||||
});
|
||||
|
||||
this.setupInterceptors();
|
||||
|
||||
if (config.enableCircuitBreaker !== false) {
|
||||
this.circuitBreaker = createCircuitBreaker(
|
||||
async (requestConfig: AxiosRequestConfig) => {
|
||||
return await this.client.request(requestConfig);
|
||||
},
|
||||
`service-client-${config.serviceName}`,
|
||||
{
|
||||
timeout: config.timeout || 5000,
|
||||
errorThresholdPercentage: 50,
|
||||
resetTimeout: 30000,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private setupInterceptors(): void {
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (process.env.INTERNAL_API_KEY) {
|
||||
config.headers['X-Service-Auth'] = process.env.INTERNAL_API_KEY;
|
||||
}
|
||||
|
||||
const correlationId = config.headers['x-correlation-id'] || this.generateCorrelationId();
|
||||
config.headers['x-correlation-id'] = correlationId;
|
||||
|
||||
logger.debug('Service request', {
|
||||
service: this.serviceName,
|
||||
method: config.method,
|
||||
url: config.url,
|
||||
correlationId,
|
||||
});
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
logger.error('Service request error', { service: this.serviceName, error });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.debug('Service response', {
|
||||
service: this.serviceName,
|
||||
status: response.status,
|
||||
url: response.config.url,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
if (error.response) {
|
||||
logger.warn('Service error response', {
|
||||
service: this.serviceName,
|
||||
status: error.response.status,
|
||||
url: error.config?.url,
|
||||
data: error.response.data,
|
||||
});
|
||||
} else if (error.request) {
|
||||
logger.error('Service request timeout', {
|
||||
service: this.serviceName,
|
||||
url: error.config?.url,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private generateCorrelationId(): string {
|
||||
return `corr_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
async request<T = any>(config: AxiosRequestConfig): Promise<T> {
|
||||
try {
|
||||
const response = this.circuitBreaker
|
||||
? await this.circuitBreaker.fire(config)
|
||||
: await this.client.request(config);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Service request failed', {
|
||||
service: this.serviceName,
|
||||
url: config.url,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.request<T>({ ...config, method: 'GET', url });
|
||||
}
|
||||
|
||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.request<T>({ ...config, method: 'POST', url, data });
|
||||
}
|
||||
|
||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.request<T>({ ...config, method: 'PUT', url, data });
|
||||
}
|
||||
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||
return this.request<T>({ ...config, method: 'DELETE', url });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
// src/modules/notification/notification-client.service.ts
|
||||
import { ServiceClient } from '../../core/clients/service-client';
|
||||
|
||||
const notificationClient = new ServiceClient({
|
||||
baseURL: process.env.NOTIFICATION_SERVICE_URL || 'http://notification-service:5003',
|
||||
serviceName: 'notification-service',
|
||||
timeout: 5000,
|
||||
enableCircuitBreaker: true,
|
||||
});
|
||||
|
||||
export class NotificationClientService {
|
||||
async sendNotification(userId: string, message: string): Promise<void> {
|
||||
try {
|
||||
await notificationClient.post('/api/v1/notifications', {
|
||||
userId,
|
||||
message,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', { userId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## gRPC Communication
|
||||
|
||||
### Protocol Buffer Definitions
|
||||
|
||||
```protobuf
|
||||
// proto/user_service.proto
|
||||
syntax = "proto3";
|
||||
|
||||
package goodgo.user.v1;
|
||||
|
||||
option go_package = "github.com/goodgo/user-service/proto";
|
||||
|
||||
service UserService {
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
|
||||
rpc StreamUserUpdates(StreamUserUpdatesRequest) returns (stream UserUpdate);
|
||||
}
|
||||
|
||||
message GetUserRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message GetUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message CreateUserRequest {
|
||||
string email = 1;
|
||||
string name = 2;
|
||||
}
|
||||
|
||||
message CreateUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message User {
|
||||
string id = 1;
|
||||
string email = 2;
|
||||
string name = 3;
|
||||
int64 created_at = 4;
|
||||
}
|
||||
|
||||
message StreamUserUpdatesRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message UserUpdate {
|
||||
string user_id = 1;
|
||||
string action = 2;
|
||||
User user = 3;
|
||||
int64 timestamp = 4;
|
||||
}
|
||||
```
|
||||
|
||||
### gRPC Server Implementation
|
||||
|
||||
```typescript
|
||||
// src/modules/user/user.grpc.service.ts
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import * as protoLoader from '@grpc/proto-loader';
|
||||
import { UserService } from './user.service';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
const PROTO_PATH = './proto/user_service.proto';
|
||||
|
||||
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
});
|
||||
|
||||
const userProto = grpc.loadPackageDefinition(packageDefinition).goodgo?.user?.v1;
|
||||
|
||||
export class UserGrpcServer {
|
||||
private server: grpc.Server;
|
||||
private userService: UserService;
|
||||
|
||||
constructor(userService: UserService) {
|
||||
this.userService = userService;
|
||||
this.server = new grpc.Server();
|
||||
this.setupServices();
|
||||
}
|
||||
|
||||
private setupServices(): void {
|
||||
if (!userProto?.UserService) {
|
||||
throw new Error('UserService proto not loaded');
|
||||
}
|
||||
|
||||
this.server.addService(userProto.UserService.service, {
|
||||
getUser: this.getUser.bind(this),
|
||||
createUser: this.createUser.bind(this),
|
||||
streamUserUpdates: this.streamUserUpdates.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
private async getUser(
|
||||
call: grpc.ServerUnaryCall<any, any>,
|
||||
callback: grpc.sendUnaryData<any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { user_id } = call.request;
|
||||
const user = await this.userService.findById(user_id);
|
||||
|
||||
if (!user) {
|
||||
return callback({
|
||||
code: grpc.status.NOT_FOUND,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
created_at: user.createdAt.getTime(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('gRPC getUser error', { error });
|
||||
callback({
|
||||
code: grpc.status.INTERNAL,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async createUser(
|
||||
call: grpc.ServerUnaryCall<any, any>,
|
||||
callback: grpc.sendUnaryData<any>
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { email, name } = call.request;
|
||||
const user = await this.userService.create({ email, name });
|
||||
|
||||
callback(null, {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
created_at: user.createdAt.getTime(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('gRPC createUser error', { error });
|
||||
callback({
|
||||
code: grpc.status.INTERNAL,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private streamUserUpdates(call: grpc.ServerWritableStream<any, any>): void {
|
||||
const { user_id } = call.request;
|
||||
|
||||
const subscription = this.userService.subscribeToUpdates(user_id, (update) => {
|
||||
call.write({
|
||||
user_id: update.userId,
|
||||
action: update.action,
|
||||
user: update.user,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
call.on('cancelled', () => {
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
start(port: number): void {
|
||||
this.server.bindAsync(
|
||||
`0.0.0.0:${port}`,
|
||||
grpc.ServerCredentials.createInsecure(),
|
||||
(error, port) => {
|
||||
if (error) {
|
||||
logger.error('Failed to start gRPC server', { error });
|
||||
return;
|
||||
}
|
||||
|
||||
this.server.start();
|
||||
logger.info('gRPC server started', { port });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
stop(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.server.tryShutdown(() => {
|
||||
logger.info('gRPC server stopped');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### gRPC Client Implementation
|
||||
|
||||
```typescript
|
||||
// src/core/clients/grpc-client.ts
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import * as protoLoader from '@grpc/proto-loader';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export interface GrpcClientConfig {
|
||||
protoPath: string;
|
||||
packageName: string;
|
||||
serviceName: string;
|
||||
serverUrl: string;
|
||||
options?: protoLoader.Options;
|
||||
}
|
||||
|
||||
export class GrpcClient {
|
||||
private client: any;
|
||||
private serviceName: string;
|
||||
|
||||
constructor(config: GrpcClientConfig) {
|
||||
this.serviceName = config.serviceName;
|
||||
|
||||
const packageDefinition = protoLoader.loadSync(config.protoPath, {
|
||||
keepCase: true,
|
||||
longs: String,
|
||||
enums: String,
|
||||
defaults: true,
|
||||
oneofs: true,
|
||||
...config.options,
|
||||
});
|
||||
|
||||
const proto = grpc.loadPackageDefinition(packageDefinition);
|
||||
const service = this.getNestedProperty(proto, config.packageName);
|
||||
|
||||
if (!service?.[config.serviceName]) {
|
||||
throw new Error(`Service ${config.serviceName} not found in proto`);
|
||||
}
|
||||
|
||||
this.client = new service[config.serviceName](
|
||||
config.serverUrl,
|
||||
grpc.credentials.createInsecure()
|
||||
);
|
||||
}
|
||||
|
||||
private getNestedProperty(obj: any, path: string): any {
|
||||
return path.split('.').reduce((current, prop) => current?.[prop], obj);
|
||||
}
|
||||
|
||||
async call<TRequest, TResponse>(
|
||||
methodName: string,
|
||||
request: TRequest,
|
||||
metadata?: grpc.Metadata
|
||||
): Promise<TResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const callMetadata = metadata || new grpc.Metadata();
|
||||
if (process.env.INTERNAL_API_KEY) {
|
||||
callMetadata.add('x-service-auth', process.env.INTERNAL_API_KEY);
|
||||
}
|
||||
|
||||
this.client[methodName](request, callMetadata, (error: any, response: TResponse) => {
|
||||
if (error) {
|
||||
logger.error('gRPC call error', {
|
||||
service: this.serviceName,
|
||||
method: methodName,
|
||||
error: error.message,
|
||||
});
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(response);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createStream<TRequest, TResponse>(
|
||||
methodName: string,
|
||||
request: TRequest,
|
||||
metadata?: grpc.Metadata
|
||||
): grpc.ClientReadableStream<TResponse> {
|
||||
const callMetadata = metadata || new grpc.Metadata();
|
||||
if (process.env.INTERNAL_API_KEY) {
|
||||
callMetadata.add('x-service-auth', process.env.INTERNAL_API_KEY);
|
||||
}
|
||||
|
||||
return this.client[methodName](request, callMetadata);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### gRPC Client Usage
|
||||
|
||||
```typescript
|
||||
// src/modules/user/user.grpc.client.ts
|
||||
import { GrpcClient } from '../../core/clients/grpc-client';
|
||||
|
||||
const userGrpcClient = new GrpcClient({
|
||||
protoPath: './proto/user_service.proto',
|
||||
packageName: 'goodgo.user.v1',
|
||||
serviceName: 'UserService',
|
||||
serverUrl: process.env.USER_SERVICE_GRPC_URL || 'localhost:50051',
|
||||
});
|
||||
|
||||
export class UserGrpcClientService {
|
||||
async getUser(userId: string) {
|
||||
return userGrpcClient.call('getUser', { user_id: userId });
|
||||
}
|
||||
|
||||
async createUser(email: string, name: string) {
|
||||
return userGrpcClient.call('createUser', { email, name });
|
||||
}
|
||||
|
||||
streamUserUpdates(userId: string, callback: (update: any) => void) {
|
||||
const stream = userGrpcClient.createStream('streamUserUpdates', {
|
||||
user_id: userId,
|
||||
});
|
||||
|
||||
stream.on('data', callback);
|
||||
stream.on('error', (error) => {
|
||||
logger.error('gRPC stream error', { error });
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## GraphQL Communication
|
||||
|
||||
### GraphQL Schema Definition
|
||||
|
||||
```graphql
|
||||
# schema/user.graphql
|
||||
type Query {
|
||||
user(id: ID!): User
|
||||
users(filter: UserFilter, pagination: Pagination): UserConnection
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
createUser(input: CreateUserInput!): User!
|
||||
updateUser(id: ID!, input: UpdateUserInput!): User!
|
||||
deleteUser(id: ID!): Boolean!
|
||||
}
|
||||
|
||||
type User {
|
||||
id: ID!
|
||||
email: String!
|
||||
name: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
input CreateUserInput {
|
||||
email: String!
|
||||
name: String
|
||||
}
|
||||
|
||||
input UpdateUserInput {
|
||||
name: String
|
||||
}
|
||||
|
||||
input UserFilter {
|
||||
email: String
|
||||
name: String
|
||||
}
|
||||
|
||||
input Pagination {
|
||||
skip: Int
|
||||
take: Int
|
||||
}
|
||||
|
||||
type UserConnection {
|
||||
nodes: [User!]!
|
||||
total: Int!
|
||||
hasNextPage: Boolean!
|
||||
}
|
||||
|
||||
scalar DateTime
|
||||
```
|
||||
|
||||
### GraphQL Server (Apollo)
|
||||
|
||||
```typescript
|
||||
// src/modules/user/user.graphql.service.ts
|
||||
import { ApolloServer } from '@apollo/server';
|
||||
import { expressMiddleware } from '@apollo/server/express4';
|
||||
import { UserService } from './user.service';
|
||||
import { logger } from '@goodgo/logger';
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolvers } from './user.resolvers';
|
||||
|
||||
const typeDefs = readFileSync('./schema/user.graphql', 'utf-8');
|
||||
|
||||
export function createGraphQLServer(userService: UserService): ApolloServer {
|
||||
return new ApolloServer({
|
||||
typeDefs,
|
||||
resolvers: resolvers(userService),
|
||||
plugins: [
|
||||
{
|
||||
requestDidStart() {
|
||||
return {
|
||||
didResolveOperation(requestContext) {
|
||||
logger.debug('GraphQL operation', {
|
||||
operation: requestContext.operationName,
|
||||
query: requestContext.request.query,
|
||||
});
|
||||
},
|
||||
didEncounterErrors(requestContext) {
|
||||
logger.error('GraphQL errors', {
|
||||
errors: requestContext.errors,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// src/modules/user/user.resolvers.ts
|
||||
export function resolvers(userService: UserService) {
|
||||
return {
|
||||
Query: {
|
||||
user: async (_: any, { id }: { id: string }) => {
|
||||
return await userService.findById(id);
|
||||
},
|
||||
users: async (
|
||||
_: any,
|
||||
{ filter, pagination }: { filter?: any; pagination?: any }
|
||||
) => {
|
||||
const result = await userService.findAll({ filter, pagination });
|
||||
return {
|
||||
nodes: result.users,
|
||||
total: result.total,
|
||||
hasNextPage: result.users.length === (pagination?.take || 10),
|
||||
};
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
createUser: async (_: any, { input }: { input: any }) => {
|
||||
return await userService.create(input);
|
||||
},
|
||||
updateUser: async (_: any, { id, input }: { id: string; input: any }) => {
|
||||
return await userService.update(id, input);
|
||||
},
|
||||
deleteUser: async (_: any, { id }: { id: string }) => {
|
||||
await userService.delete(id);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### GraphQL Client
|
||||
|
||||
```typescript
|
||||
// src/core/clients/graphql-client.ts
|
||||
import { GraphQLClient } from 'graphql-request';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export interface GraphQLClientConfig {
|
||||
endpoint: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export class GraphQLServiceClient {
|
||||
private client: GraphQLClient;
|
||||
private endpoint: string;
|
||||
|
||||
constructor(config: GraphQLClientConfig) {
|
||||
this.endpoint = config.endpoint;
|
||||
this.client = new GraphQLClient(config.endpoint, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(process.env.INTERNAL_API_KEY && {
|
||||
'x-service-auth': process.env.INTERNAL_API_KEY,
|
||||
}),
|
||||
...config.headers,
|
||||
},
|
||||
timeout: config.timeout || 5000,
|
||||
});
|
||||
}
|
||||
|
||||
async query<T = any>(query: string, variables?: any): Promise<T> {
|
||||
try {
|
||||
const data = await this.client.request<T>(query, variables);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('GraphQL query error', {
|
||||
endpoint: this.endpoint,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async mutate<T = any>(mutation: string, variables?: any): Promise<T> {
|
||||
try {
|
||||
const data = await this.client.request<T>(mutation, variables);
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('GraphQL mutation error', {
|
||||
endpoint: this.endpoint,
|
||||
error: error.message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage example
|
||||
const userGraphQLClient = new GraphQLServiceClient({
|
||||
endpoint: process.env.USER_SERVICE_GRAPHQL_URL || 'http://user-service:5002/graphql',
|
||||
});
|
||||
|
||||
const GET_USER_QUERY = `
|
||||
query GetUser($id: ID!) {
|
||||
user(id: $id) {
|
||||
id
|
||||
email
|
||||
name
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export async function getUser(userId: string) {
|
||||
return userGraphQLClient.query(GET_USER_QUERY, { id: userId });
|
||||
}
|
||||
```
|
||||
|
||||
## Service-to-Service Authentication
|
||||
|
||||
### Internal Authentication Middleware
|
||||
|
||||
```typescript
|
||||
// src/middlewares/internal-auth.middleware.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export const internalAuthMiddleware = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void => {
|
||||
const serviceAuthToken = req.headers['x-service-auth'];
|
||||
|
||||
if (!serviceAuthToken) {
|
||||
logger.warn('Missing service auth token', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
});
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_AUTH_REQUIRED',
|
||||
message: 'Service authentication required',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (serviceAuthToken !== process.env.INTERNAL_API_KEY) {
|
||||
logger.warn('Invalid service auth token', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
});
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_SERVICE_AUTH',
|
||||
message: 'Invalid service authentication',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
req.serviceContext = {
|
||||
authenticated: true,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
```
|
||||
|
||||
### Mutual TLS (mTLS) for gRPC
|
||||
|
||||
```typescript
|
||||
// src/config/grpc-tls.config.ts
|
||||
import * as grpc from '@grpc/grpc-js';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
export function createServerCredentials(): grpc.ServerCredentials {
|
||||
return grpc.ServerCredentials.createSsl(
|
||||
null,
|
||||
[
|
||||
{
|
||||
cert_chain: readFileSync(process.env.GRPC_SERVER_CERT_PATH!),
|
||||
private_key: readFileSync(process.env.GRPC_SERVER_KEY_PATH!),
|
||||
},
|
||||
],
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
export function createClientCredentials(): grpc.ChannelCredentials {
|
||||
return grpc.credentials.createSsl(
|
||||
readFileSync(process.env.GRPC_CA_CERT_PATH!),
|
||||
readFileSync(process.env.GRPC_CLIENT_KEY_PATH!),
|
||||
readFileSync(process.env.GRPC_CLIENT_CERT_PATH!)
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Connection Pooling
|
||||
|
||||
### HTTP Connection Pool
|
||||
|
||||
```typescript
|
||||
// src/core/clients/connection-pool.ts
|
||||
import { ServiceClient } from './service-client';
|
||||
|
||||
export class ServiceClientPool {
|
||||
private pools: Map<string, ServiceClient> = new Map();
|
||||
|
||||
getClient(serviceName: string, config: ServiceClientConfig): ServiceClient {
|
||||
if (!this.pools.has(serviceName)) {
|
||||
this.pools.set(serviceName, new ServiceClient(config));
|
||||
}
|
||||
return this.pools.get(serviceName)!;
|
||||
}
|
||||
|
||||
clearPool(serviceName: string): void {
|
||||
this.pools.delete(serviceName);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
this.pools.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const serviceClientPool = new ServiceClientPool();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Service Error Classes
|
||||
|
||||
```typescript
|
||||
// src/core/errors/service-errors.ts
|
||||
export class ServiceUnavailableError extends Error {
|
||||
constructor(public serviceName: string, message?: string) {
|
||||
super(message || `Service ${serviceName} is unavailable`);
|
||||
this.name = 'ServiceUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceTimeoutError extends Error {
|
||||
constructor(public serviceName: string, public timeout: number) {
|
||||
super(`Service ${serviceName} request timed out after ${timeout}ms`);
|
||||
this.name = 'ServiceTimeoutError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceError extends Error {
|
||||
constructor(
|
||||
public serviceName: string,
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ServiceError';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Mocking Service Clients
|
||||
|
||||
```typescript
|
||||
// src/__tests__/mocks/service-client.mock.ts
|
||||
export const createMockServiceClient = () => {
|
||||
return {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
request: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
// Usage in tests
|
||||
const mockUserClient = createMockServiceClient();
|
||||
mockUserClient.get.mockResolvedValue({ id: '123', email: 'test@example.com' });
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
```typescript
|
||||
// src/__tests__/integration/service-communication.e2e.ts
|
||||
describe('Service Communication E2E', () => {
|
||||
it('should communicate with user service via HTTP', async () => {
|
||||
const client = new ServiceClient({
|
||||
baseURL: process.env.USER_SERVICE_URL || 'http://localhost:5002',
|
||||
serviceName: 'user-service',
|
||||
});
|
||||
|
||||
const user = await client.get('/api/v1/users/123');
|
||||
expect(user).toHaveProperty('id');
|
||||
});
|
||||
});
|
||||
```
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: observability-monitoring
|
||||
description: Observability and monitoring for GoodGo services. Use for metrics, logging, tracing, health checks, or production debugging.
|
||||
dependencies: "prom-client>=15, winston>=3, @opentelemetry/sdk-node"
|
||||
compatibility: "prom-client>=15, winston>=3, @opentelemetry/sdk-node"
|
||||
---
|
||||
|
||||
# Observability & Monitoring Patterns
|
||||
@@ -31,76 +31,41 @@ Use this skill when:
|
||||
- **Tracing**: OpenTelemetry + Jaeger
|
||||
- **APM**: DataDog or New Relic (optional)
|
||||
|
||||
## Structured Logging
|
||||
## Key Patterns
|
||||
|
||||
### Structured Logging
|
||||
|
||||
```typescript
|
||||
// src/lib/logger.ts
|
||||
import winston from 'winston';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: {
|
||||
service: process.env.SERVICE_NAME || 'unknown',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
},
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: process.env.NODE_ENV === 'development'
|
||||
? winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
: logFormat
|
||||
}),
|
||||
// Production: Send to log aggregation service
|
||||
...(process.env.NODE_ENV === 'production'
|
||||
? [new winston.transports.Http({
|
||||
host: 'logs.example.com',
|
||||
path: '/collect',
|
||||
ssl: true
|
||||
})]
|
||||
: [])
|
||||
]
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: process.env.SERVICE_NAME }
|
||||
});
|
||||
|
||||
// Request logger middleware
|
||||
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
// Request logging middleware
|
||||
export const requestLogger = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
method: req.method, url: req.url,
|
||||
status: res.statusCode, duration: Date.now() - start,
|
||||
correlationId: req.headers['x-correlation-id']
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
```
|
||||
|
||||
## Metrics Collection
|
||||
### Metrics Collection
|
||||
|
||||
```typescript
|
||||
// src/lib/metrics.ts
|
||||
import { Registry, Counter, Histogram, Gauge } from 'prom-client';
|
||||
import { Counter, Histogram, Gauge } from 'prom-client';
|
||||
|
||||
export const register = new Registry();
|
||||
|
||||
// HTTP metrics
|
||||
export const httpRequestDuration = new Histogram({
|
||||
name: 'http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
@@ -113,104 +78,22 @@ export const httpRequestTotal = new Counter({
|
||||
help: 'Total number of HTTP requests',
|
||||
labelNames: ['method', 'route', 'status']
|
||||
});
|
||||
|
||||
// Business metrics
|
||||
export const userRegistrations = new Counter({
|
||||
name: 'user_registrations_total',
|
||||
help: 'Total number of user registrations',
|
||||
labelNames: ['type']
|
||||
});
|
||||
|
||||
export const activeUsers = new Gauge({
|
||||
name: 'active_users',
|
||||
help: 'Number of active users',
|
||||
labelNames: ['status']
|
||||
});
|
||||
|
||||
// Register metrics
|
||||
register.registerMetric(httpRequestDuration);
|
||||
register.registerMetric(httpRequestTotal);
|
||||
register.registerMetric(userRegistrations);
|
||||
register.registerMetric(activeUsers);
|
||||
|
||||
// Metrics middleware
|
||||
export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
const route = req.route?.path || req.path;
|
||||
|
||||
httpRequestDuration
|
||||
.labels(req.method, route, res.statusCode.toString())
|
||||
.observe(duration);
|
||||
|
||||
httpRequestTotal
|
||||
.labels(req.method, route, res.statusCode.toString())
|
||||
.inc();
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Metrics endpoint
|
||||
export const metricsHandler = async (req: Request, res: Response) => {
|
||||
res.set('Content-Type', register.contentType);
|
||||
res.end(await register.metrics());
|
||||
};
|
||||
```
|
||||
|
||||
## Distributed Tracing
|
||||
### Distributed Tracing
|
||||
|
||||
```typescript
|
||||
// src/lib/tracing.ts
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
|
||||
|
||||
export const initTracing = () => {
|
||||
const jaegerExporter = new JaegerExporter({
|
||||
endpoint: process.env.JAEGER_ENDPOINT || 'http://localhost:14268/api/traces',
|
||||
});
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource: new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: process.env.SERVICE_NAME || 'unknown',
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: process.env.SERVICE_VERSION || '1.0.0',
|
||||
}),
|
||||
traceExporter: jaegerExporter,
|
||||
instrumentations: [getNodeAutoInstrumentations()]
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
sdk.shutdown()
|
||||
.then(() => console.log('Tracing terminated'))
|
||||
.catch((error) => console.log('Error terminating tracing', error))
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
};
|
||||
|
||||
// Custom span creation
|
||||
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
||||
|
||||
export const tracedOperation = async (name: string, fn: Function) => {
|
||||
const tracer = trace.getTracer('application');
|
||||
const span = tracer.startSpan(name);
|
||||
|
||||
const span = trace.getTracer('app').startSpan(name);
|
||||
try {
|
||||
const result = await fn();
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (error) {
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message
|
||||
});
|
||||
span.recordException(error);
|
||||
span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
@@ -218,323 +101,58 @@ export const tracedOperation = async (name: string, fn: Function) => {
|
||||
};
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
### Health Checks
|
||||
|
||||
```typescript
|
||||
// src/modules/health/health.controller.ts
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis
|
||||
) {}
|
||||
// Liveness - is the service running?
|
||||
app.get('/health/live', (req, res) => res.json({ status: 'ok' }));
|
||||
|
||||
// Liveness probe - is the service running?
|
||||
async liveness(req: Request, res: Response) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Readiness probe - is the service ready for traffic?
|
||||
async readiness(req: Request, res: Response) {
|
||||
const checks = await this.runHealthChecks();
|
||||
const isHealthy = Object.values(checks).every(check => check.status === 'healthy');
|
||||
|
||||
res.status(isHealthy ? 200 : 503).json({
|
||||
status: isHealthy ? 'ready' : 'not ready',
|
||||
checks,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Detailed health check
|
||||
async health(req: Request, res: Response) {
|
||||
const checks = await this.runHealthChecks();
|
||||
const isHealthy = Object.values(checks).every(check => check.status === 'healthy');
|
||||
|
||||
res.status(isHealthy ? 200 : 503).json({
|
||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||
version: process.env.SERVICE_VERSION || '1.0.0',
|
||||
uptime: process.uptime(),
|
||||
checks,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
private async runHealthChecks() {
|
||||
const checks: Record<string, any> = {};
|
||||
|
||||
// Database check
|
||||
try {
|
||||
const start = Date.now();
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
checks.database = {
|
||||
status: 'healthy',
|
||||
responseTime: Date.now() - start
|
||||
};
|
||||
} catch (error) {
|
||||
checks.database = {
|
||||
status: 'unhealthy',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Redis check
|
||||
try {
|
||||
const start = Date.now();
|
||||
await this.redis.ping();
|
||||
checks.redis = {
|
||||
status: 'healthy',
|
||||
responseTime: Date.now() - start
|
||||
};
|
||||
} catch (error) {
|
||||
checks.redis = {
|
||||
status: 'unhealthy',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Memory check
|
||||
const memUsage = process.memoryUsage();
|
||||
checks.memory = {
|
||||
status: memUsage.heapUsed < 500 * 1024 * 1024 ? 'healthy' : 'warning',
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024)
|
||||
};
|
||||
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Tracking
|
||||
|
||||
```typescript
|
||||
// src/lib/error-tracking.ts
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
export const initErrorTracking = () => {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV,
|
||||
tracesSampleRate: 0.1,
|
||||
beforeSend(event, hint) {
|
||||
// Filter sensitive data
|
||||
if (event.request?.cookies) {
|
||||
delete event.request.cookies;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Error handler middleware
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
// Log error
|
||||
logger.error('Unhandled error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
correlationId: req.headers['x-correlation-id']
|
||||
});
|
||||
|
||||
// Report to Sentry
|
||||
Sentry.captureException(err, {
|
||||
tags: {
|
||||
service: process.env.SERVICE_NAME
|
||||
},
|
||||
user: {
|
||||
id: req.user?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Send response
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: err.message
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
```typescript
|
||||
// src/middlewares/performance.middleware.ts
|
||||
export const performanceMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
res.on('finish', () => {
|
||||
const end = process.hrtime.bigint();
|
||||
const duration = Number(end - start) / 1000000; // Convert to milliseconds
|
||||
|
||||
// Log slow requests
|
||||
if (duration > 1000) {
|
||||
logger.warn('Slow request detected', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
duration,
|
||||
threshold: 1000
|
||||
});
|
||||
}
|
||||
|
||||
// Add to response header
|
||||
res.set('X-Response-Time', `${duration}ms`);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
```
|
||||
|
||||
## Grafana Dashboard Config
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Service Metrics",
|
||||
"panels": [
|
||||
{
|
||||
"title": "Request Rate",
|
||||
"targets": [{
|
||||
"expr": "rate(http_requests_total[5m])"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"title": "Request Duration",
|
||||
"targets": [{
|
||||
"expr": "histogram_quantile(0.95, http_request_duration_seconds)"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"title": "Error Rate",
|
||||
"targets": [{
|
||||
"expr": "rate(http_requests_total{status=~\"5..\"}[5m])"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"title": "Active Users",
|
||||
"targets": [{
|
||||
"expr": "active_users"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Alerting Rules
|
||||
|
||||
```yaml
|
||||
# prometheus/alerts.yml
|
||||
groups:
|
||||
- name: service_alerts
|
||||
rules:
|
||||
- alert: HighErrorRate
|
||||
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
|
||||
for: 5m
|
||||
annotations:
|
||||
summary: "High error rate detected"
|
||||
description: "Error rate is above 5% for 5 minutes"
|
||||
|
||||
- alert: HighLatency
|
||||
expr: histogram_quantile(0.95, http_request_duration_seconds) > 1
|
||||
for: 5m
|
||||
annotations:
|
||||
summary: "High latency detected"
|
||||
description: "95th percentile latency is above 1s"
|
||||
|
||||
- alert: ServiceDown
|
||||
expr: up{job="service"} == 0
|
||||
for: 1m
|
||||
annotations:
|
||||
summary: "Service is down"
|
||||
description: "Service has been down for 1 minute"
|
||||
// Readiness - is the service ready for traffic?
|
||||
app.get('/health/ready', async (req, res) => {
|
||||
const dbOk = await prisma.$queryRaw`SELECT 1`.then(() => true).catch(() => false);
|
||||
const redisOk = await redis.ping().then(() => true).catch(() => false);
|
||||
const ready = dbOk && redisOk;
|
||||
res.status(ready ? 200 : 503).json({ ready, db: dbOk, redis: redisOk });
|
||||
});
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Logging**
|
||||
- Use structured logging (JSON format)
|
||||
- Include correlation IDs for request tracing
|
||||
- Log at appropriate levels (ERROR, WARN, INFO, DEBUG)
|
||||
- Avoid logging sensitive data
|
||||
|
||||
2. **Metrics**
|
||||
- Use standard metric types (Counter, Gauge, Histogram)
|
||||
- Keep cardinality low (avoid high-cardinality labels)
|
||||
- Define SLIs and SLOs for critical paths
|
||||
- Monitor business metrics, not just technical ones
|
||||
|
||||
3. **Tracing**
|
||||
- Add traces for critical operations
|
||||
- Include relevant context in spans
|
||||
- Sample appropriately to control costs
|
||||
- Use distributed tracing for microservices
|
||||
|
||||
4. **Alerting**
|
||||
- Alert on symptoms, not causes
|
||||
- Include runbook links in alerts
|
||||
- Avoid alert fatigue with proper thresholds
|
||||
- Test alerting rules regularly
|
||||
- **Logging**: Use structured JSON format with correlation IDs
|
||||
- **Metrics**: Use standard types (Counter, Gauge, Histogram) with low-cardinality labels
|
||||
- **Tracing**: Add traces for critical operations, sample appropriately
|
||||
- **Alerting**: Alert on symptoms, include runbook links, avoid alert fatigue
|
||||
- **Security**: Never log sensitive data (passwords, tokens, PII)
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Logging Sensitive Data**: Exposing PII in logs
|
||||
```typescript
|
||||
// ❌ BAD: Logging sensitive data
|
||||
logger.info('User login', { email, password, token });
|
||||
|
||||
// ✅ GOOD: Sanitize sensitive fields
|
||||
logger.info('User login', { email, userId });
|
||||
// BAD: logger.info('User login', { email, password, token });
|
||||
// GOOD: logger.info('User login', { email, userId });
|
||||
```
|
||||
|
||||
2. **High Cardinality Labels**: Too many metric label values
|
||||
```typescript
|
||||
// ❌ BAD: userId as label (millions of values)
|
||||
httpRequests.labels(method, route, userId).inc();
|
||||
|
||||
// ✅ GOOD: Low cardinality labels only
|
||||
httpRequests.labels(method, route, statusCode).inc();
|
||||
// BAD: httpRequests.labels(method, route, userId).inc();
|
||||
// GOOD: httpRequests.labels(method, route, statusCode).inc();
|
||||
```
|
||||
|
||||
3. **No Correlation IDs**: Can't trace requests across services
|
||||
```typescript
|
||||
// ❌ BAD: No correlation
|
||||
logger.info('Processing request');
|
||||
|
||||
// ✅ GOOD: Include correlation ID
|
||||
logger.info('Processing request', { correlationId: req.headers['x-correlation-id'] });
|
||||
// GOOD: Include correlationId in all logs
|
||||
logger.info('Processing', { correlationId: req.headers['x-correlation-id'] });
|
||||
```
|
||||
|
||||
4. **Wrong Log Levels**: Using ERROR for non-errors
|
||||
```typescript
|
||||
// ❌ BAD: Wrong level
|
||||
logger.error('User not found'); // Not an error, expected case
|
||||
|
||||
// ✅ GOOD: Appropriate level
|
||||
logger.info('User not found', { userId });
|
||||
logger.error('Database connection failed', { error });
|
||||
// BAD: logger.error('User not found');
|
||||
// GOOD: logger.info('User not found', { userId });
|
||||
```
|
||||
|
||||
5. **No Health Checks**: Service status unknown
|
||||
```typescript
|
||||
// ❌ BAD: No health endpoint
|
||||
|
||||
// ✅ GOOD: Comprehensive health checks
|
||||
// GOOD: Implement both endpoints
|
||||
app.get('/health/live', livenessCheck);
|
||||
app.get('/health/ready', readinessCheck);
|
||||
```
|
||||
@@ -543,7 +161,7 @@ groups:
|
||||
|
||||
| Pillar | Tool | Endpoint |
|
||||
|--------|------|----------|
|
||||
| **Logs** | Winston/Loki | stdout → Loki |
|
||||
| **Logs** | Winston/Loki | stdout -> Loki |
|
||||
| **Metrics** | Prometheus | `/metrics` |
|
||||
| **Traces** | Jaeger | `http://jaeger:14268` |
|
||||
|
||||
@@ -557,17 +175,9 @@ groups:
|
||||
|
||||
**Essential Metrics:**
|
||||
```typescript
|
||||
// Request rate
|
||||
rate(http_requests_total[5m])
|
||||
|
||||
// Error rate
|
||||
rate(http_requests_total{status=~"5.."}[5m])
|
||||
|
||||
// Latency (p95)
|
||||
histogram_quantile(0.95, http_request_duration_seconds_bucket)
|
||||
|
||||
// Active connections
|
||||
active_connections
|
||||
rate(http_requests_total[5m]) // Request rate
|
||||
rate(http_requests_total{status=~"5.."}[5m]) // Error rate
|
||||
histogram_quantile(0.95, http_request_duration_bucket) // Latency p95
|
||||
```
|
||||
|
||||
**Health Check Endpoints:**
|
||||
@@ -577,18 +187,11 @@ active_connections
|
||||
| `/health/ready` | Ready for traffic? | K8s readiness probe |
|
||||
| `/health` | Full status | Monitoring |
|
||||
|
||||
**Essential Imports:**
|
||||
```typescript
|
||||
import { logger } from '@goodgo/logger';
|
||||
import { Counter, Histogram, Gauge } from 'prom-client';
|
||||
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [OpenTelemetry](https://opentelemetry.io/docs/) - Distributed tracing standard
|
||||
- [Prometheus](https://prometheus.io/docs/) - Metrics and alerting
|
||||
- [Grafana](https://grafana.com/docs/) - Visualization
|
||||
- [Detailed Code Examples](./references/REFERENCE.md)
|
||||
- [Deployment Kubernetes](../deployment-kubernetes/SKILL.md) - K8s health probes
|
||||
- [Resilience Patterns](../resilience-patterns/SKILL.md) - Circuit breaker metrics
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo standards
|
||||
437
.cursor/skills/observability-monitoring/references/REFERENCE.md
Normal file
437
.cursor/skills/observability-monitoring/references/REFERENCE.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# Observability & Monitoring - Detailed Reference
|
||||
|
||||
This reference contains detailed code examples for observability and monitoring patterns.
|
||||
|
||||
## Structured Logging
|
||||
|
||||
```typescript
|
||||
// src/lib/logger.ts
|
||||
import winston from 'winston';
|
||||
|
||||
const logFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: logFormat,
|
||||
defaultMeta: {
|
||||
service: process.env.SERVICE_NAME || 'unknown',
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
},
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
format: process.env.NODE_ENV === 'development'
|
||||
? winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
: logFormat
|
||||
}),
|
||||
// Production: Send to log aggregation service
|
||||
...(process.env.NODE_ENV === 'production'
|
||||
? [new winston.transports.Http({
|
||||
host: 'logs.example.com',
|
||||
path: '/collect',
|
||||
ssl: true
|
||||
})]
|
||||
: [])
|
||||
]
|
||||
});
|
||||
|
||||
// Request logger middleware
|
||||
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent'),
|
||||
correlationId: req.headers['x-correlation-id']
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
```
|
||||
|
||||
## Metrics Collection
|
||||
|
||||
```typescript
|
||||
// src/lib/metrics.ts
|
||||
import { Registry, Counter, Histogram, Gauge } from 'prom-client';
|
||||
|
||||
export const register = new Registry();
|
||||
|
||||
// HTTP metrics
|
||||
export const httpRequestDuration = new Histogram({
|
||||
name: 'http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
labelNames: ['method', 'route', 'status'],
|
||||
buckets: [0.1, 0.3, 0.5, 0.7, 1, 3, 5, 7, 10]
|
||||
});
|
||||
|
||||
export const httpRequestTotal = new Counter({
|
||||
name: 'http_requests_total',
|
||||
help: 'Total number of HTTP requests',
|
||||
labelNames: ['method', 'route', 'status']
|
||||
});
|
||||
|
||||
// Business metrics
|
||||
export const userRegistrations = new Counter({
|
||||
name: 'user_registrations_total',
|
||||
help: 'Total number of user registrations',
|
||||
labelNames: ['type']
|
||||
});
|
||||
|
||||
export const activeUsers = new Gauge({
|
||||
name: 'active_users',
|
||||
help: 'Number of active users',
|
||||
labelNames: ['status']
|
||||
});
|
||||
|
||||
// Register metrics
|
||||
register.registerMetric(httpRequestDuration);
|
||||
register.registerMetric(httpRequestTotal);
|
||||
register.registerMetric(userRegistrations);
|
||||
register.registerMetric(activeUsers);
|
||||
|
||||
// Metrics middleware
|
||||
export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
const route = req.route?.path || req.path;
|
||||
|
||||
httpRequestDuration
|
||||
.labels(req.method, route, res.statusCode.toString())
|
||||
.observe(duration);
|
||||
|
||||
httpRequestTotal
|
||||
.labels(req.method, route, res.statusCode.toString())
|
||||
.inc();
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Metrics endpoint
|
||||
export const metricsHandler = async (req: Request, res: Response) => {
|
||||
res.set('Content-Type', register.contentType);
|
||||
res.end(await register.metrics());
|
||||
};
|
||||
```
|
||||
|
||||
## Distributed Tracing
|
||||
|
||||
```typescript
|
||||
// src/lib/tracing.ts
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions';
|
||||
import { JaegerExporter } from '@opentelemetry/exporter-jaeger';
|
||||
|
||||
export const initTracing = () => {
|
||||
const jaegerExporter = new JaegerExporter({
|
||||
endpoint: process.env.JAEGER_ENDPOINT || 'http://localhost:14268/api/traces',
|
||||
});
|
||||
|
||||
const sdk = new NodeSDK({
|
||||
resource: new Resource({
|
||||
[SemanticResourceAttributes.SERVICE_NAME]: process.env.SERVICE_NAME || 'unknown',
|
||||
[SemanticResourceAttributes.SERVICE_VERSION]: process.env.SERVICE_VERSION || '1.0.0',
|
||||
}),
|
||||
traceExporter: jaegerExporter,
|
||||
instrumentations: [getNodeAutoInstrumentations()]
|
||||
});
|
||||
|
||||
sdk.start();
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
sdk.shutdown()
|
||||
.then(() => console.log('Tracing terminated'))
|
||||
.catch((error) => console.log('Error terminating tracing', error))
|
||||
.finally(() => process.exit(0));
|
||||
});
|
||||
};
|
||||
|
||||
// Custom span creation
|
||||
import { trace, SpanStatusCode } from '@opentelemetry/api';
|
||||
|
||||
export const tracedOperation = async (name: string, fn: Function) => {
|
||||
const tracer = trace.getTracer('application');
|
||||
const span = tracer.startSpan(name);
|
||||
|
||||
try {
|
||||
const result = await fn();
|
||||
span.setStatus({ code: SpanStatusCode.OK });
|
||||
return result;
|
||||
} catch (error) {
|
||||
span.setStatus({
|
||||
code: SpanStatusCode.ERROR,
|
||||
message: error.message
|
||||
});
|
||||
span.recordException(error);
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
```typescript
|
||||
// src/modules/health/health.controller.ts
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private prisma: PrismaClient,
|
||||
private redis: Redis
|
||||
) {}
|
||||
|
||||
// Liveness probe - is the service running?
|
||||
async liveness(req: Request, res: Response) {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Readiness probe - is the service ready for traffic?
|
||||
async readiness(req: Request, res: Response) {
|
||||
const checks = await this.runHealthChecks();
|
||||
const isHealthy = Object.values(checks).every(check => check.status === 'healthy');
|
||||
|
||||
res.status(isHealthy ? 200 : 503).json({
|
||||
status: isHealthy ? 'ready' : 'not ready',
|
||||
checks,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
// Detailed health check
|
||||
async health(req: Request, res: Response) {
|
||||
const checks = await this.runHealthChecks();
|
||||
const isHealthy = Object.values(checks).every(check => check.status === 'healthy');
|
||||
|
||||
res.status(isHealthy ? 200 : 503).json({
|
||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||
version: process.env.SERVICE_VERSION || '1.0.0',
|
||||
uptime: process.uptime(),
|
||||
checks,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
private async runHealthChecks() {
|
||||
const checks: Record<string, any> = {};
|
||||
|
||||
// Database check
|
||||
try {
|
||||
const start = Date.now();
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
checks.database = {
|
||||
status: 'healthy',
|
||||
responseTime: Date.now() - start
|
||||
};
|
||||
} catch (error) {
|
||||
checks.database = {
|
||||
status: 'unhealthy',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Redis check
|
||||
try {
|
||||
const start = Date.now();
|
||||
await this.redis.ping();
|
||||
checks.redis = {
|
||||
status: 'healthy',
|
||||
responseTime: Date.now() - start
|
||||
};
|
||||
} catch (error) {
|
||||
checks.redis = {
|
||||
status: 'unhealthy',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
// Memory check
|
||||
const memUsage = process.memoryUsage();
|
||||
checks.memory = {
|
||||
status: memUsage.heapUsed < 500 * 1024 * 1024 ? 'healthy' : 'warning',
|
||||
heapUsed: Math.round(memUsage.heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(memUsage.heapTotal / 1024 / 1024),
|
||||
rss: Math.round(memUsage.rss / 1024 / 1024)
|
||||
};
|
||||
|
||||
return checks;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Tracking
|
||||
|
||||
```typescript
|
||||
// src/lib/error-tracking.ts
|
||||
import * as Sentry from '@sentry/node';
|
||||
|
||||
export const initErrorTracking = () => {
|
||||
if (process.env.SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: process.env.SENTRY_DSN,
|
||||
environment: process.env.NODE_ENV,
|
||||
tracesSampleRate: 0.1,
|
||||
beforeSend(event, hint) {
|
||||
// Filter sensitive data
|
||||
if (event.request?.cookies) {
|
||||
delete event.request.cookies;
|
||||
}
|
||||
return event;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Error handler middleware
|
||||
export const errorHandler = (
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
// Log error
|
||||
logger.error('Unhandled error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
correlationId: req.headers['x-correlation-id']
|
||||
});
|
||||
|
||||
// Report to Sentry
|
||||
Sentry.captureException(err, {
|
||||
tags: {
|
||||
service: process.env.SERVICE_NAME
|
||||
},
|
||||
user: {
|
||||
id: req.user?.id
|
||||
}
|
||||
});
|
||||
|
||||
// Send response
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: process.env.NODE_ENV === 'production'
|
||||
? 'Internal server error'
|
||||
: err.message
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## Performance Monitoring
|
||||
|
||||
```typescript
|
||||
// src/middlewares/performance.middleware.ts
|
||||
export const performanceMiddleware = (req: Request, res: Response, next: NextFunction) => {
|
||||
const start = process.hrtime.bigint();
|
||||
|
||||
res.on('finish', () => {
|
||||
const end = process.hrtime.bigint();
|
||||
const duration = Number(end - start) / 1000000; // Convert to milliseconds
|
||||
|
||||
// Log slow requests
|
||||
if (duration > 1000) {
|
||||
logger.warn('Slow request detected', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
duration,
|
||||
threshold: 1000
|
||||
});
|
||||
}
|
||||
|
||||
// Add to response header
|
||||
res.set('X-Response-Time', `${duration}ms`);
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
```
|
||||
|
||||
## Grafana Dashboard Config
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard": {
|
||||
"title": "Service Metrics",
|
||||
"panels": [
|
||||
{
|
||||
"title": "Request Rate",
|
||||
"targets": [{
|
||||
"expr": "rate(http_requests_total[5m])"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"title": "Request Duration",
|
||||
"targets": [{
|
||||
"expr": "histogram_quantile(0.95, http_request_duration_seconds)"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"title": "Error Rate",
|
||||
"targets": [{
|
||||
"expr": "rate(http_requests_total{status=~\"5..\"}[5m])"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"title": "Active Users",
|
||||
"targets": [{
|
||||
"expr": "active_users"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Alerting Rules
|
||||
|
||||
```yaml
|
||||
# prometheus/alerts.yml
|
||||
groups:
|
||||
- name: service_alerts
|
||||
rules:
|
||||
- alert: HighErrorRate
|
||||
expr: rate(http_requests_total{status=~"5.."}[5m]) > 0.05
|
||||
for: 5m
|
||||
annotations:
|
||||
summary: "High error rate detected"
|
||||
description: "Error rate is above 5% for 5 minutes"
|
||||
|
||||
- alert: HighLatency
|
||||
expr: histogram_quantile(0.95, http_request_duration_seconds) > 1
|
||||
for: 5m
|
||||
annotations:
|
||||
summary: "High latency detected"
|
||||
description: "95th percentile latency is above 1s"
|
||||
|
||||
- alert: ServiceDown
|
||||
expr: up{job="service"} == 0
|
||||
for: 1m
|
||||
annotations:
|
||||
summary: "Service is down"
|
||||
description: "Service has been down for 1 minute"
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: security
|
||||
description: Security patterns for GoodGo platform. Use for authentication, authorization, data protection, input validation, rate limiting, or secrets management.
|
||||
dependencies: "bcrypt>=5, helmet>=7, zod>=3, jsonwebtoken"
|
||||
compatibility: "bcrypt>=5, helmet>=7, zod>=3, jsonwebtoken"
|
||||
---
|
||||
|
||||
# Security Patterns for GoodGo Microservices
|
||||
@@ -37,658 +37,240 @@ Use this skill when:
|
||||
|
||||
### JWT Token Validation
|
||||
|
||||
```typescript
|
||||
// src/middlewares/auth.middleware.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { jwtService } from '@goodgo/auth-sdk';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export const authenticate = () => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Extract token from Authorization header or cookie
|
||||
let token: string | null = null;
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else if (req.cookies?.access_token) {
|
||||
token = req.cookies.access_token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'AUTH_REQUIRED', message: 'Authentication required' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = await jwtService.verifyAccessToken(token);
|
||||
|
||||
// Attach user to request
|
||||
req.user = {
|
||||
id: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
roles: payload.roles || [],
|
||||
permissions: payload.permissions || []
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.warn('Authentication failed', { error: error.message });
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'INVALID_TOKEN', message: 'Invalid or expired token' }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Role-Based Authorization
|
||||
Extract tokens from Authorization header or cookies, verify with `jwtService.verifyAccessToken()`, and attach user payload to request. Return 401 for missing/invalid tokens.
|
||||
|
||||
```typescript
|
||||
// src/middlewares/rbac.middleware.ts
|
||||
export const requireRole = (...allowedRoles: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'AUTH_REQUIRED', message: 'Authentication required' }
|
||||
});
|
||||
}
|
||||
|
||||
const userRoles = req.user.roles || [];
|
||||
const hasRole = userRoles.some(role => allowedRoles.includes(role));
|
||||
|
||||
if (!hasRole) {
|
||||
logger.warn('Access denied - insufficient role', {
|
||||
userId: req.user.id,
|
||||
userRoles,
|
||||
requiredRoles: allowedRoles
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Permission-based authorization
|
||||
export const requirePermission = (resource: string, action: string) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'AUTH_REQUIRED', message: 'Authentication required' }
|
||||
});
|
||||
}
|
||||
|
||||
const permission = `${resource}:${action}`;
|
||||
const hasPermission = req.user.permissions?.includes(permission);
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn('Access denied - insufficient permission', {
|
||||
userId: req.user.id,
|
||||
required: permission
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Usage in routes
|
||||
router.post(
|
||||
'/api/v1/users',
|
||||
authenticate(),
|
||||
requirePermission('users', 'create'),
|
||||
userController.create
|
||||
);
|
||||
// Key pattern - see references/REFERENCE.md for full implementation
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : req.cookies?.access_token;
|
||||
const payload = await jwtService.verifyAccessToken(token);
|
||||
req.user = { id: payload.sub, roles: payload.roles || [], permissions: payload.permissions || [] };
|
||||
```
|
||||
|
||||
### Resource Ownership Validation
|
||||
### Role-Based Authorization (RBAC)
|
||||
|
||||
Use `requireRole()` middleware for role checks and `requirePermission()` for fine-grained access control with `resource:action` format.
|
||||
|
||||
```typescript
|
||||
// Ensure users can only access their own resources
|
||||
export const requireOwnership = (resourceIdParam: string = 'id') => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const resourceId = req.params[resourceIdParam];
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (resourceId !== userId) {
|
||||
logger.warn('Access denied - resource ownership mismatch', {
|
||||
userId,
|
||||
resourceId
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Access denied' }
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
// Usage pattern
|
||||
router.post('/api/v1/users', authenticate(), requirePermission('users', 'create'), userController.create);
|
||||
router.delete('/api/v1/admin', authenticate(), requireRole('admin', 'superadmin'), adminController.delete);
|
||||
```
|
||||
|
||||
### Resource Ownership
|
||||
|
||||
Validate users can only access their own resources using `requireOwnership()` middleware.
|
||||
|
||||
## Data Protection
|
||||
|
||||
### Encryption Service
|
||||
### Encryption
|
||||
|
||||
```typescript
|
||||
// src/core/security/encryption.service.ts
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
export class EncryptionService {
|
||||
private getKey(): Buffer {
|
||||
const secret = process.env.ENCRYPTION_KEY;
|
||||
if (!secret || secret.length < 32) {
|
||||
throw new Error('ENCRYPTION_KEY must be at least 32 characters');
|
||||
}
|
||||
return crypto.scryptSync(secret, 'salt', 32);
|
||||
}
|
||||
|
||||
encrypt(text: string): string {
|
||||
const key = this.getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`;
|
||||
}
|
||||
|
||||
decrypt(encryptedText: string): string {
|
||||
const [ivHex, tagHex, encrypted] = encryptedText.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
|
||||
const key = this.getKey();
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Encrypt PII before storing
|
||||
const encryption = new EncryptionService();
|
||||
const encryptedPhone = encryption.encrypt(user.phone);
|
||||
```
|
||||
- Use AES-256-GCM for encrypting PII at rest
|
||||
- Store encrypted data as `iv:tag:ciphertext` format
|
||||
- Require 32+ character ENCRYPTION_KEY
|
||||
|
||||
### Password Hashing
|
||||
|
||||
```typescript
|
||||
// Always use bcrypt with appropriate cost factor
|
||||
import bcrypt from 'bcrypt';
|
||||
- Always use bcrypt with cost factor 12 in production
|
||||
- Never log passwords - sanitize before logging
|
||||
|
||||
const SALT_ROUNDS = 12; // Production: 12, Development: 10
|
||||
### Token Storage
|
||||
|
||||
export class PasswordService {
|
||||
async hash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
async verify(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Never log passwords
|
||||
sanitizeForLogging(data: any): any {
|
||||
const sanitized = { ...data };
|
||||
if (sanitized.password) sanitized.password = '[REDACTED]';
|
||||
if (sanitized.passwordHash) sanitized.passwordHash = '[REDACTED]';
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Token Hashing
|
||||
|
||||
```typescript
|
||||
// Hash tokens before storing in database
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class TokenService {
|
||||
hashToken(token: string): string {
|
||||
const salt = process.env.TOKEN_SALT || 'default-salt-change-in-production';
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(token + salt)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
generateSecureToken(length: number = 32): string {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
}
|
||||
```
|
||||
- Hash tokens with SHA-256 before database storage
|
||||
- Use `crypto.randomBytes(32)` for secure token generation
|
||||
|
||||
## Input Validation
|
||||
|
||||
### Zod Schema Validation
|
||||
|
||||
```typescript
|
||||
// Always validate inputs with Zod
|
||||
import { z } from 'zod';
|
||||
Always validate inputs with Zod schemas before processing:
|
||||
|
||||
// DTO with validation
|
||||
export const CreateUserDto = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain special character'),
|
||||
phone: z.string()
|
||||
.regex(/^\+[1-9]\d{1,14}$/, 'Invalid phone format (E.164)')
|
||||
.optional(),
|
||||
```typescript
|
||||
const CreateUserDto = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8).regex(/[A-Z]/).regex(/[a-z]/).regex(/[0-9]/).regex(/[^A-Za-z0-9]/),
|
||||
phone: z.string().regex(/^\+[1-9]\d{1,14}$/).optional(),
|
||||
name: z.string().min(1).max(255)
|
||||
});
|
||||
|
||||
// In controller
|
||||
export class UserController {
|
||||
async create(req: Request, res: Response) {
|
||||
try {
|
||||
const dto = CreateUserDto.parse(req.body);
|
||||
const user = await this.service.create(dto);
|
||||
res.status(201).json({ success: true, data: user });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input data',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const dto = CreateUserDto.parse(req.body);
|
||||
```
|
||||
|
||||
### File Upload Validation
|
||||
|
||||
```typescript
|
||||
// Validate file uploads
|
||||
import fileType from 'file-type';
|
||||
|
||||
export class FileValidationService {
|
||||
private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
private readonly ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf'];
|
||||
|
||||
async validateFile(file: Express.Multer.File): Promise<void> {
|
||||
// Size check
|
||||
if (file.size > this.MAX_FILE_SIZE) {
|
||||
throw new HttpError(400, 'FILE_TOO_LARGE', 'File exceeds maximum size');
|
||||
}
|
||||
|
||||
// Type check
|
||||
if (!this.ALLOWED_TYPES.includes(file.mimetype)) {
|
||||
throw new HttpError(400, 'INVALID_FILE_TYPE', 'File type not allowed');
|
||||
}
|
||||
|
||||
// Content validation (prevent MIME type spoofing)
|
||||
const type = await fileType.fromBuffer(file.buffer);
|
||||
if (!type || !this.ALLOWED_TYPES.includes(type.mime)) {
|
||||
throw new HttpError(400, 'INVALID_FILE_CONTENT', 'File content mismatch');
|
||||
}
|
||||
|
||||
// TODO: Add virus scanning for production
|
||||
}
|
||||
}
|
||||
```
|
||||
- Check file size (max 10MB default)
|
||||
- Validate MIME type against whitelist
|
||||
- Verify content with `file-type` library to prevent MIME spoofing
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
```typescript
|
||||
// Always use Prisma parameterized queries (automatic)
|
||||
// Never use string concatenation for queries
|
||||
|
||||
// ❌ BAD - Never do this
|
||||
const query = `SELECT * FROM users WHERE email = '${email}'`;
|
||||
|
||||
// ✅ GOOD - Use Prisma
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
// ✅ GOOD - For dynamic queries
|
||||
const where: any = {};
|
||||
if (email) where.email = email;
|
||||
if (status) where.status = status;
|
||||
|
||||
const users = await prisma.user.findMany({ where });
|
||||
```
|
||||
Always use Prisma parameterized queries - never string concatenation for queries.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Configure Redis-backed rate limiting for all endpoints:
|
||||
|
||||
| Limiter Type | Window | Max Requests | Use Case |
|
||||
|-------------|--------|--------------|----------|
|
||||
| Standard | 15 min | 100 | General API endpoints |
|
||||
| Strict | 1 hour | 10 | Sensitive operations |
|
||||
| Login | 15 min | 5 | Authentication endpoints |
|
||||
|
||||
```typescript
|
||||
// Implement rate limiting for all endpoints
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
|
||||
// Standard rate limit
|
||||
export const standardLimiter = rateLimit({
|
||||
store: new RedisStore({
|
||||
client: redis,
|
||||
prefix: 'rl:standard:'
|
||||
}),
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // 100 requests per window
|
||||
message: 'Too many requests, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
|
||||
// Strict rate limit for sensitive operations
|
||||
export const strictLimiter = rateLimit({
|
||||
store: new RedisStore({
|
||||
client: redis,
|
||||
prefix: 'rl:strict:'
|
||||
}),
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
message: 'Rate limit exceeded for this operation'
|
||||
});
|
||||
|
||||
// Login-specific rate limit
|
||||
export const loginLimiter = rateLimit({
|
||||
store: new RedisStore({
|
||||
client: redis,
|
||||
prefix: 'rl:login:'
|
||||
}),
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5, // 5 login attempts per 15 minutes
|
||||
skipSuccessfulRequests: true,
|
||||
message: 'Too many login attempts, please try again later'
|
||||
});
|
||||
|
||||
// Usage
|
||||
router.post('/api/v1/auth/login', loginLimiter, authController.login);
|
||||
router.post('/api/v1/users', authenticate(), strictLimiter, userController.create);
|
||||
```
|
||||
|
||||
## Error Handling Security
|
||||
|
||||
```typescript
|
||||
// Sanitize error messages to prevent information disclosure
|
||||
export class SecureErrorHandler {
|
||||
handleError(error: Error, req: Request, res: Response) {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Log full error internally
|
||||
logger.error('Request error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userId: req.user?.id
|
||||
});
|
||||
|
||||
// Don't expose user existence
|
||||
if (error.message.includes('user not found') ||
|
||||
error.message.includes('invalid credentials')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: 'Invalid email or password'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validation errors - safe to expose
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input data',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generic errors for production
|
||||
if (isProd) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An error occurred. Please try again later.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Detailed errors only in development
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error.message,
|
||||
stack: isDev ? error.stack : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Secrets Management
|
||||
|
||||
```typescript
|
||||
// Never hardcode secrets
|
||||
// Always use environment variables with validation
|
||||
import { z } from 'zod';
|
||||
|
||||
const secretsSchema = z.object({
|
||||
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
|
||||
JWT_REFRESH_SECRET: z.string().min(32),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
ENCRYPTION_KEY: z.string().min(32).optional()
|
||||
});
|
||||
|
||||
export const secrets = secretsSchema.parse(process.env);
|
||||
|
||||
// For production, use secret management:
|
||||
// - AWS Secrets Manager
|
||||
// - HashiCorp Vault
|
||||
// - Kubernetes Secrets
|
||||
// - Azure Key Vault
|
||||
|
||||
// Rotate secrets regularly (quarterly recommended)
|
||||
```
|
||||
|
||||
## Audit Logging
|
||||
|
||||
```typescript
|
||||
// Log all security-relevant events
|
||||
export class AuditService {
|
||||
async logSecurityEvent(
|
||||
event: string,
|
||||
userId: string | null,
|
||||
details: Record<string, any>,
|
||||
req?: Request
|
||||
) {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
event,
|
||||
userId,
|
||||
type: 'SECURITY',
|
||||
details: this.sanitizeDetails(details),
|
||||
ipAddress: req?.ip || details.ipAddress,
|
||||
userAgent: req?.get('user-agent'),
|
||||
timestamp: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize PII from logs
|
||||
private sanitizeDetails(details: Record<string, any>): Record<string, any> {
|
||||
const sensitive = ['password', 'token', 'secret', 'ssn', 'creditCard'];
|
||||
const sanitized = { ...details };
|
||||
|
||||
for (const key of sensitive) {
|
||||
if (sanitized[key]) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
await auditService.logSecurityEvent('LOGIN_SUCCESS', user.id, {
|
||||
email: user.email,
|
||||
ipAddress: req.ip
|
||||
}, req);
|
||||
|
||||
await auditService.logSecurityEvent('PERMISSION_DENIED', user.id, {
|
||||
resource: 'users',
|
||||
action: 'delete',
|
||||
targetId: targetUserId
|
||||
}, req);
|
||||
```
|
||||
|
||||
## Security Headers
|
||||
|
||||
```typescript
|
||||
// Add security headers middleware
|
||||
import helmet from 'helmet';
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"]
|
||||
}
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
}
|
||||
}));
|
||||
|
||||
// Additional headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
```
|
||||
Use Helmet middleware with CSP, HSTS, and additional headers:
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
- `X-XSS-Protection: 1; mode=block`
|
||||
- `Referrer-Policy: strict-origin-when-cross-origin`
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
- Whitelist allowed origins from environment variables
|
||||
- Enable credentials for authenticated requests
|
||||
- Set `maxAge: 86400` (24 hours) for preflight caching
|
||||
|
||||
## Secrets Management
|
||||
|
||||
- Never hardcode secrets - use environment variables
|
||||
- Validate secrets with Zod schema at startup
|
||||
- Use secret managers in production (AWS Secrets Manager, Vault, etc.)
|
||||
- Rotate secrets quarterly
|
||||
|
||||
```typescript
|
||||
// Configure CORS securely
|
||||
import cors from 'cors';
|
||||
|
||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',') || [];
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
exposedHeaders: ['X-Request-ID'],
|
||||
maxAge: 86400 // 24 hours
|
||||
}));
|
||||
const secretsSchema = z.object({
|
||||
JWT_SECRET: z.string().min(32),
|
||||
DATABASE_URL: z.string().url(),
|
||||
ENCRYPTION_KEY: z.string().min(32).optional()
|
||||
});
|
||||
```
|
||||
|
||||
## Audit Logging
|
||||
|
||||
Log all security-relevant events with sanitized details:
|
||||
|
||||
```typescript
|
||||
await auditService.logSecurityEvent('LOGIN_SUCCESS', user.id, { email: user.email }, req);
|
||||
await auditService.logSecurityEvent('PERMISSION_DENIED', user.id, { resource, action }, req);
|
||||
```
|
||||
|
||||
Sanitize sensitive fields: password, token, secret, ssn, creditCard.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Log full errors internally
|
||||
- Never expose user existence (use generic "Invalid credentials")
|
||||
- Show stack traces only in development
|
||||
- Return sanitized error codes in production
|
||||
|
||||
## Security Testing
|
||||
|
||||
Write tests for:
|
||||
- SQL injection prevention
|
||||
- XSS attack prevention
|
||||
- Authentication enforcement
|
||||
- Authorization enforcement
|
||||
- Rate limiting effectiveness
|
||||
|
||||
## Best Practices
|
||||
|
||||
- All endpoints require authentication (except public)
|
||||
- Authorization checks at every protected route
|
||||
- Input validation with Zod on all user input
|
||||
- Rate limiting on all endpoints
|
||||
- Error messages sanitized in production
|
||||
- PII encrypted at rest with AES-256-GCM
|
||||
- Passwords hashed with bcrypt (cost 12+)
|
||||
- Tokens hashed before database storage
|
||||
- HTTPS enforced (TLS 1.2+)
|
||||
- Security headers via Helmet
|
||||
- Audit logging for security events
|
||||
- Dependencies scanned for vulnerabilities
|
||||
- File uploads validated (size, type, content)
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
### 1. Weak Password Hashing
|
||||
|
||||
```typescript
|
||||
// Security test patterns
|
||||
describe('Security Tests', () => {
|
||||
it('should prevent SQL injection', async () => {
|
||||
const maliciousInput = "'; DROP TABLE users; --";
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/users?search=${encodeURIComponent(maliciousInput)}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(response.status).not.toBe(500);
|
||||
// Should return 400 or empty results, not crash
|
||||
});
|
||||
// BAD: Low cost factor
|
||||
const hash = await bcrypt.hash(password, 8);
|
||||
|
||||
it('should prevent XSS attacks', async () => {
|
||||
const xssPayload = '<script>alert("XSS")</script>';
|
||||
const response = await request(app)
|
||||
.post('/api/v1/users')
|
||||
.send({ email: xssPayload, password: 'test123' });
|
||||
|
||||
// Response should sanitize or reject
|
||||
expect(response.body.data?.email).not.toContain('<script>');
|
||||
});
|
||||
// GOOD: Use cost 12
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
```
|
||||
|
||||
it('should enforce authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/users');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
### 2. Hardcoded Secrets
|
||||
|
||||
it('should enforce authorization', async () => {
|
||||
const userToken = await createUserToken({ roles: ['user'] });
|
||||
const response = await request(app)
|
||||
.delete('/api/v1/users/123')
|
||||
.set('Authorization', `Bearer ${userToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
```typescript
|
||||
// BAD
|
||||
const JWT_SECRET = "my-secret-key";
|
||||
|
||||
it('should rate limit excessive requests', async () => {
|
||||
const requests = Array(20).fill(null).map(() =>
|
||||
request(app).get('/api/v1/users')
|
||||
);
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
const rateLimited = responses.filter(r => r.status === 429);
|
||||
|
||||
expect(rateLimited.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
// GOOD
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET) throw new Error('JWT_SECRET required');
|
||||
```
|
||||
|
||||
### 3. Missing Input Validation
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
|
||||
// GOOD
|
||||
const { id } = z.object({ id: z.string().cuid() }).parse(req.params);
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
```
|
||||
|
||||
### 4. Logging Sensitive Data
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
logger.info('User login', { email, password });
|
||||
|
||||
// GOOD
|
||||
logger.info('User login', { email, password: '[REDACTED]' });
|
||||
```
|
||||
|
||||
### 5. Exposing User Existence
|
||||
|
||||
```typescript
|
||||
// BAD
|
||||
if (!user) throw new Error('User not found');
|
||||
|
||||
// GOOD
|
||||
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Security Area | Implementation |
|
||||
|--------------|----------------|
|
||||
| **Password hashing** | `bcrypt.hash(password, 12)` |
|
||||
| **JWT Access Token** | 15 minutes expiry |
|
||||
| **JWT Refresh Token** | 7 days expiry |
|
||||
| **Rate limiting** | Standard: 100/15min, Strict: 10/hour, Login: 5/15min |
|
||||
| **Encryption** | AES-256-GCM for PII |
|
||||
| **Input validation** | Zod schemas, always parse before use |
|
||||
| **SQL injection** | Use Prisma (parameterized by default) |
|
||||
| **Security headers** | helmet middleware |
|
||||
| **CORS** | Whitelist origins, credentials: true |
|
||||
|
||||
**Essential Imports:**
|
||||
|
||||
```typescript
|
||||
import bcrypt from 'bcrypt';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { z } from 'zod';
|
||||
import { jwtService } from '@goodgo/auth-sdk';
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
@@ -715,146 +297,9 @@ Before deploying any service:
|
||||
- [ ] Dependencies scanned for vulnerabilities
|
||||
- [ ] Secrets rotation plan in place
|
||||
|
||||
## Common Security Anti-Patterns
|
||||
|
||||
```typescript
|
||||
// ❌ BAD: Hardcoded secrets
|
||||
const SECRET = 'my-secret-key';
|
||||
|
||||
// ✅ GOOD: Environment variables
|
||||
const SECRET = process.env.JWT_SECRET;
|
||||
|
||||
// ❌ BAD: Plain text passwords
|
||||
await prisma.user.create({ data: { password: password } });
|
||||
|
||||
// ✅ GOOD: Hashed passwords
|
||||
await prisma.user.create({
|
||||
data: { passwordHash: await bcrypt.hash(password, 12) }
|
||||
});
|
||||
|
||||
// ❌ BAD: Exposing user existence
|
||||
if (!user) {
|
||||
throw new Error('User not found'); // Reveals user doesn't exist
|
||||
}
|
||||
|
||||
// ✅ GOOD: Generic error
|
||||
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// ❌ BAD: No input validation
|
||||
const email = req.body.email;
|
||||
|
||||
// ✅ GOOD: Validate with Zod
|
||||
const { email } = CreateUserDto.parse(req.body);
|
||||
|
||||
// ❌ BAD: Stack traces in production
|
||||
res.status(500).json({ error: error.stack });
|
||||
|
||||
// ✅ GOOD: Sanitized errors
|
||||
res.status(500).json({
|
||||
error: { code: 'INTERNAL_ERROR', message: 'An error occurred' }
|
||||
});
|
||||
```
|
||||
|
||||
## Incident Response
|
||||
|
||||
```typescript
|
||||
// Security incident detection and response
|
||||
export class SecurityIncidentService {
|
||||
async detectAnomaly(userId: string, event: string, context: any) {
|
||||
// Check for suspicious patterns
|
||||
const recentEvents = await this.getRecentEvents(userId, '1h');
|
||||
|
||||
if (recentEvents.length > 10) {
|
||||
await this.triggerAlert('SUSPICIOUS_ACTIVITY', {
|
||||
userId,
|
||||
eventCount: recentEvents.length,
|
||||
timeWindow: '1h'
|
||||
});
|
||||
}
|
||||
|
||||
// Check for privilege escalation attempts
|
||||
if (event === 'PERMISSION_DENIED' && context.requiredPermission) {
|
||||
await this.logSecurityEvent('PRIVILEGE_ESCALATION_ATTEMPT', userId, context);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAlert(type: string, details: any) {
|
||||
// Send to monitoring system
|
||||
logger.error('Security alert', { type, details });
|
||||
|
||||
// TODO: Integrate with PagerDuty, Slack, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Weak Password Hashing**: Using insufficient bcrypt rounds
|
||||
```typescript
|
||||
// ❌ BAD: Low cost factor
|
||||
const hash = await bcrypt.hash(password, 8);
|
||||
|
||||
// ✅ GOOD: Use cost 12
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
```
|
||||
|
||||
2. **Storing Secrets in Code**: Hardcoded credentials
|
||||
```typescript
|
||||
// ❌ BAD: Hardcoded secret
|
||||
const JWT_SECRET = "my-secret-key";
|
||||
|
||||
// ✅ GOOD: Use environment variable
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET) throw new Error('JWT_SECRET required');
|
||||
```
|
||||
|
||||
3. **Missing Input Validation**: Not validating user input
|
||||
```typescript
|
||||
// ❌ BAD: No validation
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
|
||||
// ✅ GOOD: Validate input
|
||||
const schema = z.object({ id: z.string().cuid() });
|
||||
const { id } = schema.parse(req.params);
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
```
|
||||
|
||||
4. **Logging Sensitive Data**: Exposing secrets in logs
|
||||
```typescript
|
||||
// ❌ BAD: Logging password
|
||||
logger.info('User login', { email, password });
|
||||
|
||||
// ✅ GOOD: Sanitize before logging
|
||||
logger.info('User login', { email, password: '[REDACTED]' });
|
||||
```
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Security Area | Implementation |
|
||||
|--------------|----------------|
|
||||
| **Password hashing** | `bcrypt.hash(password, 12)` |
|
||||
| **JWT Access Token** | 15 minutes expiry |
|
||||
| **JWT Refresh Token** | 7 days expiry |
|
||||
| **Rate limiting** | Standard: 100/15min, Strict: 10/hour, Login: 5/15min |
|
||||
| **Encryption** | AES-256-GCM for PII |
|
||||
| **Input validation** | Zod schemas, always parse before use |
|
||||
| **SQL injection** | Use Prisma (parameterized by default) |
|
||||
| **Security headers** | helmet middleware |
|
||||
| **CORS** | Whitelist origins, credentials: true |
|
||||
|
||||
**Essential Imports:**
|
||||
```typescript
|
||||
import bcrypt from 'bcrypt';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { z } from 'zod';
|
||||
import { jwtService } from '@goodgo/auth-sdk';
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Detailed Code Examples](./references/REFERENCE.md) - Full implementation examples
|
||||
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
|
||||
- [OWASP API Security Top 10](https://owasp.org/www-project-api-security/)
|
||||
- [Node.js Security Best Practices](https://nodejs.org/en/docs/guides/security/)
|
||||
|
||||
840
.cursor/skills/security/references/REFERENCE.md
Normal file
840
.cursor/skills/security/references/REFERENCE.md
Normal file
@@ -0,0 +1,840 @@
|
||||
# Security Patterns - Detailed Reference
|
||||
|
||||
This document contains comprehensive code examples and implementation details for the Security skill.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Authentication Middleware](#authentication-middleware)
|
||||
2. [Role-Based Authorization](#role-based-authorization)
|
||||
3. [Resource Ownership Validation](#resource-ownership-validation)
|
||||
4. [Encryption Service](#encryption-service)
|
||||
5. [Password Hashing](#password-hashing)
|
||||
6. [Token Hashing](#token-hashing)
|
||||
7. [Input Validation with Zod](#input-validation-with-zod)
|
||||
8. [File Upload Validation](#file-upload-validation)
|
||||
9. [SQL Injection Prevention](#sql-injection-prevention)
|
||||
10. [Rate Limiting](#rate-limiting)
|
||||
11. [Error Handling Security](#error-handling-security)
|
||||
12. [Secrets Management](#secrets-management)
|
||||
13. [Audit Logging](#audit-logging)
|
||||
14. [Security Headers with Helmet](#security-headers-with-helmet)
|
||||
15. [CORS Configuration](#cors-configuration)
|
||||
16. [Security Testing](#security-testing)
|
||||
17. [Security Anti-Patterns](#security-anti-patterns)
|
||||
18. [Incident Response](#incident-response)
|
||||
|
||||
---
|
||||
|
||||
## Authentication Middleware
|
||||
|
||||
### JWT Token Validation
|
||||
|
||||
```typescript
|
||||
// src/middlewares/auth.middleware.ts
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { jwtService } from '@goodgo/auth-sdk';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export const authenticate = () => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
// Extract token from Authorization header or cookie
|
||||
let token: string | null = null;
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else if (req.cookies?.access_token) {
|
||||
token = req.cookies.access_token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'AUTH_REQUIRED', message: 'Authentication required' }
|
||||
});
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const payload = await jwtService.verifyAccessToken(token);
|
||||
|
||||
// Attach user to request
|
||||
req.user = {
|
||||
id: payload.sub,
|
||||
userId: payload.sub,
|
||||
email: payload.email,
|
||||
roles: payload.roles || [],
|
||||
permissions: payload.permissions || []
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.warn('Authentication failed', { error: error.message });
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'INVALID_TOKEN', message: 'Invalid or expired token' }
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role-Based Authorization
|
||||
|
||||
### RBAC Middleware
|
||||
|
||||
```typescript
|
||||
// src/middlewares/rbac.middleware.ts
|
||||
export const requireRole = (...allowedRoles: string[]) => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'AUTH_REQUIRED', message: 'Authentication required' }
|
||||
});
|
||||
}
|
||||
|
||||
const userRoles = req.user.roles || [];
|
||||
const hasRole = userRoles.some(role => allowedRoles.includes(role));
|
||||
|
||||
if (!hasRole) {
|
||||
logger.warn('Access denied - insufficient role', {
|
||||
userId: req.user.id,
|
||||
userRoles,
|
||||
requiredRoles: allowedRoles
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Permission-Based Authorization
|
||||
|
||||
```typescript
|
||||
// Permission-based authorization
|
||||
export const requirePermission = (resource: string, action: string) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 'AUTH_REQUIRED', message: 'Authentication required' }
|
||||
});
|
||||
}
|
||||
|
||||
const permission = `${resource}:${action}`;
|
||||
const hasPermission = req.user.permissions?.includes(permission);
|
||||
|
||||
if (!hasPermission) {
|
||||
logger.warn('Access denied - insufficient permission', {
|
||||
userId: req.user.id,
|
||||
required: permission
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' }
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
// Usage in routes
|
||||
router.post(
|
||||
'/api/v1/users',
|
||||
authenticate(),
|
||||
requirePermission('users', 'create'),
|
||||
userController.create
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Resource Ownership Validation
|
||||
|
||||
```typescript
|
||||
// Ensure users can only access their own resources
|
||||
export const requireOwnership = (resourceIdParam: string = 'id') => {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const resourceId = req.params[resourceIdParam];
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (resourceId !== userId) {
|
||||
logger.warn('Access denied - resource ownership mismatch', {
|
||||
userId,
|
||||
resourceId
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 'FORBIDDEN', message: 'Access denied' }
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Encryption Service
|
||||
|
||||
### AES-256-GCM Encryption
|
||||
|
||||
```typescript
|
||||
// src/core/security/encryption.service.ts
|
||||
import crypto from 'crypto';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
export class EncryptionService {
|
||||
private getKey(): Buffer {
|
||||
const secret = process.env.ENCRYPTION_KEY;
|
||||
if (!secret || secret.length < 32) {
|
||||
throw new Error('ENCRYPTION_KEY must be at least 32 characters');
|
||||
}
|
||||
return crypto.scryptSync(secret, 'salt', 32);
|
||||
}
|
||||
|
||||
encrypt(text: string): string {
|
||||
const key = this.getKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return `${iv.toString('hex')}:${tag.toString('hex')}:${encrypted}`;
|
||||
}
|
||||
|
||||
decrypt(encryptedText: string): string {
|
||||
const [ivHex, tagHex, encrypted] = encryptedText.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const tag = Buffer.from(tagHex, 'hex');
|
||||
|
||||
const key = this.getKey();
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage: Encrypt PII before storing
|
||||
const encryption = new EncryptionService();
|
||||
const encryptedPhone = encryption.encrypt(user.phone);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Password Hashing
|
||||
|
||||
### Bcrypt Password Service
|
||||
|
||||
```typescript
|
||||
// Always use bcrypt with appropriate cost factor
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const SALT_ROUNDS = 12; // Production: 12, Development: 10
|
||||
|
||||
export class PasswordService {
|
||||
async hash(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
async verify(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
// Never log passwords
|
||||
sanitizeForLogging(data: any): any {
|
||||
const sanitized = { ...data };
|
||||
if (sanitized.password) sanitized.password = '[REDACTED]';
|
||||
if (sanitized.passwordHash) sanitized.passwordHash = '[REDACTED]';
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Token Hashing
|
||||
|
||||
```typescript
|
||||
// Hash tokens before storing in database
|
||||
import crypto from 'crypto';
|
||||
|
||||
export class TokenService {
|
||||
hashToken(token: string): string {
|
||||
const salt = process.env.TOKEN_SALT || 'default-salt-change-in-production';
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(token + salt)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
generateSecureToken(length: number = 32): string {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Input Validation with Zod
|
||||
|
||||
### DTO Schema Validation
|
||||
|
||||
```typescript
|
||||
// Always validate inputs with Zod
|
||||
import { z } from 'zod';
|
||||
|
||||
// DTO with validation
|
||||
export const CreateUserDto = z.object({
|
||||
email: z.string().email('Invalid email format'),
|
||||
password: z.string()
|
||||
.min(8, 'Password must be at least 8 characters')
|
||||
.regex(/[A-Z]/, 'Password must contain uppercase letter')
|
||||
.regex(/[a-z]/, 'Password must contain lowercase letter')
|
||||
.regex(/[0-9]/, 'Password must contain number')
|
||||
.regex(/[^A-Za-z0-9]/, 'Password must contain special character'),
|
||||
phone: z.string()
|
||||
.regex(/^\+[1-9]\d{1,14}$/, 'Invalid phone format (E.164)')
|
||||
.optional(),
|
||||
name: z.string().min(1).max(255)
|
||||
});
|
||||
|
||||
// In controller
|
||||
export class UserController {
|
||||
async create(req: Request, res: Response) {
|
||||
try {
|
||||
const dto = CreateUserDto.parse(req.body);
|
||||
const user = await this.service.create(dto);
|
||||
res.status(201).json({ success: true, data: user });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input data',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Upload Validation
|
||||
|
||||
```typescript
|
||||
// Validate file uploads
|
||||
import fileType from 'file-type';
|
||||
|
||||
export class FileValidationService {
|
||||
private readonly MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
private readonly ALLOWED_TYPES = ['image/jpeg', 'image/png', 'application/pdf'];
|
||||
|
||||
async validateFile(file: Express.Multer.File): Promise<void> {
|
||||
// Size check
|
||||
if (file.size > this.MAX_FILE_SIZE) {
|
||||
throw new HttpError(400, 'FILE_TOO_LARGE', 'File exceeds maximum size');
|
||||
}
|
||||
|
||||
// Type check
|
||||
if (!this.ALLOWED_TYPES.includes(file.mimetype)) {
|
||||
throw new HttpError(400, 'INVALID_FILE_TYPE', 'File type not allowed');
|
||||
}
|
||||
|
||||
// Content validation (prevent MIME type spoofing)
|
||||
const type = await fileType.fromBuffer(file.buffer);
|
||||
if (!type || !this.ALLOWED_TYPES.includes(type.mime)) {
|
||||
throw new HttpError(400, 'INVALID_FILE_CONTENT', 'File content mismatch');
|
||||
}
|
||||
|
||||
// TODO: Add virus scanning for production
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SQL Injection Prevention
|
||||
|
||||
```typescript
|
||||
// Always use Prisma parameterized queries (automatic)
|
||||
// Never use string concatenation for queries
|
||||
|
||||
// BAD - Never do this
|
||||
const query = `SELECT * FROM users WHERE email = '${email}'`;
|
||||
|
||||
// GOOD - Use Prisma
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email }
|
||||
});
|
||||
|
||||
// GOOD - For dynamic queries
|
||||
const where: any = {};
|
||||
if (email) where.email = email;
|
||||
if (status) where.status = status;
|
||||
|
||||
const users = await prisma.user.findMany({ where });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### Redis-Backed Rate Limiter
|
||||
|
||||
```typescript
|
||||
// Implement rate limiting for all endpoints
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL);
|
||||
|
||||
// Standard rate limit
|
||||
export const standardLimiter = rateLimit({
|
||||
store: new RedisStore({
|
||||
client: redis,
|
||||
prefix: 'rl:standard:'
|
||||
}),
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // 100 requests per window
|
||||
message: 'Too many requests, please try again later',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false
|
||||
});
|
||||
|
||||
// Strict rate limit for sensitive operations
|
||||
export const strictLimiter = rateLimit({
|
||||
store: new RedisStore({
|
||||
client: redis,
|
||||
prefix: 'rl:strict:'
|
||||
}),
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 10,
|
||||
message: 'Rate limit exceeded for this operation'
|
||||
});
|
||||
|
||||
// Login-specific rate limit
|
||||
export const loginLimiter = rateLimit({
|
||||
store: new RedisStore({
|
||||
client: redis,
|
||||
prefix: 'rl:login:'
|
||||
}),
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5, // 5 login attempts per 15 minutes
|
||||
skipSuccessfulRequests: true,
|
||||
message: 'Too many login attempts, please try again later'
|
||||
});
|
||||
|
||||
// Usage
|
||||
router.post('/api/v1/auth/login', loginLimiter, authController.login);
|
||||
router.post('/api/v1/users', authenticate(), strictLimiter, userController.create);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling Security
|
||||
|
||||
### Secure Error Handler
|
||||
|
||||
```typescript
|
||||
// Sanitize error messages to prevent information disclosure
|
||||
export class SecureErrorHandler {
|
||||
handleError(error: Error, req: Request, res: Response) {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Log full error internally
|
||||
logger.error('Request error', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userId: req.user?.id
|
||||
});
|
||||
|
||||
// Don't expose user existence
|
||||
if (error.message.includes('user not found') ||
|
||||
error.message.includes('invalid credentials')) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INVALID_CREDENTIALS',
|
||||
message: 'Invalid email or password'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validation errors - safe to expose
|
||||
if (error instanceof z.ZodError) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'Invalid input data',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generic errors for production
|
||||
if (isProd) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'An error occurred. Please try again later.'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Detailed errors only in development
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error.message,
|
||||
stack: isDev ? error.stack : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secrets Management
|
||||
|
||||
### Environment Variable Validation
|
||||
|
||||
```typescript
|
||||
// Never hardcode secrets
|
||||
// Always use environment variables with validation
|
||||
import { z } from 'zod';
|
||||
|
||||
const secretsSchema = z.object({
|
||||
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
|
||||
JWT_REFRESH_SECRET: z.string().min(32),
|
||||
DATABASE_URL: z.string().url(),
|
||||
REDIS_URL: z.string().url().optional(),
|
||||
ENCRYPTION_KEY: z.string().min(32).optional()
|
||||
});
|
||||
|
||||
export const secrets = secretsSchema.parse(process.env);
|
||||
|
||||
// For production, use secret management:
|
||||
// - AWS Secrets Manager
|
||||
// - HashiCorp Vault
|
||||
// - Kubernetes Secrets
|
||||
// - Azure Key Vault
|
||||
|
||||
// Rotate secrets regularly (quarterly recommended)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Audit Logging
|
||||
|
||||
### Audit Service Implementation
|
||||
|
||||
```typescript
|
||||
// Log all security-relevant events
|
||||
export class AuditService {
|
||||
async logSecurityEvent(
|
||||
event: string,
|
||||
userId: string | null,
|
||||
details: Record<string, any>,
|
||||
req?: Request
|
||||
) {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
event,
|
||||
userId,
|
||||
type: 'SECURITY',
|
||||
details: this.sanitizeDetails(details),
|
||||
ipAddress: req?.ip || details.ipAddress,
|
||||
userAgent: req?.get('user-agent'),
|
||||
timestamp: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize PII from logs
|
||||
private sanitizeDetails(details: Record<string, any>): Record<string, any> {
|
||||
const sensitive = ['password', 'token', 'secret', 'ssn', 'creditCard'];
|
||||
const sanitized = { ...details };
|
||||
|
||||
for (const key of sensitive) {
|
||||
if (sanitized[key]) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
await auditService.logSecurityEvent('LOGIN_SUCCESS', user.id, {
|
||||
email: user.email,
|
||||
ipAddress: req.ip
|
||||
}, req);
|
||||
|
||||
await auditService.logSecurityEvent('PERMISSION_DENIED', user.id, {
|
||||
resource: 'users',
|
||||
action: 'delete',
|
||||
targetId: targetUserId
|
||||
}, req);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Headers with Helmet
|
||||
|
||||
```typescript
|
||||
// Add security headers middleware
|
||||
import helmet from 'helmet';
|
||||
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
scriptSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "https:"]
|
||||
}
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
}
|
||||
}));
|
||||
|
||||
// Additional headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
next();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CORS Configuration
|
||||
|
||||
```typescript
|
||||
// Configure CORS securely
|
||||
import cors from 'cors';
|
||||
|
||||
const allowedOrigins = process.env.CORS_ORIGIN?.split(',') || [];
|
||||
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||
exposedHeaders: ['X-Request-ID'],
|
||||
maxAge: 86400 // 24 hours
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Testing
|
||||
|
||||
### Test Patterns
|
||||
|
||||
```typescript
|
||||
// Security test patterns
|
||||
describe('Security Tests', () => {
|
||||
it('should prevent SQL injection', async () => {
|
||||
const maliciousInput = "'; DROP TABLE users; --";
|
||||
const response = await request(app)
|
||||
.get(`/api/v1/users?search=${encodeURIComponent(maliciousInput)}`)
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
expect(response.status).not.toBe(500);
|
||||
// Should return 400 or empty results, not crash
|
||||
});
|
||||
|
||||
it('should prevent XSS attacks', async () => {
|
||||
const xssPayload = '<script>alert("XSS")</script>';
|
||||
const response = await request(app)
|
||||
.post('/api/v1/users')
|
||||
.send({ email: xssPayload, password: 'test123' });
|
||||
|
||||
// Response should sanitize or reject
|
||||
expect(response.body.data?.email).not.toContain('<script>');
|
||||
});
|
||||
|
||||
it('should enforce authentication', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/v1/users');
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('should enforce authorization', async () => {
|
||||
const userToken = await createUserToken({ roles: ['user'] });
|
||||
const response = await request(app)
|
||||
.delete('/api/v1/users/123')
|
||||
.set('Authorization', `Bearer ${userToken}`);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should rate limit excessive requests', async () => {
|
||||
const requests = Array(20).fill(null).map(() =>
|
||||
request(app).get('/api/v1/users')
|
||||
);
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
const rateLimited = responses.filter(r => r.status === 429);
|
||||
|
||||
expect(rateLimited.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Anti-Patterns
|
||||
|
||||
### Examples of What NOT to Do
|
||||
|
||||
```typescript
|
||||
// BAD: Hardcoded secrets
|
||||
const SECRET = 'my-secret-key';
|
||||
|
||||
// GOOD: Environment variables
|
||||
const SECRET = process.env.JWT_SECRET;
|
||||
|
||||
// BAD: Plain text passwords
|
||||
await prisma.user.create({ data: { password: password } });
|
||||
|
||||
// GOOD: Hashed passwords
|
||||
await prisma.user.create({
|
||||
data: { passwordHash: await bcrypt.hash(password, 12) }
|
||||
});
|
||||
|
||||
// BAD: Exposing user existence
|
||||
if (!user) {
|
||||
throw new Error('User not found'); // Reveals user doesn't exist
|
||||
}
|
||||
|
||||
// GOOD: Generic error
|
||||
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// BAD: No input validation
|
||||
const email = req.body.email;
|
||||
|
||||
// GOOD: Validate with Zod
|
||||
const { email } = CreateUserDto.parse(req.body);
|
||||
|
||||
// BAD: Stack traces in production
|
||||
res.status(500).json({ error: error.stack });
|
||||
|
||||
// GOOD: Sanitized errors
|
||||
res.status(500).json({
|
||||
error: { code: 'INTERNAL_ERROR', message: 'An error occurred' }
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Incident Response
|
||||
|
||||
### Security Incident Detection
|
||||
|
||||
```typescript
|
||||
// Security incident detection and response
|
||||
export class SecurityIncidentService {
|
||||
async detectAnomaly(userId: string, event: string, context: any) {
|
||||
// Check for suspicious patterns
|
||||
const recentEvents = await this.getRecentEvents(userId, '1h');
|
||||
|
||||
if (recentEvents.length > 10) {
|
||||
await this.triggerAlert('SUSPICIOUS_ACTIVITY', {
|
||||
userId,
|
||||
eventCount: recentEvents.length,
|
||||
timeWindow: '1h'
|
||||
});
|
||||
}
|
||||
|
||||
// Check for privilege escalation attempts
|
||||
if (event === 'PERMISSION_DENIED' && context.requiredPermission) {
|
||||
await this.logSecurityEvent('PRIVILEGE_ESCALATION_ATTEMPT', userId, context);
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAlert(type: string, details: any) {
|
||||
// Send to monitoring system
|
||||
logger.error('Security alert', { type, details });
|
||||
|
||||
// TODO: Integrate with PagerDuty, Slack, etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security Checklist
|
||||
|
||||
Before deploying any service:
|
||||
|
||||
- [ ] All endpoints require authentication (except public)
|
||||
- [ ] Authorization checks implemented (RBAC/ABAC)
|
||||
- [ ] Input validation with Zod schemas
|
||||
- [ ] Rate limiting configured
|
||||
- [ ] Error messages sanitized (no info disclosure)
|
||||
- [ ] PII encrypted at rest
|
||||
- [ ] Passwords hashed with bcrypt (cost 12+)
|
||||
- [ ] Tokens hashed before storing
|
||||
- [ ] Secrets in environment variables (never hardcoded)
|
||||
- [ ] HTTPS enforced (TLS 1.2+)
|
||||
- [ ] CORS configured correctly
|
||||
- [ ] Security headers set (helmet)
|
||||
- [ ] Audit logging enabled
|
||||
- [ ] SQL injection prevented (use Prisma)
|
||||
- [ ] XSS prevention (input sanitization)
|
||||
- [ ] File upload validation
|
||||
- [ ] Security tests passing
|
||||
- [ ] Dependencies scanned for vulnerabilities
|
||||
- [ ] Secrets rotation plan in place
|
||||
@@ -0,0 +1,354 @@
|
||||
# Service Discovery & Registry - Detailed Reference
|
||||
|
||||
This reference contains detailed code examples for service discovery patterns.
|
||||
|
||||
## Kubernetes Service Discovery
|
||||
|
||||
### DNS-Based Discovery
|
||||
|
||||
```yaml
|
||||
# Kubernetes automatically creates DNS records
|
||||
# Service: user-service.namespace.svc.cluster.local
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: user-service
|
||||
namespace: production
|
||||
spec:
|
||||
selector:
|
||||
app: user-service
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 5000
|
||||
```
|
||||
|
||||
### Service Discovery in Code
|
||||
|
||||
```typescript
|
||||
// src/core/discovery/kubernetes-discovery.ts
|
||||
export class KubernetesServiceDiscovery {
|
||||
/**
|
||||
* Get service URL using Kubernetes DNS
|
||||
*/
|
||||
getServiceUrl(serviceName: string, namespace: string = 'default'): string {
|
||||
// Kubernetes DNS format: service.namespace.svc.cluster.local
|
||||
return `http://${serviceName}.${namespace}.svc.cluster.local`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service URL with port
|
||||
*/
|
||||
getServiceUrlWithPort(serviceName: string, port: number, namespace: string = 'default'): string {
|
||||
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const discovery = new KubernetesServiceDiscovery();
|
||||
const userServiceUrl = discovery.getServiceUrl('user-service', 'production');
|
||||
```
|
||||
|
||||
## Service Registry Pattern
|
||||
|
||||
### Service Registry Implementation
|
||||
|
||||
```typescript
|
||||
// src/core/registry/service-registry.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
url: string;
|
||||
healthCheckUrl: string;
|
||||
status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
lastHeartbeat: Date;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export class ServiceRegistry {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
/**
|
||||
* Register service
|
||||
*/
|
||||
async register(serviceInfo: ServiceInfo): Promise<void> {
|
||||
await this.prisma.serviceRegistry.upsert({
|
||||
where: { name: serviceInfo.name },
|
||||
update: {
|
||||
version: serviceInfo.version,
|
||||
url: serviceInfo.url,
|
||||
healthCheckUrl: serviceInfo.healthCheckUrl,
|
||||
status: serviceInfo.status,
|
||||
lastHeartbeat: new Date(),
|
||||
metadata: serviceInfo.metadata || {},
|
||||
},
|
||||
create: {
|
||||
name: serviceInfo.name,
|
||||
version: serviceInfo.version,
|
||||
url: serviceInfo.url,
|
||||
healthCheckUrl: serviceInfo.healthCheckUrl,
|
||||
status: serviceInfo.status,
|
||||
lastHeartbeat: new Date(),
|
||||
metadata: serviceInfo.metadata || {},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info('Service registered', { serviceName: serviceInfo.name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover service
|
||||
*/
|
||||
async discover(serviceName: string): Promise<ServiceInfo | null> {
|
||||
const service = await this.prisma.serviceRegistry.findUnique({
|
||||
where: { name: serviceName },
|
||||
});
|
||||
|
||||
if (!service || service.status !== 'healthy') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: service.name,
|
||||
version: service.version,
|
||||
url: service.url,
|
||||
healthCheckUrl: service.healthCheckUrl,
|
||||
status: service.status as 'healthy' | 'unhealthy' | 'unknown',
|
||||
lastHeartbeat: service.lastHeartbeat,
|
||||
metadata: service.metadata as Record<string, any> || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all healthy services
|
||||
*/
|
||||
async listHealthyServices(): Promise<ServiceInfo[]> {
|
||||
const services = await this.prisma.serviceRegistry.findMany({
|
||||
where: {
|
||||
status: 'healthy',
|
||||
lastHeartbeat: {
|
||||
gte: new Date(Date.now() - 60000), // Last heartbeat within 1 minute
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return services.map((s) => ({
|
||||
name: s.name,
|
||||
version: s.version,
|
||||
url: s.url,
|
||||
healthCheckUrl: s.healthCheckUrl,
|
||||
status: s.status as 'healthy',
|
||||
lastHeartbeat: s.lastHeartbeat,
|
||||
metadata: s.metadata as Record<string, any> || {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister service
|
||||
*/
|
||||
async unregister(serviceName: string): Promise<void> {
|
||||
await this.prisma.serviceRegistry.delete({
|
||||
where: { name: serviceName },
|
||||
});
|
||||
|
||||
logger.info('Service unregistered', { serviceName });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Service Registration on Startup
|
||||
|
||||
```typescript
|
||||
// src/core/registry/service-registration.ts
|
||||
import { ServiceRegistry, ServiceInfo } from './service-registry';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export class ServiceRegistration {
|
||||
private registry: ServiceRegistry;
|
||||
private heartbeatInterval?: NodeJS.Timeout;
|
||||
private serviceInfo: ServiceInfo;
|
||||
|
||||
constructor(registry: ServiceRegistry, serviceInfo: ServiceInfo) {
|
||||
this.registry = registry;
|
||||
this.serviceInfo = serviceInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register and start heartbeat
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
// Initial registration
|
||||
await this.registry.register(this.serviceInfo);
|
||||
|
||||
// Start heartbeat
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
try {
|
||||
// Check health and update registry
|
||||
const isHealthy = await this.checkHealth();
|
||||
await this.registry.register({
|
||||
...this.serviceInfo,
|
||||
status: isHealthy ? 'healthy' : 'unhealthy',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Heartbeat failed', { error });
|
||||
}
|
||||
}, 30000); // Every 30 seconds
|
||||
|
||||
logger.info('Service registration started', { serviceName: this.serviceInfo.name });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop registration and cleanup
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
|
||||
await this.registry.unregister(this.serviceInfo.name);
|
||||
logger.info('Service registration stopped', { serviceName: this.serviceInfo.name });
|
||||
}
|
||||
|
||||
private async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(this.serviceInfo.healthCheckUrl, {
|
||||
timeout: 5000,
|
||||
});
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in main.ts
|
||||
const serviceRegistry = new ServiceRegistry(prisma);
|
||||
const serviceRegistration = new ServiceRegistration(serviceRegistry, {
|
||||
name: process.env.SERVICE_NAME || 'unknown-service',
|
||||
version: process.env.SERVICE_VERSION || '1.0.0',
|
||||
url: `http://${process.env.SERVICE_NAME}:${process.env.PORT}`,
|
||||
healthCheckUrl: `http://localhost:${process.env.PORT}/health`,
|
||||
status: 'healthy',
|
||||
lastHeartbeat: new Date(),
|
||||
});
|
||||
|
||||
await serviceRegistration.start();
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
await serviceRegistration.stop();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
## Health Check Orchestration
|
||||
|
||||
### Aggregated Health Check
|
||||
|
||||
```typescript
|
||||
// src/core/health/health-aggregator.ts
|
||||
import { ServiceRegistry } from '../registry/service-registry';
|
||||
import { logger } from '@goodgo/logger';
|
||||
|
||||
export class HealthAggregator {
|
||||
constructor(private serviceRegistry: ServiceRegistry) {}
|
||||
|
||||
/**
|
||||
* Get aggregated health status
|
||||
*/
|
||||
async getAggregatedHealth(): Promise<{
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
services: Array<{ name: string; status: string; lastHeartbeat: Date }>;
|
||||
}> {
|
||||
const services = await this.serviceRegistry.listHealthyServices();
|
||||
|
||||
const serviceStatuses = services.map((s) => ({
|
||||
name: s.name,
|
||||
status: s.status,
|
||||
lastHeartbeat: s.lastHeartbeat,
|
||||
}));
|
||||
|
||||
const unhealthyCount = serviceStatuses.filter((s) => s.status !== 'healthy').length;
|
||||
const totalCount = serviceStatuses.length;
|
||||
|
||||
let overallStatus: 'healthy' | 'degraded' | 'unhealthy';
|
||||
if (unhealthyCount === 0) {
|
||||
overallStatus = 'healthy';
|
||||
} else if (unhealthyCount < totalCount / 2) {
|
||||
overallStatus = 'degraded';
|
||||
} else {
|
||||
overallStatus = 'unhealthy';
|
||||
}
|
||||
|
||||
return {
|
||||
status: overallStatus,
|
||||
services: serviceStatuses,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Load Balancing Strategies
|
||||
|
||||
### Client-Side Load Balancing
|
||||
|
||||
```typescript
|
||||
// src/core/discovery/load-balancer.ts
|
||||
export class LoadBalancer {
|
||||
/**
|
||||
* Round-robin load balancing
|
||||
*/
|
||||
roundRobin<T>(items: T[]): T {
|
||||
const index = Math.floor(Math.random() * items.length);
|
||||
return items[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Least connections load balancing
|
||||
*/
|
||||
leastConnections<T extends { connections: number }>(items: T[]): T {
|
||||
return items.reduce((min, item) =>
|
||||
item.connections < min.connections ? item : min
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Weighted round-robin
|
||||
*/
|
||||
weightedRoundRobin<T extends { weight: number }>(items: T[]): T {
|
||||
const totalWeight = items.reduce((sum, item) => sum + item.weight, 0);
|
||||
let random = Math.random() * totalWeight;
|
||||
|
||||
for (const item of items) {
|
||||
random -= item.weight;
|
||||
if (random <= 0) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return items[items.length - 1];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Service Mesh Integration
|
||||
|
||||
### Istio Service Discovery
|
||||
|
||||
```yaml
|
||||
# Istio automatically handles service discovery
|
||||
apiVersion: networking.istio.io/v1alpha3
|
||||
kind: ServiceEntry
|
||||
metadata:
|
||||
name: external-service
|
||||
spec:
|
||||
hosts:
|
||||
- external-api.example.com
|
||||
ports:
|
||||
- number: 443
|
||||
name: https
|
||||
protocol: HTTPS
|
||||
location: MESH_EXTERNAL
|
||||
resolution: DNS
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: testing-patterns
|
||||
description: Testing best practices for GoodGo microservices. Use for unit tests, integration tests, E2E tests, Jest setup, mocking, or debugging.
|
||||
dependencies: "jest>=29, supertest>=6, jest-mock-extended"
|
||||
compatibility: "jest>=29, supertest>=6, jest-mock-extended"
|
||||
---
|
||||
|
||||
# Testing Patterns for GoodGo Microservices
|
||||
@@ -21,194 +21,43 @@ Use this skill when:
|
||||
|
||||
### Test Types
|
||||
|
||||
1. **Unit Tests**: Test individual functions/classes in isolation
|
||||
- Location: Next to source files (`*.test.ts`)
|
||||
- Scope: Single function or class
|
||||
- Dependencies: Mocked
|
||||
- Speed: Fast (<1s per test)
|
||||
| Type | Location | Speed | Dependencies |
|
||||
|------|----------|-------|--------------|
|
||||
| **Unit** | `*.test.ts` (next to source) | <1s | All mocked |
|
||||
| **Integration** | `__tests__/` | 1-5s | Partial mocking |
|
||||
| **E2E** | `__tests__/*.e2e.ts` | 5-10s | Test DB, mocked external |
|
||||
|
||||
2. **Integration Tests**: Test component interactions
|
||||
- Location: `__tests__/` directory
|
||||
- Scope: Multiple components working together
|
||||
- Dependencies: Some real, some mocked
|
||||
- Speed: Medium (1-5s per test)
|
||||
## Key Patterns
|
||||
|
||||
3. **E2E Tests**: Test complete request/response cycles
|
||||
- Location: `__tests__/*.e2e.ts`
|
||||
- Scope: Full API workflow
|
||||
- Dependencies: Test database, mocked external services
|
||||
- Speed: Slow (5-10s per test)
|
||||
|
||||
## Jest Configuration
|
||||
### Jest Configuration
|
||||
|
||||
```typescript
|
||||
// jest.config.ts
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
export default {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.test.ts',
|
||||
'**/__tests__/**/*.e2e.ts',
|
||||
'**/?(*.)+(spec|test).ts'
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/main.ts',
|
||||
'!src/config/**/*.ts'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70
|
||||
}
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.ts', '**/__tests__/**/*.e2e.ts'],
|
||||
coverageThreshold: { global: { branches: 70, functions: 70, lines: 70 } },
|
||||
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setupTests.ts'],
|
||||
testTimeout: 10000,
|
||||
clearMocks: true
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Setup Files
|
||||
|
||||
```typescript
|
||||
// src/__tests__/setupTests.ts
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Mock Prisma
|
||||
jest.mock('../prisma', () => ({
|
||||
__esModule: true,
|
||||
default: mockDeep<PrismaClient>()
|
||||
}));
|
||||
|
||||
// Mock Redis
|
||||
jest.mock('ioredis', () => {
|
||||
const Redis = jest.requireActual('ioredis-mock');
|
||||
return Redis;
|
||||
});
|
||||
|
||||
// Global test utilities
|
||||
global.testUtils = {
|
||||
generateId: () => `test-${Date.now()}`,
|
||||
createMockRequest: () => ({
|
||||
headers: {},
|
||||
body: {},
|
||||
query: {},
|
||||
params: {}
|
||||
}),
|
||||
createMockResponse: () => {
|
||||
const res: any = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Unit Test Pattern
|
||||
|
||||
```typescript
|
||||
// feature.service.test.ts
|
||||
import { FeatureService } from './feature.service';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
|
||||
describe('FeatureService', () => {
|
||||
let service: FeatureService;
|
||||
let mockRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = {
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn()
|
||||
};
|
||||
mockRepository = { findById: jest.fn(), create: jest.fn() };
|
||||
service = new FeatureService(mockRepository);
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return feature when found', async () => {
|
||||
// Arrange
|
||||
const mockFeature = { id: '1', name: 'Test Feature' };
|
||||
mockRepository.findById.mockResolvedValue(mockFeature);
|
||||
|
||||
// Act
|
||||
const result = await service.findById('1');
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockFeature);
|
||||
expect(mockRepository.findById).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should throw error when feature not found', async () => {
|
||||
// Arrange
|
||||
mockRepository.findById.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.findById('999')).rejects.toThrow('Feature not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Test Pattern
|
||||
|
||||
```typescript
|
||||
// auth.middleware.test.ts
|
||||
import { authMiddleware } from '../auth.middleware';
|
||||
import { createMockRequest, createMockResponse } from '../../test-utils';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
const mockNext = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call next when valid token provided', async () => {
|
||||
// Arrange
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
const token = jwt.sign({ userId: '123' }, 'secret');
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
// Act
|
||||
await authMiddleware(req, res, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
expect(req.user).toEqual({ userId: '123' });
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
// Arrange
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
|
||||
// Act
|
||||
await authMiddleware(req, res, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
it('should return feature when found', async () => {
|
||||
mockRepository.findById.mockResolvedValue({ id: '1', name: 'Test' });
|
||||
const result = await service.findById('1');
|
||||
expect(result).toEqual({ id: '1', name: 'Test' });
|
||||
});
|
||||
});
|
||||
```
|
||||
@@ -216,320 +65,78 @@ describe('Auth Middleware', () => {
|
||||
### E2E Test Pattern
|
||||
|
||||
```typescript
|
||||
// feature.e2e.ts
|
||||
import supertest from 'supertest';
|
||||
import { createApp } from '../app';
|
||||
import { prisma } from '../prisma';
|
||||
describe('POST /api/features', () => {
|
||||
it('should create a new feature', async () => {
|
||||
const response = await request
|
||||
.post('/api/features')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ name: 'New Feature' })
|
||||
.expect(201);
|
||||
|
||||
describe('Feature API E2E', () => {
|
||||
let app: any;
|
||||
let request: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
request = supertest(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.feature.deleteMany();
|
||||
});
|
||||
|
||||
describe('POST /api/features', () => {
|
||||
it('should create a new feature', async () => {
|
||||
// Arrange
|
||||
const featureData = {
|
||||
name: 'New Feature',
|
||||
description: 'Feature description'
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = await request
|
||||
.post('/api/features')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(featureData)
|
||||
.expect(201);
|
||||
|
||||
// Assert
|
||||
expect(response.body).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
name: 'New Feature',
|
||||
description: 'Feature description'
|
||||
}
|
||||
});
|
||||
|
||||
const created = await prisma.feature.findFirst({
|
||||
where: { name: 'New Feature' }
|
||||
});
|
||||
expect(created).toBeDefined();
|
||||
});
|
||||
expect(response.body).toMatchObject({ success: true, data: { name: 'New Feature' } });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking Strategies
|
||||
|
||||
### Mock Prisma
|
||||
|
||||
```typescript
|
||||
// __mocks__/prisma.ts
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';
|
||||
|
||||
export const prismaMock = mockDeep<PrismaClient>();
|
||||
jest.mock('../prisma', () => ({ default: prismaMock }));
|
||||
|
||||
jest.mock('../src/prisma', () => ({
|
||||
__esModule: true,
|
||||
default: prismaMock,
|
||||
}));
|
||||
|
||||
// Usage in tests
|
||||
import { prismaMock } from '../__mocks__/prisma';
|
||||
|
||||
test('should create user', async () => {
|
||||
const user = { id: '1', email: 'test@example.com' };
|
||||
prismaMock.user.create.mockResolvedValue(user);
|
||||
|
||||
const result = await createUser({ email: 'test@example.com' });
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
// Usage
|
||||
prismaMock.user.create.mockResolvedValue({ id: '1', email: 'test@example.com' });
|
||||
```
|
||||
|
||||
### Mock Redis
|
||||
### Test Factory
|
||||
|
||||
```typescript
|
||||
// __mocks__/redis.ts
|
||||
import Redis from 'ioredis-mock';
|
||||
|
||||
export const redisMock = new Redis();
|
||||
|
||||
// Usage in tests
|
||||
test('should cache value', async () => {
|
||||
const cache = new CacheService(redisMock);
|
||||
await cache.set('key', 'value');
|
||||
|
||||
const result = await cache.get('key');
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
```
|
||||
|
||||
### Mock External APIs
|
||||
|
||||
```typescript
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
import axios from 'axios';
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
test('should fetch external data', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: { result: 'success' }
|
||||
});
|
||||
|
||||
const result = await fetchExternalData();
|
||||
expect(result).toEqual({ result: 'success' });
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Utilities
|
||||
|
||||
```typescript
|
||||
// test-utils.ts
|
||||
export class TestFactory {
|
||||
static createUser(overrides = {}) {
|
||||
return {
|
||||
id: 'test-user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
createdAt: new Date(),
|
||||
...overrides
|
||||
};
|
||||
return { id: 'test-user-1', email: 'test@example.com', ...overrides };
|
||||
}
|
||||
|
||||
static createAuthToken(userId: string) {
|
||||
return jwt.sign({ userId }, 'test-secret');
|
||||
}
|
||||
|
||||
static async cleanDatabase() {
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.feature.deleteMany();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const user = TestFactory.createUser({ name: 'Custom Name' });
|
||||
const token = TestFactory.createAuthToken(user.id);
|
||||
```
|
||||
|
||||
## Common Test Scenarios
|
||||
|
||||
### Testing Error Handling
|
||||
|
||||
```typescript
|
||||
test('should handle database errors gracefully', async () => {
|
||||
prismaMock.user.findUnique.mockRejectedValue(
|
||||
new Error('Database connection failed')
|
||||
);
|
||||
|
||||
const response = await request
|
||||
.get('/api/users/123')
|
||||
.expect(500);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Internal server error'
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Validation
|
||||
|
||||
```typescript
|
||||
describe('Validation', () => {
|
||||
it('should reject invalid email', async () => {
|
||||
const response = await request
|
||||
.post('/api/auth/register')
|
||||
.send({ email: 'invalid-email', password: '123456' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Pagination
|
||||
|
||||
```typescript
|
||||
test('should paginate results', async () => {
|
||||
// Create test data
|
||||
const items = Array(25).fill(null).map((_, i) => ({
|
||||
id: `item-${i}`,
|
||||
name: `Item ${i}`
|
||||
}));
|
||||
|
||||
prismaMock.item.findMany.mockResolvedValue(items.slice(0, 10));
|
||||
prismaMock.item.count.mockResolvedValue(25);
|
||||
|
||||
const response = await request
|
||||
.get('/api/items?page=1&limit=10')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: items.slice(0, 10),
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 25,
|
||||
totalPages: 3
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:unit": "jest --testPathPattern=\\.test\\.ts$",
|
||||
"test:e2e": "jest --testPathPattern=\\.e2e\\.ts$",
|
||||
"test:ci": "jest --coverage --silent --maxWorkers=2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Debugging Tests
|
||||
## Best Practices
|
||||
|
||||
### Debug with VS Code
|
||||
|
||||
```json
|
||||
// .vscode/launch.json
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Jest Tests",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["test", "--", "--runInBand"],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Debug Tips
|
||||
|
||||
1. Use `test.only()` to run single test
|
||||
2. Use `--detectOpenHandles` for async issues
|
||||
3. Use `--runInBand` for sequential execution
|
||||
4. Add `console.log()` statements temporarily
|
||||
5. Use debugger breakpoints in VS Code
|
||||
|
||||
## Best Practices Checklist
|
||||
|
||||
- [ ] Each test is independent and isolated
|
||||
- [ ] Tests follow AAA pattern (Arrange-Act-Assert)
|
||||
- [ ] Mock external dependencies
|
||||
- [ ] Test edge cases and error scenarios
|
||||
- [ ] Keep tests simple and focused
|
||||
- [ ] Use descriptive test names
|
||||
- [ ] Maintain >70% code coverage
|
||||
- [ ] Run tests before committing
|
||||
- [ ] Keep test data realistic
|
||||
- [ ] Clean up after tests
|
||||
- Each test is independent and isolated
|
||||
- Tests follow AAA pattern (Arrange-Act-Assert)
|
||||
- Mock external dependencies
|
||||
- Test edge cases and error scenarios
|
||||
- Keep tests simple and focused
|
||||
- Use descriptive test names
|
||||
- Maintain >70% code coverage
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
1. **Testing Implementation Details**: Tests break on refactoring
|
||||
```typescript
|
||||
// ❌ BAD: Testing internal method
|
||||
expect(service['privateMethod']()).toBe(true);
|
||||
|
||||
// ✅ GOOD: Test public behavior
|
||||
expect(await service.processOrder(order)).toEqual({ success: true });
|
||||
// BAD: expect(service['privateMethod']()).toBe(true);
|
||||
// GOOD: expect(await service.processOrder(order)).toEqual({ success: true });
|
||||
```
|
||||
|
||||
2. **Shared Mutable State**: Tests affect each other
|
||||
```typescript
|
||||
// ❌ BAD: Shared state
|
||||
let counter = 0;
|
||||
test('first', () => { counter++; expect(counter).toBe(1); });
|
||||
test('second', () => { expect(counter).toBe(0); }); // Fails!
|
||||
|
||||
// ✅ GOOD: Reset in beforeEach
|
||||
let counter: number;
|
||||
beforeEach(() => { counter = 0; });
|
||||
// BAD: let counter = 0; (shared across tests)
|
||||
// GOOD: beforeEach(() => { counter = 0; });
|
||||
```
|
||||
|
||||
3. **Not Mocking External Services**: Tests are slow and flaky
|
||||
```typescript
|
||||
// ❌ BAD: Real HTTP calls
|
||||
const response = await fetch('https://api.example.com/data');
|
||||
|
||||
// ✅ GOOD: Mock external services
|
||||
jest.spyOn(httpClient, 'get').mockResolvedValue({ data: mockData });
|
||||
// BAD: await fetch('https://api.example.com/data');
|
||||
// GOOD: jest.spyOn(httpClient, 'get').mockResolvedValue({ data: mockData });
|
||||
```
|
||||
|
||||
4. **Missing Edge Cases**: Only testing happy path
|
||||
```typescript
|
||||
// ❌ BAD: Only success case
|
||||
test('creates user', async () => { ... });
|
||||
|
||||
// ✅ GOOD: Include error cases
|
||||
// GOOD: Include error cases
|
||||
test('creates user', async () => { ... });
|
||||
test('throws on duplicate email', async () => { ... });
|
||||
test('validates email format', async () => { ... });
|
||||
@@ -537,12 +144,6 @@ test('should paginate results', async () => {
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Test Type | Location | Speed | Mocking |
|
||||
|-----------|----------|-------|---------|
|
||||
| **Unit** | `*.test.ts` (next to source) | <1s | All dependencies |
|
||||
| **Integration** | `__tests__/` | 1-5s | Partial |
|
||||
| **E2E** | `__tests__/*.e2e.ts` | 5-10s | External APIs only |
|
||||
|
||||
**Coverage Targets:**
|
||||
- Global: 70%+ (branches, functions, lines)
|
||||
- Critical paths: 90%+
|
||||
@@ -564,11 +165,17 @@ import { prismaMock } from '../__mocks__/prisma';
|
||||
import supertest from 'supertest';
|
||||
```
|
||||
|
||||
**Debug Tips:**
|
||||
1. Use `test.only()` to run single test
|
||||
2. Use `--detectOpenHandles` for async issues
|
||||
3. Use `--runInBand` for sequential execution
|
||||
4. Add `console.log()` statements temporarily
|
||||
|
||||
## Resources
|
||||
|
||||
- [Jest Documentation](https://jestjs.io/docs/getting-started) - Testing framework
|
||||
- [Supertest](https://github.com/ladjs/supertest) - HTTP assertions
|
||||
- [jest-mock-extended](https://github.com/marchaos/jest-mock-extended) - TypeScript mocks
|
||||
- [Detailed Code Examples](./references/REFERENCE.md)
|
||||
- [Database Prisma](../database-prisma/SKILL.md) - Database mocking patterns
|
||||
- [Error Handling](../error-handling-patterns/SKILL.md) - Error testing
|
||||
- [Project Rules](../project-rules/SKILL.md) - GoodGo standards
|
||||
431
.cursor/skills/testing-patterns/references/REFERENCE.md
Normal file
431
.cursor/skills/testing-patterns/references/REFERENCE.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# Testing Patterns - Detailed Reference
|
||||
|
||||
This reference contains detailed code examples for testing patterns in GoodGo microservices.
|
||||
|
||||
## Jest Configuration
|
||||
|
||||
```typescript
|
||||
// jest.config.ts
|
||||
import type { Config } from 'jest';
|
||||
|
||||
const config: Config = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.test.ts',
|
||||
'**/__tests__/**/*.e2e.ts',
|
||||
'**/?(*.)+(spec|test).ts'
|
||||
],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/main.ts',
|
||||
'!src/config/**/*.ts'
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
coverageReporters: ['text', 'lcov', 'html'],
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 70,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70
|
||||
}
|
||||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/src/__tests__/setupTests.ts'],
|
||||
testTimeout: 10000,
|
||||
clearMocks: true
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
## Setup Files
|
||||
|
||||
```typescript
|
||||
// src/__tests__/setupTests.ts
|
||||
import { mockDeep, mockReset } from 'jest-mock-extended';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Mock Prisma
|
||||
jest.mock('../prisma', () => ({
|
||||
__esModule: true,
|
||||
default: mockDeep<PrismaClient>()
|
||||
}));
|
||||
|
||||
// Mock Redis
|
||||
jest.mock('ioredis', () => {
|
||||
const Redis = jest.requireActual('ioredis-mock');
|
||||
return Redis;
|
||||
});
|
||||
|
||||
// Global test utilities
|
||||
global.testUtils = {
|
||||
generateId: () => `test-${Date.now()}`,
|
||||
createMockRequest: () => ({
|
||||
headers: {},
|
||||
body: {},
|
||||
query: {},
|
||||
params: {}
|
||||
}),
|
||||
createMockResponse: () => {
|
||||
const res: any = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
```
|
||||
|
||||
## Unit Test Pattern
|
||||
|
||||
```typescript
|
||||
// feature.service.test.ts
|
||||
import { FeatureService } from './feature.service';
|
||||
import { mockDeep } from 'jest-mock-extended';
|
||||
|
||||
describe('FeatureService', () => {
|
||||
let service: FeatureService;
|
||||
let mockRepository: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepository = {
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn()
|
||||
};
|
||||
service = new FeatureService(mockRepository);
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return feature when found', async () => {
|
||||
// Arrange
|
||||
const mockFeature = { id: '1', name: 'Test Feature' };
|
||||
mockRepository.findById.mockResolvedValue(mockFeature);
|
||||
|
||||
// Act
|
||||
const result = await service.findById('1');
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockFeature);
|
||||
expect(mockRepository.findById).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should throw error when feature not found', async () => {
|
||||
// Arrange
|
||||
mockRepository.findById.mockResolvedValue(null);
|
||||
|
||||
// Act & Assert
|
||||
await expect(service.findById('999')).rejects.toThrow('Feature not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Integration Test Pattern
|
||||
|
||||
```typescript
|
||||
// auth.middleware.test.ts
|
||||
import { authMiddleware } from '../auth.middleware';
|
||||
import { createMockRequest, createMockResponse } from '../../test-utils';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
describe('Auth Middleware', () => {
|
||||
const mockNext = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should call next when valid token provided', async () => {
|
||||
// Arrange
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
const token = jwt.sign({ userId: '123' }, 'secret');
|
||||
req.headers.authorization = `Bearer ${token}`;
|
||||
|
||||
// Act
|
||||
await authMiddleware(req, res, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(mockNext).toHaveBeenCalled();
|
||||
expect(req.user).toEqual({ userId: '123' });
|
||||
});
|
||||
|
||||
it('should return 401 when no token provided', async () => {
|
||||
// Arrange
|
||||
const req = createMockRequest();
|
||||
const res = createMockResponse();
|
||||
|
||||
// Act
|
||||
await authMiddleware(req, res, mockNext);
|
||||
|
||||
// Assert
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(mockNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## E2E Test Pattern
|
||||
|
||||
```typescript
|
||||
// feature.e2e.ts
|
||||
import supertest from 'supertest';
|
||||
import { createApp } from '../app';
|
||||
import { prisma } from '../prisma';
|
||||
|
||||
describe('Feature API E2E', () => {
|
||||
let app: any;
|
||||
let request: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createApp();
|
||||
request = supertest(app);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.feature.deleteMany();
|
||||
});
|
||||
|
||||
describe('POST /api/features', () => {
|
||||
it('should create a new feature', async () => {
|
||||
// Arrange
|
||||
const featureData = {
|
||||
name: 'New Feature',
|
||||
description: 'Feature description'
|
||||
};
|
||||
|
||||
// Act
|
||||
const response = await request
|
||||
.post('/api/features')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send(featureData)
|
||||
.expect(201);
|
||||
|
||||
// Assert
|
||||
expect(response.body).toMatchObject({
|
||||
success: true,
|
||||
data: {
|
||||
name: 'New Feature',
|
||||
description: 'Feature description'
|
||||
}
|
||||
});
|
||||
|
||||
const created = await prisma.feature.findFirst({
|
||||
where: { name: 'New Feature' }
|
||||
});
|
||||
expect(created).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking Strategies
|
||||
|
||||
### Mock Prisma
|
||||
|
||||
```typescript
|
||||
// __mocks__/prisma.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { mockDeep, DeepMockProxy } from 'jest-mock-extended';
|
||||
|
||||
export const prismaMock = mockDeep<PrismaClient>();
|
||||
|
||||
jest.mock('../src/prisma', () => ({
|
||||
__esModule: true,
|
||||
default: prismaMock,
|
||||
}));
|
||||
|
||||
// Usage in tests
|
||||
import { prismaMock } from '../__mocks__/prisma';
|
||||
|
||||
test('should create user', async () => {
|
||||
const user = { id: '1', email: 'test@example.com' };
|
||||
prismaMock.user.create.mockResolvedValue(user);
|
||||
|
||||
const result = await createUser({ email: 'test@example.com' });
|
||||
expect(result).toEqual(user);
|
||||
});
|
||||
```
|
||||
|
||||
### Mock Redis
|
||||
|
||||
```typescript
|
||||
// __mocks__/redis.ts
|
||||
import Redis from 'ioredis-mock';
|
||||
|
||||
export const redisMock = new Redis();
|
||||
|
||||
// Usage in tests
|
||||
test('should cache value', async () => {
|
||||
const cache = new CacheService(redisMock);
|
||||
await cache.set('key', 'value');
|
||||
|
||||
const result = await cache.get('key');
|
||||
expect(result).toBe('value');
|
||||
});
|
||||
```
|
||||
|
||||
### Mock External APIs
|
||||
|
||||
```typescript
|
||||
// Mock axios
|
||||
jest.mock('axios');
|
||||
import axios from 'axios';
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
test('should fetch external data', async () => {
|
||||
mockedAxios.get.mockResolvedValue({
|
||||
data: { result: 'success' }
|
||||
});
|
||||
|
||||
const result = await fetchExternalData();
|
||||
expect(result).toEqual({ result: 'success' });
|
||||
});
|
||||
```
|
||||
|
||||
## Testing Utilities
|
||||
|
||||
```typescript
|
||||
// test-utils.ts
|
||||
export class TestFactory {
|
||||
static createUser(overrides = {}) {
|
||||
return {
|
||||
id: 'test-user-1',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
createdAt: new Date(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
static createAuthToken(userId: string) {
|
||||
return jwt.sign({ userId }, 'test-secret');
|
||||
}
|
||||
|
||||
static async cleanDatabase() {
|
||||
await prisma.user.deleteMany();
|
||||
await prisma.feature.deleteMany();
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
const user = TestFactory.createUser({ name: 'Custom Name' });
|
||||
const token = TestFactory.createAuthToken(user.id);
|
||||
```
|
||||
|
||||
## Common Test Scenarios
|
||||
|
||||
### Testing Error Handling
|
||||
|
||||
```typescript
|
||||
test('should handle database errors gracefully', async () => {
|
||||
prismaMock.user.findUnique.mockRejectedValue(
|
||||
new Error('Database connection failed')
|
||||
);
|
||||
|
||||
const response = await request
|
||||
.get('/api/users/123')
|
||||
.expect(500);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: false,
|
||||
error: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Internal server error'
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Validation
|
||||
|
||||
```typescript
|
||||
describe('Validation', () => {
|
||||
it('should reject invalid email', async () => {
|
||||
const response = await request
|
||||
.post('/api/auth/register')
|
||||
.send({ email: 'invalid-email', password: '123456' })
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.code).toBe('VALIDATION_ERROR');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Testing Pagination
|
||||
|
||||
```typescript
|
||||
test('should paginate results', async () => {
|
||||
// Create test data
|
||||
const items = Array(25).fill(null).map((_, i) => ({
|
||||
id: `item-${i}`,
|
||||
name: `Item ${i}`
|
||||
}));
|
||||
|
||||
prismaMock.item.findMany.mockResolvedValue(items.slice(0, 10));
|
||||
prismaMock.item.count.mockResolvedValue(25);
|
||||
|
||||
const response = await request
|
||||
.get('/api/items?page=1&limit=10')
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({
|
||||
success: true,
|
||||
data: items.slice(0, 10),
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
total: 25,
|
||||
totalPages: 3
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## VS Code Debug Configuration
|
||||
|
||||
```json
|
||||
// .vscode/launch.json
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Debug Jest Tests",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["test", "--", "--runInBand"],
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Test Commands
|
||||
|
||||
```json
|
||||
// package.json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"test:unit": "jest --testPathPattern=\\.test\\.ts$",
|
||||
"test:e2e": "jest --testPathPattern=\\.e2e\\.ts$",
|
||||
"test:ci": "jest --coverage --silent --maxWorkers=2"
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user