feat(docs): Remove outdated service templates and enhance Vietnamese architecture documentation

- Deleted obsolete service architecture templates in both English and Vietnamese to streamline content.
- Updated the Vietnamese architecture documentation with improved Mermaid diagrams for better visual clarity.
- Enhanced color coding in diagrams to improve readability and consistency across documentation.
- Added a new section detailing visual indicators for better understanding of architecture components.
This commit is contained in:
Ho Ngoc Hai
2026-01-10 21:00:02 +07:00
parent b89e07f4cb
commit 4e595d0746
50 changed files with 36 additions and 28 deletions

View File

@@ -0,0 +1,552 @@
# Service Template Architecture
This document describes the architecture of a single microservice built from this template and how it integrates with the GoodGo microservices platform.
## Overview
This template provides a complete, production-ready foundation for building individual microservices with:
- **Security**: Authentication, authorization, input validation, and security headers
- **Observability**: Comprehensive logging, metrics, tracing, and health checks
- **Data Management**: Repository pattern, database migrations, and seeding
- **API Documentation**: OpenAPI/Swagger documentation with interactive UI
- **Error Handling**: Structured error responses with proper HTTP status codes
- **Docker Support**: Multi-stage builds and production optimization
**Important Context**: This template represents a **single microservice**. For platform-level deployment and orchestration, services are registered in `deployments/local/docker-compose.yml` and routed through the Traefik API Gateway configured in `infra/traefik/`.
---
# Part 1: Single Service Architecture (Internal)
This section describes the internal architecture of a single microservice built from this template.
## Internal Service Components
```mermaid
graph TD
Request[HTTP Request] -->|From Traefik| Middleware[Middleware Chain]
subgraph SingleService[Single Service Boundary]
Middleware --> Correlation[Correlation ID Middleware]
Correlation --> Auth[Authentication Middleware]
Auth --> Validation[Validation Middleware]
Validation --> Error[Error Handler]
Error --> Logger[Request Logger]
Logger --> Metrics[Metrics Collector]
Metrics --> Router[Router Layer]
Router --> Controller[Controller Layer]
Controller --> Service[Service Layer]
Service --> Repository[Repository Layer]
Repository --> Database[(PostgreSQL)]
Service --> Cache[(Redis)]
Service -.->|Health Status| Health[Health Checks]
Service -.->|API Docs| OpenAPI[OpenAPI/Swagger]
end
Service -.->|Metrics| Prometheus[Prometheus]
Service -.->|Traces| Jaeger[Jaeger]
style Correlation fill:#e1f5fe
style Auth fill:#f3e5f5
style Validation fill:#e8f5e8
style Error fill:#fff3e0
style Logger fill:#f3e5f5
style Metrics fill:#e8f5e8
```
## Layer Architecture
### Middleware Chain
The middleware chain processes every incoming request in order:
1. **Correlation Middleware**: Generates/propagates correlation and request IDs
2. **Authentication Middleware**: Validates JWT tokens (optional for public routes)
3. **Validation Middleware**: Sanitizes and validates input data with Zod schemas
4. **Error Handler**: Catches and formats errors into structured responses
5. **Logger Middleware**: Logs request/response with correlation IDs
6. **Metrics Middleware**: Collects Prometheus metrics (duration, status, payload size)
### Controller Layer
- Handles HTTP requests and responses
- Orchestrates service layer calls
- Formats API responses
- Wraps async handlers for error propagation
### Service Layer
- Contains pure business logic
- Independent of HTTP transport
- Orchestrates repository calls
- Implements caching strategies
- Throws domain-specific errors
### Repository Layer
- Abstracts database operations
- Uses Prisma ORM for type-safe queries
- Implements repository pattern
- Provides consistent error handling
- Supports transactions
## Request Flow
1. **Request Entry**:
- Client sends HTTP request to ingress/load balancer
- Request includes optional correlation ID header (`x-correlation-id`)
2. **Correlation Middleware**:
- Generates or propagates correlation ID for request tracing
- Adds request ID for unique request identification
- Sets correlation headers on response
3. **Security Middleware**:
- **Authentication**: Validates JWT tokens (optional for public routes)
- **Authorization**: Checks user roles and permissions
- **Rate Limiting**: Prevents abuse with Redis-backed rate limiting
- **Helmet**: Secures HTTP headers
4. **Validation Middleware**:
- Sanitizes input data (trimming, normalization)
- Validates request data using Zod schemas
- Returns structured validation errors
5. **Router & Controller**:
- Routes request to appropriate controller
- Controller orchestrates business logic execution
- Input validation and response formatting
6. **Service Layer**:
- Contains pure business logic
- Independent of HTTP transport layer
- Orchestrates data access and external service calls
7. **Repository Layer**:
- Implements repository pattern for data access
- Abstracts database operations with Prisma ORM
- Provides consistent error handling
8. **Response & Observability**:
- Formats structured JSON responses
- Records comprehensive metrics (duration, errors, payload sizes)
- Logs with correlation IDs for distributed tracing
- Sends traces to Jaeger if enabled
## Architecture Patterns
### Repository Pattern
```typescript
// Base repository with common CRUD operations
class BaseRepository<T, CreateInput, UpdateInput> {
async findById(id: string): Promise<T | null>
async create(data: CreateInput): Promise<T>
async update(id: string, data: UpdateInput): Promise<T>
// ... more common methods
}
// Specific repository extends base
class FeatureRepository extends BaseRepository<Feature, CreateFeatureInput, UpdateFeatureInput> {
async findByName(name: string): Promise<Feature | null>
async findByTags(tags: string[]): Promise<Feature[]>
// ... feature-specific methods
}
```
### Middleware Chain
```typescript
// Request processing pipeline
app.use(correlationMiddleware()); // Add correlation IDs
app.use(authenticate()); // JWT validation
app.use(authorize('admin')); // Role checking
app.use(validateDto(schema)); // Input validation
app.use(errorHandler); // Error handling
```
### Error Handling
```typescript
// Custom error classes
class NotFoundError extends HttpError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
// Usage in services
if (!feature) {
throw new NotFoundError('Feature');
}
```
### Dependency Injection
```typescript
// Constructor injection for testability
export class FeatureService {
constructor(private repository: IRepository<Feature>) {}
async create(data: CreateFeatureInput): Promise<Feature> {
return this.repository.create(data);
}
}
```
## Best Practices
### Code Organization
- **Separation of Concerns**: Clear boundaries between layers (Controller → Service → Repository)
- **Single Responsibility**: Each class/method has one clear purpose
- **Dependency Injection**: Constructor injection for better testability
- **Error Boundaries**: Proper error handling at each layer
### Security
- **Input Validation**: All inputs validated with Zod schemas
- **Authentication**: JWT tokens with proper expiration
- **Authorization**: Role-based access control (RBAC)
- **Rate Limiting**: Distributed rate limiting with Redis
- **Security Headers**: Helmet.js for HTTP security headers
### Observability
- **Structured Logging**: Consistent log format with correlation IDs
- **Metrics**: Comprehensive Prometheus metrics
- **Tracing**: Distributed tracing with Jaeger
- **Health Checks**: Liveness and readiness probes
- **Correlation IDs**: Request tracing across service boundaries
### Error Handling
- **Custom Error Classes**: Specific error types for different scenarios
- **HTTP Status Mapping**: Proper status codes for different error types
- **Structured Responses**: Consistent error response format
- **Operational Errors**: Clear distinction between programming and operational errors
### Testing
- **Unit Tests**: Test individual functions and classes
- **Integration Tests**: Test component interactions
- **E2E Tests**: Test complete request/response cycles
- **Test Utilities**: Shared test helpers and mocks
- **Coverage Goals**: >70% code coverage target
### Docker & Deployment
- **Multi-stage Builds**: Optimized for production image size
- **Security**: Non-root users, minimal attack surface
- **Health Checks**: Container health monitoring
- **Compose Files**: Development, testing, and production configurations
- **Resource Limits**: Proper CPU and memory constraints
## Configuration Management
### Environment Variables
- **Typed Configuration**: Zod schemas for env validation
- **Default Values**: Sensible defaults for development
- **Override Support**: `.env.local` overrides `.env`
- **Documentation**: Comprehensive env variable documentation
### Feature Flags
- **Runtime Configuration**: Database-backed feature flags
- **Admin Control**: Admin API for feature management
- **Gradual Rollout**: Enable/disable features without deployment
- **Audit Trail**: Track feature flag changes
## API Design
### RESTful Conventions
- **Resource Naming**: Plural nouns for resource endpoints
- **HTTP Methods**: GET, POST, PUT, DELETE, PATCH appropriately
- **Status Codes**: Proper HTTP status codes for all responses
- **Content Negotiation**: JSON responses with proper content-type
### Response Format
```json
{
"success": true,
"data": { ... },
"message": "Operation completed successfully",
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### Error Responses
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [...]
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
## Development Workflow
### Local Development
1. **Setup Infrastructure**: `docker-compose up -d`
2. **Install Dependencies**: `pnpm install`
3. **Database Setup**: `pnpm prisma migrate dev && pnpm prisma db seed`
4. **Start Development**: `pnpm dev`
5. **Run Tests**: `pnpm test`
### Testing Strategy
1. **Unit Tests**: Test individual functions and classes
2. **Integration Tests**: Test middleware chains and service interactions
3. **E2E Tests**: Test complete API workflows
4. **Performance Tests**: Load testing and performance validation
### Deployment Pipeline
1. **Linting**: Code quality checks with ESLint and Prettier
2. **Testing**: Full test suite execution (unit, integration, E2E)
3. **Security Scanning**: Dependency audit, SAST, and container scanning
4. **Build**: Multi-stage Docker image creation with security scanning
5. **Deploy**: Container orchestration deployment with health checks
6. **Verification**: Automated post-deployment health and performance verification
---
# Part 2: Platform Integration (External)
This section describes how a service built from this template integrates with the GoodGo microservices platform.
## Platform Architecture
```mermaid
graph TD
Client[Client / Browser] --> Traefik[Traefik API Gateway]
subgraph Platform[GoodGo Microservices Platform]
Traefik --> AuthService[Auth Service]
Traefik --> YourService[Your Service from Template]
Traefik --> OtherServices[Other Services...]
YourService --> SharedDB[(Shared PostgreSQL)]
YourService --> SharedRedis[(Shared Redis)]
AuthService -.->|JWT Validation| YourService
YourService -.->|Inter-Service Calls| OtherServices
end
subgraph Observability[Observability Stack]
Prometheus[Prometheus]
Grafana[Grafana]
Jaeger[Jaeger]
Loki[Loki]
end
YourService -.->|Metrics| Prometheus
YourService -.->|Traces| Jaeger
YourService -.->|Logs| Loki
Prometheus --> Grafana
style Traefik fill:#ffecb3
style YourService fill:#e1f5fe
```
## Service Discovery & Registration
Services are registered with Traefik via Docker labels in `deployments/local/docker-compose.yml`:
```yaml
services:
your-service:
build:
context: ../..
dockerfile: services/your-service/Dockerfile
labels:
# Enable Traefik for this service
- "traefik.enable=true"
# Define routing rule
- "traefik.http.routers.your-service.rule=PathPrefix(`/api/v1/your-service`)"
# Specify service port
- "traefik.http.services.your-service.loadbalancer.server.port=5002"
# Health check configuration
- "traefik.http.services.your-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.your-service.loadbalancer.healthcheck.interval=10s"
```
## Shared Infrastructure
### Traefik API Gateway (infra/traefik/)
- **Location**: `infra/traefik/` - Platform-level configuration
- **Static Config**: `traefik.yml` - Entry points, providers, API dashboard
- **Dynamic Config**: `dynamic/middlewares.yml`, `dynamic/routes.yml`
- **Features**: Load balancing, rate limiting, SSL/TLS, CORS, security headers
### PostgreSQL Database
- **Shared or Isolated**: Can be shared database with schema isolation or separate databases
- **Connection**: Via `DATABASE_URL` environment variable
- **Migrations**: Managed per-service with Prisma
### Redis Cache
- **Shared Instance**: Common Redis instance for all services
- **Connection**: Via `REDIS_URL` or `REDIS_HOST`/`REDIS_PORT`
- **Use Cases**: Caching, rate limiting, session storage
### Observability Stack (infra/observability/)
- **Prometheus**: Metrics collection from all services
- **Grafana**: Visualization and dashboards
- **Jaeger**: Distributed tracing
- **Loki**: Log aggregation
## Inter-Service Communication
### HTTP/REST Communication
Services communicate via HTTP through Traefik or direct service-to-service calls:
```typescript
// Example: Calling another service
const response = await fetch('http://auth-service:5001/api/v1/users/validate', {
headers: {
'Authorization': `Bearer ${token}`,
'X-Correlation-ID': correlationId
}
});
```
### Authentication Flow
1. Client authenticates with Auth Service
2. Auth Service issues JWT token
3. Client includes JWT in requests to other services
4. Services validate JWT using `@goodgo/auth-sdk`
5. Services extract user info from validated token
---
# Part 3: Deployment Context
This section explains how to deploy a service built from this template to the platform.
## Adding Service to Platform
### Step 1: Create Service from Template
```bash
# Use the create-service script
./scripts/utils/create-service.sh my-new-service
# Or manually copy the template
cp -r services/_template services/my-new-service
```
### Step 2: Register in deployments/local/docker-compose.yml
Add your service to the platform compose file:
```yaml
services:
my-new-service:
build:
context: ../..
dockerfile: services/my-new-service/Dockerfile
container_name: my-new-service-local
environment:
- NODE_ENV=development
- PORT=5003
- DATABASE_URL=${DATABASE_URL}
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=${JWT_SECRET}
- SERVICE_NAME=my-new-service
- API_VERSION=v1
depends_on:
redis:
condition: service_healthy
networks:
- microservices-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.my-new-service.rule=PathPrefix(`/api/v1/my-new-service`)"
- "traefik.http.services.my-new-service.loadbalancer.server.port=5003"
```
### Step 3: Configure Traefik Routes (Optional)
For advanced routing, add to `infra/traefik/dynamic/routes.yml`:
```yaml
http:
routers:
my-new-service:
rule: "PathPrefix(`/api/v1/my-new-service`)"
service: my-new-service
middlewares:
- secure-headers
- cors
- compress
```
### Step 4: Start the Platform
```bash
cd deployments/local
docker-compose up -d
```
### Step 5: Access Your Service
- **API**: http://localhost/api/v1/my-new-service
- **Health**: http://localhost/api/v1/my-new-service/health
- **API Docs**: http://localhost/api/v1/my-new-service/api-docs
- **Traefik Dashboard**: http://localhost:8080
## Environment Configuration
Services inherit environment variables from:
1. **Platform Level**: `deployments/local/.env.local`
2. **Service Level**: Service-specific environment in docker-compose.yml
3. **Defaults**: Service's `.env.example` for development
## Operational Excellence
### Incident Response
1. **Detection**: Automated monitoring alerts
2. **Assessment**: Incident severity classification
3. **Communication**: Stakeholder notification
4. **Investigation**: Root cause analysis
5. **Resolution**: Fix deployment and verification
6. **Post-mortem**: Incident review and improvement
### Capacity Planning
- **Resource Monitoring**: Track CPU, memory, disk, and network usage
- **Performance Benchmarks**: Regular performance testing
- **Scaling Triggers**: Automated scaling based on metrics
- **Cost Optimization**: Right-sizing resources
### Compliance & Security
- **Security Audits**: Regular security assessments
- **Compliance Checks**: GDPR, HIPAA, SOC2 compliance
- **Data Encryption**: At-rest and in-transit encryption
- **Access Controls**: Least privilege access principles

View File

@@ -0,0 +1,552 @@
# Kiến Trúc Template Dịch Vụ
Tài liệu này mô tả kiến trúc của một microservice đơn lẻ được xây dựng từ template này và cách nó tích hợp với nền tảng microservices GoodGo.
## Tổng quan
Template này cung cấp foundation hoàn chỉnh, production-ready để xây dựng các microservice riêng lẻ với:
- **Bảo mật**: Xác thực, phân quyền, validation đầu vào, và security headers
- **Khả năng quan sát**: Logging toàn diện, metrics, tracing, và health checks
- **Quản lý dữ liệu**: Repository pattern, database migrations, và seeding
- **Tài liệu API**: OpenAPI/Swagger documentation với giao diện tương tác
- **Xử lý lỗi**: Structured error responses với HTTP status codes phù hợp
- **Hỗ trợ Docker**: Multi-stage builds và tối ưu hóa production
**Bối cảnh Quan trọng**: Template này đại diện cho **một microservice đơn lẻ**. Để triển khai và điều phối ở cấp độ nền tảng, các service được đăng ký trong `deployments/local/docker-compose.yml` và định tuyến qua Traefik API Gateway được cấu hình trong `infra/traefik/`.
---
# Phần 1: Kiến Trúc Service Đơn Lẻ (Nội bộ)
Phần này mô tả kiến trúc nội bộ của một microservice đơn lẻ được xây dựng từ template này.
## Các Thành Phần Nội Bộ Service
```mermaid
graph TD
Request[HTTP Request] -->|Từ Traefik| Middleware[Chuỗi Middleware]
subgraph SingleService[Ranh Giới Service Đơn Lẻ]
Middleware --> Correlation[Correlation ID Middleware]
Correlation --> Auth[Authentication Middleware]
Auth --> Validation[Validation Middleware]
Validation --> Error[Error Handler]
Error --> Logger[Request Logger]
Logger --> Metrics[Metrics Collector]
Metrics --> Router[Lớp Router]
Router --> Controller[Lớp Controller]
Controller --> Service[Lớp Service]
Service --> Repository[Lớp Repository]
Repository --> Database[(PostgreSQL)]
Service --> Cache[(Redis)]
Service -.->|Trạng thái Health| Health[Health Checks]
Service -.->|Tài liệu API| OpenAPI[OpenAPI/Swagger]
end
Service -.->|Metrics| Prometheus[Prometheus]
Service -.->|Traces| Jaeger[Jaeger]
style Correlation fill:#e1f5fe
style Auth fill:#f3e5f5
style Validation fill:#e8f5e8
style Error fill:#fff3e0
style Logger fill:#f3e5f5
style Metrics fill:#e8f5e8
```
## Kiến Trúc Phân Lớp
### Chuỗi Middleware
Chuỗi middleware xử lý mọi request đến theo thứ tự:
1. **Correlation Middleware**: Tạo/truyền correlation và request IDs
2. **Authentication Middleware**: Xác thực JWT tokens (tùy chọn cho public routes)
3. **Validation Middleware**: Làm sạch và validate dữ liệu đầu vào với Zod schemas
4. **Error Handler**: Bắt và format lỗi thành structured responses
5. **Logger Middleware**: Ghi log request/response với correlation IDs
6. **Metrics Middleware**: Thu thập Prometheus metrics (duration, status, payload size)
### Lớp Controller
- Xử lý HTTP requests và responses
- Điều phối các lời gọi service layer
- Format API responses
- Bọc async handlers để truyền lỗi
### Lớp Service
- Chứa business logic thuần túy
- Độc lập với HTTP transport
- Điều phối các lời gọi repository
- Triển khai caching strategies
- Throw domain-specific errors
### Lớp Repository
- Trừu tượng hóa database operations
- Sử dụng Prisma ORM cho type-safe queries
- Triển khai repository pattern
- Cung cấp error handling nhất quán
- Hỗ trợ transactions
## Luồng Xử Lý Request
1. **Đầu vào Request**:
- Client gửi HTTP request đến ingress/load balancer
- Request bao gồm correlation ID header tùy chọn (`x-correlation-id`)
2. **Correlation Middleware**:
- Tạo hoặc truyền correlation ID để tracing request
- Thêm request ID để định danh request duy nhất
- Đặt correlation headers trên response
3. **Security Middleware**:
- **Authentication**: Xác thực JWT tokens (tùy chọn cho public routes)
- **Authorization**: Kiểm tra user roles và permissions
- **Rate Limiting**: Ngăn chặn lạm dụng với Redis-backed rate limiting
- **Helmet**: Bảo mật HTTP headers
4. **Validation Middleware**:
- Làm sạch input data (trimming, normalization)
- Validate request data sử dụng Zod schemas
- Trả về structured validation errors
5. **Router & Controller**:
- Định tuyến request đến controller phù hợp
- Controller điều phối thực thi business logic
- Input validation và response formatting
6. **Lớp Service**:
- Chứa business logic thuần túy
- Độc lập với HTTP transport layer
- Điều phối data access và external service calls
7. **Lớp Repository**:
- Triển khai repository pattern cho data access
- Trừu tượng hóa database operations với Prisma ORM
- Cung cấp error handling nhất quán
8. **Response & Observability**:
- Format structured JSON responses
- Ghi lại comprehensive metrics (duration, errors, payload sizes)
- Log với correlation IDs cho distributed tracing
- Gửi traces đến Jaeger nếu được bật
## Mẫu Kiến Trúc
### Mẫu Repository
```typescript
// Base repository với common CRUD operations
class BaseRepository<T, CreateInput, UpdateInput> {
async findById(id: string): Promise<T | null>
async create(data: CreateInput): Promise<T>
async update(id: string, data: UpdateInput): Promise<T>
// ... thêm các methods phổ biến
}
// Repository cụ thể extends base
class FeatureRepository extends BaseRepository<Feature, CreateFeatureInput, UpdateFeatureInput> {
async findByName(name: string): Promise<Feature | null>
async findByTags(tags: string[]): Promise<Feature[]>
// ... feature-specific methods
}
```
### Chuỗi Middleware
```typescript
// Request processing pipeline
app.use(correlationMiddleware()); // Thêm correlation IDs
app.use(authenticate()); // JWT validation
app.use(authorize('admin')); // Role checking
app.use(validateDto(schema)); // Input validation
app.use(errorHandler); // Error handling
```
### Xử Lý Lỗi
```typescript
// Custom error classes
class NotFoundError extends HttpError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
// Sử dụng trong services
if (!feature) {
throw new NotFoundError('Feature');
}
```
### Tiêm Phụ Thuộc
```typescript
// Constructor injection cho testability
export class FeatureService {
constructor(private repository: IRepository<Feature>) {}
async create(data: CreateFeatureInput): Promise<Feature> {
return this.repository.create(data);
}
}
```
## Thực Tiễn Tốt
### Tổ Chức Code
- **Separation of Concerns**: Ranh giới rõ ràng giữa các lớp (Controller → Service → Repository)
- **Single Responsibility**: Mỗi class/method có một mục đích rõ ràng
- **Dependency Injection**: Constructor injection để testability tốt hơn
- **Error Boundaries**: Xử lý lỗi phù hợp ở mỗi lớp
### Bảo Mật
- **Input Validation**: Tất cả inputs được validate với Zod schemas
- **Authentication**: JWT tokens với expiration phù hợp
- **Authorization**: Role-based access control (RBAC)
- **Rate Limiting**: Distributed rate limiting với Redis
- **Security Headers**: Helmet.js cho HTTP security headers
### Khả Năng Quan Sát
- **Structured Logging**: Format log nhất quán với correlation IDs
- **Metrics**: Comprehensive Prometheus metrics
- **Tracing**: Distributed tracing với Jaeger
- **Health Checks**: Liveness và readiness probes
- **Correlation IDs**: Request tracing qua service boundaries
### Xử Lý Lỗi
- **Custom Error Classes**: Error types cụ thể cho các scenarios khác nhau
- **HTTP Status Mapping**: Status codes phù hợp cho các error types khác nhau
- **Structured Responses**: Format error response nhất quán
- **Operational Errors**: Phân biệt rõ ràng giữa programming và operational errors
### Kiểm Thử
- **Unit Tests**: Test các functions và classes riêng lẻ
- **Integration Tests**: Test tương tác giữa các components
- **E2E Tests**: Test chu trình request/response hoàn chỉnh
- **Test Utilities**: Shared test helpers và mocks
- **Coverage Goals**: Mục tiêu >70% code coverage
### Docker & Triển Khai
- **Multi-stage Builds**: Tối ưu cho production image size
- **Security**: Non-root users, minimal attack surface
- **Health Checks**: Container health monitoring
- **Compose Files**: Development, testing, và production configurations
- **Resource Limits**: CPU và memory constraints phù hợp
## Quản Lý Cấu Hình
### Biến Môi Trường
- **Typed Configuration**: Zod schemas cho env validation
- **Default Values**: Defaults hợp lý cho development
- **Override Support**: `.env.local` ghi đè `.env`
- **Documentation**: Tài liệu biến môi trường toàn diện
### Feature Flags
- **Runtime Configuration**: Database-backed feature flags
- **Admin Control**: Admin API cho feature management
- **Gradual Rollout**: Bật/tắt features không cần deployment
- **Audit Trail**: Theo dõi feature flag changes
## Thiết Kế API
### Quy Ước RESTful
- **Resource Naming**: Danh từ số nhiều cho resource endpoints
- **HTTP Methods**: GET, POST, PUT, DELETE, PATCH phù hợp
- **Status Codes**: HTTP status codes phù hợp cho tất cả responses
- **Content Negotiation**: JSON responses với content-type phù hợp
### Định Dạng Phản hồi
```json
{
"success": true,
"data": { ... },
"message": "Hoạt động hoàn thành thành công",
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
### Phản hồi Lỗi
```json
{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation thất bại",
"details": [...]
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
## Quy Trình Phát Triển
### Phát Triển Cục Bộ
1. **Setup Infrastructure**: `docker-compose up -d`
2. **Install Dependencies**: `pnpm install`
3. **Database Setup**: `pnpm prisma migrate dev && pnpm prisma db seed`
4. **Start Development**: `pnpm dev`
5. **Run Tests**: `pnpm test`
### Chiến Lược Kiểm Thử
1. **Unit Tests**: Test các functions và classes riêng lẻ
2. **Integration Tests**: Test middleware chains và service interactions
3. **E2E Tests**: Test complete API workflows
4. **Performance Tests**: Load testing và performance validation
### Pipeline Triển Khai
1. **Linting**: Code quality checks với ESLint và Prettier
2. **Testing**: Full test suite execution (unit, integration, E2E)
3. **Security Scanning**: Dependency audit, SAST, và container scanning
4. **Build**: Multi-stage Docker image creation với security scanning
5. **Deploy**: Container orchestration deployment với health checks
6. **Verification**: Automated post-deployment health và performance verification
---
# Phần 2: Tích Hợp Nền Tảng (Ngoại vi)
Phần này mô tả cách một service được xây dựng từ template này tích hợp với nền tảng microservices GoodGo.
## Kiến Trúc Nền Tảng
```mermaid
graph TD
Client[Client / Browser] --> Traefik[Traefik API Gateway]
subgraph Platform[Nền Tảng Microservices GoodGo]
Traefik --> AuthService[Auth Service]
Traefik --> YourService[Service Của Bạn từ Template]
Traefik --> OtherServices[Các Services Khác...]
YourService --> SharedDB[(PostgreSQL Chung)]
YourService --> SharedRedis[(Redis Chung)]
AuthService -.->|JWT Validation| YourService
YourService -.->|Inter-Service Calls| OtherServices
end
subgraph Observability[Observability Stack]
Prometheus[Prometheus]
Grafana[Grafana]
Jaeger[Jaeger]
Loki[Loki]
end
YourService -.->|Metrics| Prometheus
YourService -.->|Traces| Jaeger
YourService -.->|Logs| Loki
Prometheus --> Grafana
style Traefik fill:#ffecb3
style YourService fill:#e1f5fe
```
## Service Discovery & Đăng Ký
Các service được đăng ký với Traefik qua Docker labels trong `deployments/local/docker-compose.yml`:
```yaml
services:
your-service:
build:
context: ../..
dockerfile: services/your-service/Dockerfile
labels:
# Bật Traefik cho service này
- "traefik.enable=true"
# Định nghĩa routing rule
- "traefik.http.routers.your-service.rule=PathPrefix(`/api/v1/your-service`)"
# Chỉ định service port
- "traefik.http.services.your-service.loadbalancer.server.port=5002"
# Cấu hình health check
- "traefik.http.services.your-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.your-service.loadbalancer.healthcheck.interval=10s"
```
## Hạ Tầng Chung
### Traefik API Gateway (infra/traefik/)
- **Vị trí**: `infra/traefik/` - Cấu hình cấp độ nền tảng
- **Static Config**: `traefik.yml` - Entry points, providers, API dashboard
- **Dynamic Config**: `dynamic/middlewares.yml`, `dynamic/routes.yml`
- **Tính năng**: Load balancing, rate limiting, SSL/TLS, CORS, security headers
### PostgreSQL Database
- **Shared hoặc Isolated**: Có thể là shared database với schema isolation hoặc databases riêng biệt
- **Connection**: Qua biến môi trường `DATABASE_URL`
- **Migrations**: Quản lý per-service với Prisma
### Redis Cache
- **Shared Instance**: Redis instance chung cho tất cả services
- **Connection**: Qua `REDIS_URL` hoặc `REDIS_HOST`/`REDIS_PORT`
- **Use Cases**: Caching, rate limiting, session storage
### Observability Stack (infra/observability/)
- **Prometheus**: Thu thập metrics từ tất cả services
- **Grafana**: Visualization và dashboards
- **Jaeger**: Distributed tracing
- **Loki**: Log aggregation
## Giao Tiếp Giữa Các Service
### HTTP/REST Communication
Services giao tiếp qua HTTP thông qua Traefik hoặc direct service-to-service calls:
```typescript
// Ví dụ: Gọi service khác
const response = await fetch('http://auth-service:5001/api/v1/users/validate', {
headers: {
'Authorization': `Bearer ${token}`,
'X-Correlation-ID': correlationId
}
});
```
### Authentication Flow
1. Client xác thực với Auth Service
2. Auth Service phát hành JWT token
3. Client bao gồm JWT trong requests đến các services khác
4. Services validate JWT sử dụng `@goodgo/auth-sdk`
5. Services trích xuất user info từ validated token
---
# Phần 3: Bối Cảnh Triển Khai
Phần này giải thích cách triển khai một service được xây dựng từ template này lên nền tảng.
## Thêm Service Vào Nền Tảng
### Bước 1: Tạo Service từ Template
```bash
# Sử dụng create-service script
./scripts/utils/create-service.sh my-new-service
# Hoặc copy template thủ công
cp -r services/_template services/my-new-service
```
### Bước 2: Đăng Ký trong deployments/local/docker-compose.yml
Thêm service của bạn vào platform compose file:
```yaml
services:
my-new-service:
build:
context: ../..
dockerfile: services/my-new-service/Dockerfile
container_name: my-new-service-local
environment:
- NODE_ENV=development
- PORT=5003
- DATABASE_URL=${DATABASE_URL}
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=${JWT_SECRET}
- SERVICE_NAME=my-new-service
- API_VERSION=v1
depends_on:
redis:
condition: service_healthy
networks:
- microservices-network
labels:
- "traefik.enable=true"
- "traefik.http.routers.my-new-service.rule=PathPrefix(`/api/v1/my-new-service`)"
- "traefik.http.services.my-new-service.loadbalancer.server.port=5003"
```
### Bước 3: Cấu Hình Traefik Routes (Tùy chọn)
Để định tuyến nâng cao, thêm vào `infra/traefik/dynamic/routes.yml`:
```yaml
http:
routers:
my-new-service:
rule: "PathPrefix(`/api/v1/my-new-service`)"
service: my-new-service
middlewares:
- secure-headers
- cors
- compress
```
### Bước 4: Khởi Động Nền Tảng
```bash
cd deployments/local
docker-compose up -d
```
### Bước 5: Truy Cập Service Của Bạn
- **API**: http://localhost/api/v1/my-new-service
- **Health**: http://localhost/api/v1/my-new-service/health
- **API Docs**: http://localhost/api/v1/my-new-service/api-docs
- **Traefik Dashboard**: http://localhost:8080
## Cấu Hình Môi Trường
Services kế thừa biến môi trường từ:
1. **Platform Level**: `deployments/local/.env.local`
2. **Service Level**: Service-specific environment trong docker-compose.yml
3. **Defaults**: `.env.example` của service cho development
## Xuất sắc Vận hành
### Phản hồi Sự cố
1. **Detection**: Automated monitoring alerts
2. **Assessment**: Incident severity classification
3. **Communication**: Stakeholder notification
4. **Investigation**: Root cause analysis
5. **Resolution**: Fix deployment và verification
6. **Post-mortem**: Incident review và improvement
### Lập kế hoạch Dung lượng
- **Resource Monitoring**: Theo dõi CPU, memory, disk, và network usage
- **Performance Benchmarks**: Regular performance testing
- **Scaling Triggers**: Automated scaling dựa trên metrics
- **Cost Optimization**: Right-sizing resources
### Tuân thủ & Bảo mật
- **Security Audits**: Regular security assessments
- **Compliance Checks**: GDPR, HIPAA, SOC2 compliance
- **Data Encryption**: At-rest và in-transit encryption
- **Access Controls**: Least privilege access principles

View File

@@ -0,0 +1,114 @@
# EN: Multi-stage Docker build for production-ready microservice
# VI: Multi-stage Docker build cho microservice production-ready
# EN: Base stage with security updates
# VI: Base stage với security updates
FROM node:25-alpine AS base
# EN: Install security updates and required packages
# VI: Cài đặt security updates và packages cần thiết
RUN apk update && apk upgrade && \
apk add --no-cache \
libc6-compat \
dumb-init \
su-exec \
&& rm -rf /var/cache/apk/*
# EN: Create app directory with correct permissions
# VI: Tạo app directory với permissions đúng
WORKDIR /app
RUN chown node:node /app
USER node
# EN: Dependencies stage - separate for better caching
# VI: Dependencies stage - tách riêng để cache tốt hơn
FROM base AS deps
USER root
RUN chown node:node /app
USER node
# EN: Enable corepack for pnpm
# VI: Enable corepack cho pnpm
RUN corepack enable pnpm
# EN: Copy package files
# VI: Copy package files
COPY --chown=node:node package.json pnpm-lock.yaml* ./
# EN: Install dependencies only (no dev dependencies for smaller image)
# VI: Install dependencies only (không có dev dependencies để image nhỏ hơn)
RUN pnpm install --frozen-lockfile --prod=false && pnpm store prune
# EN: Builder stage - compile TypeScript and generate Prisma client
# VI: Builder stage - compile TypeScript và generate Prisma client
FROM base AS builder
USER root
RUN chown node:node /app
USER node
# EN: Enable corepack
# VI: Enable corepack
RUN corepack enable pnpm
# EN: Copy dependencies from deps stage
# VI: Copy dependencies từ deps stage
COPY --from=deps --chown=node:node /app/node_modules ./node_modules
# EN: Copy source code
# VI: Copy source code
COPY --chown=node:node . .
# EN: Build application
# VI: Build application
RUN pnpm prisma generate && \
pnpm build && \
pnpm prune --prod
# EN: Production stage - minimal runtime image
# VI: Production stage - minimal runtime image
FROM base AS runner
# EN: Install runtime dependencies only
# VI: Install runtime dependencies only
USER root
RUN apk add --no-cache \
curl \
&& rm -rf /var/cache/apk/*
# EN: Create non-root user for security
# VI: Tạo non-root user cho security
RUN addgroup -g 1001 -S nodejs && \
adduser -S microservice -u 1001
# EN: Create necessary directories with correct permissions
# VI: Tạo necessary directories với permissions đúng
RUN mkdir -p /app/dist /app/node_modules /app/prisma && \
chown -R microservice:nodejs /app
# EN: Switch to non-root user
# VI: Switch sang non-root user
USER microservice
# EN: Copy built application from builder stage
# VI: Copy built application từ builder stage
COPY --from=builder --chown=microservice:nodejs /app/dist ./dist
COPY --from=builder --chown=microservice:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=microservice:nodejs /app/package.json ./
COPY --from=builder --chown=microservice:nodejs /app/prisma ./prisma
# EN: Add health check
# VI: Thêm health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/health/live || exit 1
# EN: Expose port
# VI: Expose port
EXPOSE 5000
# EN: Use dumb-init to handle signals properly
# VI: Sử dụng dumb-init để handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# EN: Start application
# VI: Start application
CMD ["node", "dist/main.js"]

View File

@@ -0,0 +1,962 @@
# Service Template / Template Dịch Vụ
> **EN**: Standard template for creating new microservices in the @goodgo ecosystem.
> **VI**: Template chuẩn để tạo các microservice mới trong hệ sinh thái @goodgo.
## Features / Tính Năng
- **Framework**: Express.js with TypeScript / Express.js với TypeScript.
- **Database**: Prisma ORM with PostgreSQL / Prisma ORM với PostgreSQL.
- **Validation**: Zod for environment & input validation / Zod cho validation biến môi trường và đầu vào.
- **Observability / Khả năng quan sát**:
- **Metrics**: Prometheus metrics at `/metrics` / Metrics Prometheus tại `/metrics`.
- **Logging**: Common logger with request tracking / Logger chung với theo dõi request.
- **Tracing**: OpenTelemetry/Jaeger integration / Tích hợp OpenTelemetry/Jaeger.
- **Resilience / Khả năng phục hồi**:
- Graceful shutdown / Đóng ứng dụng an toàn.
- Rate limiting (Distributed via Redis) / Giới hạn tốc độ request (Phân tán qua Redis).
- Circuit Breaker / Ngắt mạch.
- Health checks (liveness/readiness) / Kiểm tra sức khỏe hệ thống.
- **Caching**: Redis caching strategy / Chiến lược caching với Redis.
- **Security / Bảo mật**: Helmet & CORS configured / Đã cấu hình Helmet & CORS.
## Project Structure / Cấu trúc Dự án
```
src/
├── config/ # Configuration & Env validation / Cấu hình & Validate biến môi trường
├── middlewares/ # Express middlewares (error, logger, metrics)
├── modules/ # Feature modules (controller, service, repository)
├── routes/ # API route definitions
└── main.ts # Entry point & App bootstrapping
```
## Getting Started / Bắt đầu
### Prerequisites / Yêu cầu tiên quyết
- Node.js >= 20
- pnpm
- Docker (Redis required)
### Installation / Cài đặt
#### Option 1: Local Development / Phát triển Cục bộ
1. **Clone & Install dependencies**:
```bash
pnpm install
```
2. **Start infrastructure with Docker**:
For local development, start the platform infrastructure from `deployments/local/`:
Để phát triển local, khởi động hạ tầng nền tảng từ `deployments/local/`:
```bash
# Navigate to deployments directory
cd deployments/local
# Start platform services (PostgreSQL, Redis, Traefik, etc.)
docker-compose up -d redis
# Return to service directory
cd ../../services/_template
```
**Note**: For full platform deployment with all services, see "Adding This Service to the Platform" section below.
**Lưu ý**: Để triển khai nền tảng đầy đủ với tất cả services, xem phần "Thêm Service Vào Nền Tảng" bên dưới.
3. **Setup database**:
```bash
pnpm prisma migrate dev
pnpm prisma db seed
```
4. **Start development server**:
```bash
pnpm dev
```
2. **Environment Setup / Thiết lập môi trường**:
Environment variables are managed at the **platform level**, not per-service:
Biến môi trường được quản lý ở **cấp độ nền tảng**, không phải mỗi service:
```bash
# EN: Setup shared environment variables (from deployments/local/)
# VI: Thiết lập biến môi trường chung (từ deployments/local/)
cd deployments/local
cp env.local.example .env.local
# Edit .env.local with your values (JWT_SECRET, DATABASE_URL, etc.)
```
**Environment Variables / Biến Môi trường**:
**Shared Variables** (in `deployments/local/.env.local`):
- `JWT_SECRET`, `JWT_REFRESH_SECRET` - Must be same across all services
- `REDIS_HOST`, `REDIS_PORT` - Shared Redis instance
- `CORS_ORIGIN` - Allowed origins for all services
- `NODE_ENV`, `LOG_LEVEL` - Common configuration
**Service-Specific Variables** (in `docker-compose.yml`):
- `PORT` - Unique port for each service
- `DATABASE_URL` - Service's database connection
- `SERVICE_NAME` - Service identifier
**Key Environment Variables / Biến Môi Trường Chính**:
| Variable / Biến | Description / Mô tả | Default / Mặc định | Required / Bắt buộc |
|-----------------|---------------------|-------------------|---------------------|
| `PORT` | Server port / Cổng server | `5000` | No / Không |
| `NODE_ENV` | Environment mode / Chế độ môi trường | `development` | No / Không |
| `API_VERSION` | API version prefix / Tiền tố phiên bản API | `v1` | No / Không |
| `CORS_ORIGIN` | Allowed CORS origins (comma-separated) / Origins CORS được phép | `http://localhost:3000` | No / Không |
| `SERVICE_NAME` | Service identifier / Mã định danh service | `microservice-template` | No / Không |
| `DATABASE_URL` | PostgreSQL connection string / Chuỗi kết nối PostgreSQL | - | **Yes / Có** |
| `REDIS_URL` | Redis connection URL / URL kết nối Redis | `redis://localhost:6379` | No / Không |
| `JWT_SECRET` | JWT secret key for token signing and verification / Khóa bí mật JWT để ký và xác minh token | - | **Yes / Có** |
| `TRACING_ENABLED` | Enable Jaeger tracing / Bật tracing Jaeger | `false` | No / Không |
| `JAEGER_ENDPOINT` | Jaeger collector endpoint / Endpoint collector Jaeger | - | No / Không |
**Environment Configuration Priority / Ưu tiên Cấu hình Môi trường**:
1. **Docker Compose environment** (in `deployments/local/docker-compose.yml`) - Highest priority
2. **Shared `.env.local`** (in `deployments/local/.env.local`) - Platform-level shared configs
3. **System environment variables** - OS-level environment
3. **Database Setup / Thiết lập Database**:
**Prerequisites / Yêu cầu tiên quyết**:
- PostgreSQL database running / Database PostgreSQL đang chạy
- `DATABASE_URL` configured in `.env` / `DATABASE_URL` đã được cấu hình trong `.env`
**Database Workflow / Quy trình Database**:
```bash
# EN: Generate Prisma client / Tạo Prisma client
pnpm prisma:generate
# EN: Create and run initial migration / Tạo và chạy migration ban đầu
pnpm prisma:migrate
# EN: (Optional) Seed database with initial data / (Tùy chọn) Seed database với dữ liệu ban đầu
pnpm prisma:seed
```
**Development Workflow / Quy trình Phát triển**:
```bash
# EN: After schema changes, regenerate client / Sau khi thay đổi schema, tạo lại client
pnpm prisma:generate
# EN: Create new migration for schema changes / Tạo migration mới cho thay đổi schema
pnpm prisma:migrate dev --name your-migration-name
# EN: View database in Prisma Studio / Xem database trong Prisma Studio
pnpm prisma:studio
```
**Production Deployment / Triển khai Production**:
```bash
# EN: Deploy migrations to production / Triển khai migrations lên production
pnpm prisma:migrate deploy
# EN: Reset database (CAUTION: destroys all data) / Reset database (CẨN THẬN: xóa tất cả dữ liệu)
pnpm prisma:migrate reset
```
4. **Run Development / Chạy môi trường phát triển**:
```bash
pnpm dev
```
5. **Build & Start Production / Build và Chạy Production**:
```bash
pnpm build
pnpm start
```
## Adding This Service to the Platform / Thêm Service Vào Nền Tảng
This template represents a **single microservice**. To deploy it as part of the GoodGo microservices platform:
Template này đại diện cho **một microservice đơn lẻ**. Để triển khai nó như một phần của nền tảng microservices GoodGo:
### 1. Register in Platform Compose File / Đăng Ký Trong Platform Compose File
Add your service to `deployments/local/docker-compose.yml`:
Thêm service của bạn vào `deployments/local/docker-compose.yml`:
```yaml
services:
your-service:
build:
context: ../..
dockerfile: services/your-service/Dockerfile
container_name: your-service-local
environment:
- NODE_ENV=development
- PORT=5002
- DATABASE_URL=${DATABASE_URL}
- REDIS_HOST=redis
- REDIS_PORT=6379
- JWT_SECRET=${JWT_SECRET}
- SERVICE_NAME=your-service
- API_VERSION=v1
- CORS_ORIGIN=http://localhost:3000,http://localhost:3001
depends_on:
redis:
condition: service_healthy
networks:
- microservices-network
labels:
# Enable Traefik service discovery
- "traefik.enable=true"
# Define routing rule (path-based routing)
- "traefik.http.routers.your-service.rule=PathPrefix(`/api/v1/your-service`)"
# Specify the service port
- "traefik.http.services.your-service.loadbalancer.server.port=5002"
# Health check configuration
- "traefik.http.services.your-service.loadbalancer.healthcheck.path=/health/live"
- "traefik.http.services.your-service.loadbalancer.healthcheck.interval=10s"
```
### 2. Start the Platform / Khởi Động Nền Tảng
```bash
# Navigate to deployments directory
cd deployments/local
# Start all services including your new service
docker-compose up -d
# View logs for your service
docker-compose logs -f your-service
```
### 3. Access Your Service / Truy Cập Service Của Bạn
Once deployed, your service is accessible through Traefik:
Sau khi triển khai, service của bạn có thể truy cập qua Traefik:
- **API**: http://localhost/api/v1/your-service
- **Health Check**: http://localhost/api/v1/your-service/health
- **API Documentation**: http://localhost/api/v1/your-service/api-docs
- **Traefik Dashboard**: http://localhost:8080 (view all registered services)
### 4. Traefik Configuration / Cấu Hình Traefik
Traefik is configured at the platform level in `infra/traefik/`:
Traefik được cấu hình ở cấp độ nền tảng trong `infra/traefik/`:
- **Static Config**: `infra/traefik/traefik.yml` - Entry points, providers, dashboard
- **Dynamic Config**: `infra/traefik/dynamic/` - Middlewares, routes, services
- **Service Discovery**: Automatic via Docker labels (no manual route configuration needed)
For advanced routing or middleware, add to `infra/traefik/dynamic/routes.yml`:
Để định tuyến nâng cao hoặc middleware, thêm vào `infra/traefik/dynamic/routes.yml`:
```yaml
http:
routers:
your-service:
rule: "PathPrefix(`/api/v1/your-service`)"
service: your-service
middlewares:
- secure-headers
- cors
- compress
```
## Observability / Khả năng quan sát
When deployed via the platform (`deployments/local/docker-compose.yml`), your service exposes:
Khi triển khai qua nền tảng (`deployments/local/docker-compose.yml`), service của bạn expose:
- **Metrics**: `http://localhost/api/v1/your-service/metrics` (Prometheus format via Traefik)
- **Health Checks**:
- Liveness: `http://localhost/api/v1/your-service/health/live`
- Readiness: `http://localhost/api/v1/your-service/health/ready`
- **API Documentation**: `http://localhost/api/v1/your-service/api-docs` (Swagger UI via Traefik)
- **Tracing**: Jaeger integration (when `TRACING_ENABLED=true`)
- **Correlation IDs**: Automatic request tracing with `x-correlation-id` headers
- **Structured Logging**: Request/response logging with correlation context
**Note**: For local development (without platform), replace `/api/v1/your-service` with `http://localhost:5000`.
**Lưu ý**: Để phát triển local (không dùng platform), thay `/api/v1/your-service` bằng `http://localhost:5000`.
### Metrics / Metrics
The service exposes comprehensive Prometheus metrics:
- **Request Duration**: `http_request_duration_seconds` (histogram)
- **Request Count**: `http_requests_total` (counter)
- **Active Requests**: `http_active_requests` (gauge)
- **Request Errors**: `http_request_errors_total` (counter)
- **Payload Sizes**: Request/response payload size histograms
- **Default Metrics**: Memory, CPU, event loop lag
### Correlation IDs / Correlation IDs
Every request gets a correlation ID for tracing across services:
- **Header**: `x-correlation-id` (propagated from upstream or auto-generated)
- **Request ID**: `x-request-id` (unique per request)
- **Logging**: All logs include correlation context
- **Metrics**: Request metrics tagged with correlation ID
### Health Checks / Health Checks
- **Liveness** (`/health/live`): Basic service availability
- **Readiness** (`/health/ready`): Service ready to handle requests (includes DB connectivity)
- **Metrics**: Health check results are tracked in Prometheus metrics
### Logging / Logging
Structured logging with multiple levels:
- **Request/Response**: Automatic logging with correlation IDs
- **Errors**: Detailed error logging with stack traces
- **Business Logic**: Custom logging with context
- **Performance**: Request duration and resource usage
### API Documentation / Tài liệu API
- **Swagger UI**: Interactive API documentation at `/api-docs`
- **OpenAPI 3.0**: Complete API specification
- **Request/Response Examples**: Real examples for all endpoints
- **Authentication**: JWT Bearer token examples
## Authentication / Xác thực
The service uses JWT (JSON Web Tokens) for authentication. Include the token in the `Authorization` header as `Bearer <token>`.
### API Documentation / Tài liệu API
#### Authentication Endpoints / Endpoints Xác thực
**Get Current User Info / Lấy Thông tin Người dùng Hiện tại**
```http
GET /auth/me
Authorization: Bearer <your-jwt-token>
```
#### Feature Management / Quản lý Feature
**Base URL**: `http://localhost/api/v1/features`
#### Create Feature / Tạo Feature
```http
POST /api/v1/features
Authorization: Bearer <admin-jwt-token>
Content-Type: application/json
{
"name": "example-feature",
"title": "Example Feature",
"description": "An example feature for demonstration",
"config": {
"enabled": true,
"priority": 1
},
"tags": ["example", "demo"]
}
```
**Required Role:** `admin`
#### Get All Features / Lấy Tất cả Features
```http
GET /api/v1/features
```
#### Get Feature by ID / Lấy Feature theo ID
```http
GET /api/v1/features/{id}
```
#### Update Feature / Cập nhật Feature
```http
PUT /api/v1/features/{id}
Content-Type: application/json
{
"title": "Updated Title",
"enabled": false
}
```
#### Delete Feature / Xóa Feature
```http
DELETE /api/v1/features/{id}
```
#### Toggle Feature Status / Chuyển đổi Trạng thái Feature
```http
PATCH /api/v1/features/{id}/toggle
```
### Response Format / Định dạng Response
All API responses follow this structure / Tất cả responses API tuân theo cấu trúc này:
```json
{
"success": true,
"data": { ... },
"message": "Operation completed / Hoạt động hoàn thành",
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
Error responses / Responses lỗi:
```json
{
"success": false,
"error": {
"code": "FEATURE_001",
"message": "Error description / Mô tả lỗi"
},
"timestamp": "2024-01-01T00:00:00.000Z"
}
```
## Troubleshooting / Khắc phục sự cố
### Common Issues / Vấn đề thường gặp
#### Database Connection Issues / Vấn đề kết nối Database
**Problem**: `Error: P1001: Can't reach database server`
```bash
# EN: Check if PostgreSQL is running (from deployments/local/)
# VI: Kiểm tra PostgreSQL có đang chạy (từ deployments/local/)
cd deployments/local
docker-compose ps
# EN: Check database logs
# VI: Kiểm tra logs database
docker-compose logs postgres
# EN: Restart database service
# VI: Khởi động lại database service
docker-compose restart postgres
```
**Problem**: `Error: P2002: Unique constraint failed`
```typescript
// EN: This usually means you're trying to create a duplicate record
// VI: Điều này thường có nghĩa là bạn đang cố tạo record trùng lặp
// EN: Check your seed data or migration scripts
// VI: Kiểm tra seed data hoặc migration scripts
```
#### Authentication Issues / Vấn đề Authentication
**Problem**: `401 Unauthorized`
```bash
# EN: Check JWT token format
# VI: Kiểm tra định dạng JWT token
curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost/auth/me
# EN: Verify JWT secret in environment
# VI: Xác minh JWT secret trong environment
echo $JWT_SECRET
# EN: Check token expiration
# VI: Kiểm tra token hết hạn
# EN: Use https://jwt.io to decode your token
```
#### Port Already in Use / Port đã được sử dụng
**Problem**: `Error: listen EADDRINUSE: address already in use`
```bash
# EN: Find process using the port
# VI: Tìm process đang sử dụng port
lsof -i :5000
# EN: Kill the process
# VI: Kill process
kill -9 <PID>
# EN: Or change port in .env
# VI: Hoặc thay đổi port trong .env
PORT=5001
```
#### Docker Issues / Vấn đề Docker
**Problem**: `ERROR: Couldn't connect to Docker daemon`
```bash
# EN: Start Docker service
# VI: Khởi động Docker service
sudo systemctl start docker
# EN: Add user to docker group (Linux)
# VI: Thêm user vào docker group (Linux)
sudo usermod -aG docker $USER
```
**Problem**: Container won't start
```bash
# EN: Check container logs (from deployments/local/)
# VI: Kiểm tra logs container (từ deployments/local/)
cd deployments/local
docker-compose logs your-service
# EN: Check container health
# VI: Kiểm tra health container
docker-compose ps
# EN: Rebuild without cache
# VI: Rebuild không dùng cache
docker-compose build --no-cache your-service
docker-compose up -d your-service
```
#### Test Failures / Test thất bại
**Problem**: Tests fail with database connection
```bash
# EN: Ensure test database is running
# VI: Đảm bảo test database đang chạy
docker-compose -f docker-compose.test.yml up -d
# EN: Run tests with verbose output
# VI: Chạy tests với output verbose
pnpm test -- --verbose
# EN: Reset test database
# VI: Reset test database
docker-compose -f docker-compose.test.yml down -v
```
### Debug Mode / Chế độ Debug
```bash
# EN: Enable debug logging (local development)
# VI: Bật debug logging (phát triển local)
DEBUG=* pnpm dev
# EN: Check application health (via platform)
# VI: Kiểm tra health ứng dụng (qua platform)
curl http://localhost/api/v1/your-service/health/ready
# EN: View application logs (from deployments/local/)
# VI: Xem logs ứng dụng (từ deployments/local/)
cd deployments/local
docker-compose logs -f your-service
# EN: Monitor metrics (via platform)
# VI: Monitor metrics (qua platform)
curl http://localhost/api/v1/your-service/metrics
```
### Performance Issues / Vấn đề Performance
**Slow Requests**:
- Check database query performance
- Review middleware chain efficiency
- Monitor Redis cache hit rates
- Check for memory leaks
**High Memory Usage**:
```bash
# EN: Check memory usage
# VI: Kiểm tra memory usage
docker stats
# EN: Monitor with Prometheus metrics
# VI: Monitor với Prometheus metrics
curl http://localhost/metrics | grep heap
```
## Docker / Docker
### Docker Image / Docker Image
This template includes a production-ready Dockerfile with:
Template này bao gồm Dockerfile production-ready với:
```dockerfile
# Multi-stage build for optimized image size
FROM node:20-alpine AS base
# ... dependency installation
FROM base AS builder
# ... build stage
FROM node:20-alpine AS runner
# ... production runtime
```
**Build the image:**
```bash
docker build -t your-service:latest .
```
### Docker Compose for Testing / Docker Compose Cho Testing
- **`docker-compose.test.yml`**: Isolated test environment with test database and Redis
**Run tests in Docker:**
```bash
docker-compose -f docker-compose.test.yml up -d
DATABASE_URL=postgresql://postgres:test_password@localhost:5433/microservice_test pnpm test
docker-compose -f docker-compose.test.yml down -v
```
### Production Deployment / Triển khai Production
For production deployment, services are orchestrated via:
Để triển khai production, các service được điều phối qua:
- **Local/Dev**: `deployments/local/docker-compose.yml`
- **Staging**: `deployments/staging/kubernetes/` (Kubernetes manifests)
- **Production**: `deployments/production/kubernetes/` (Kubernetes manifests)
**Build production image:**
```bash
docker build -t your-service:v1.0.0 .
docker tag your-service:v1.0.0 registry.example.com/your-service:v1.0.0
docker push registry.example.com/your-service:v1.0.0
```
### Docker Image Features / Tính năng Docker Image
- **Multi-stage Build**: Optimized for small production images
- **Security**: Non-root user, minimal attack surface
- **Health Checks**: Built-in health check endpoints
- **Signal Handling**: Proper signal handling with dumb-init
- **Layer Caching**: Efficient Docker layer caching
### Environment Variables for Docker / Biến môi trường cho Docker
When running in Docker, ensure these environment variables are set:
```bash
# EN: Database connection
# VI: Kết nối database
DATABASE_URL=postgresql://postgres:password@postgres:5432/microservice_template
# EN: Redis connection
# VI: Kết nối Redis
REDIS_URL=redis://redis:6379
# EN: JWT secret (change in production!)
# VI: JWT secret (thay đổi trong production!)
JWT_SECRET=your-production-jwt-secret
```
## Testing / Kiểm thử
```bash
# EN: Run all tests / Chạy tất cả tests
pnpm test
# EN: Run unit tests only / Chạy chỉ unit tests
pnpm test:unit
# EN: Run E2E tests only / Chạy chỉ E2E tests
pnpm test:e2e
# EN: Run tests with coverage / Chạy tests với coverage
pnpm test:coverage
# EN: Run tests in watch mode / Chạy tests ở chế độ watch
pnpm test:watch
# EN: Run tests in specific file / Chạy tests trong file cụ thể
pnpm test src/modules/feature/__tests__/feature.service.test.ts
# EN: Run tests matching pattern / Chạy tests khớp pattern
pnpm test -- --testNamePattern="authentication"
```
### Test Structure / Cấu trúc Test
```
src/
├── middlewares/__tests__/ # Middleware unit tests
├── modules/
│ ├── feature/__tests__/ # Feature module tests
│ └── health/__tests__/ # Health module tests
├── __tests__/ # E2E tests
│ ├── health.e2e.ts # Health endpoint E2E
│ └── feature.e2e.ts # Feature endpoint E2E
└── config/__tests__/ # Configuration tests
```
### Writing Tests / Viết Tests
#### Unit Test Example / Ví dụ Unit Test
```typescript
import { FeatureService } from '../feature.service';
import { featureRepository } from '../feature.repository';
jest.mock('../feature.repository');
describe('FeatureService', () => {
let service: FeatureService;
beforeEach(() => {
service = new FeatureService();
});
it('should create feature successfully', async () => {
const mockFeature = { id: '1', name: 'test', enabled: true };
(featureRepository.create as jest.Mock).mockResolvedValue(mockFeature);
const result = await service.create({ name: 'test' });
expect(result).toEqual(mockFeature);
});
});
```
#### E2E Test Example / Ví dụ E2E Test
```typescript
import request from 'supertest';
import express from 'express';
import { createRouter } from '../routes';
describe('Feature Endpoints E2E', () => {
let app: express.Application;
beforeAll(() => {
app = express();
app.use(express.json());
app.use(createRouter());
});
it('should create feature successfully', async () => {
const response = await request(app)
.post('/api/v1/features')
.send({ name: 'test-feature' })
.expect(201);
expect(response.body.success).toBe(true);
});
});
```
## Creating a New Service / Tạo Service Mới
To create a new microservice from this template / Để tạo microservice mới từ template này:
1. **Copy Template / Sao chép Template**:
```bash
# EN: Copy template to new service directory / Sao chép template vào thư mục service mới
cp -r services/_template services/your-service-name
cd services/your-service-name
```
2. **Update Package Configuration / Cập nhật Cấu hình Package**:
```bash
# EN: Update package.json name and description / Cập nhật tên và mô tả trong package.json
# VI: Thay đổi "name", "description", và các thông tin khác
```
3. **Configure Environment / Cấu hình Môi trường**:
```bash
# EN: Set up shared environment variables at platform level
# VI: Thiết lập biến môi trường chung ở cấp độ nền tảng
cd ../../deployments/local
cp env.local.example .env.local
# EN: Edit .env.local with shared values (JWT_SECRET, DATABASE_URL, etc.)
# VI: Chỉnh sửa .env.local với các giá trị chung (JWT_SECRET, DATABASE_URL, etc.)
nano .env.local
```
4. **Database Setup / Thiết lập Database**:
```bash
# EN: Update Prisma schema with your models / Cập nhật schema Prisma với models của bạn
# VI: Chỉnh sửa prisma/schema.prisma
# EN: Generate and run migrations / Tạo và chạy migrations
pnpm prisma:generate
pnpm prisma:migrate
```
5. **Implement Business Logic / Triển khai Logic Kinh doanh**:
- Add your modules in `src/modules/`
- Update routes in `src/routes/index.ts`
- Add validation schemas and DTOs
6. **Testing / Kiểm thử**:
```bash
# EN: Add tests for your new functionality / Thêm tests cho chức năng mới
pnpm test
```
7. **Documentation / Tài liệu**:
- Update `README.md` with service-specific information
- Update `ARCHITECTURE.md` if needed
- Update OpenAPI documentation in route files
## Extending the Template / Mở rộng Template
### Adding a New Module / Thêm Module Mới
1. **Create Module Structure / Tạo cấu trúc Module**:
```bash
mkdir -p src/modules/your-module/{__tests__}
touch src/modules/your-module/your-module.{controller,service,repository,dto,module}.ts
touch src/modules/your-module/__tests__/your-module.{service,controller}.test.ts
```
2. **Implement Repository / Triển khai Repository**:
```typescript
// src/modules/your-module/your-module.repository.ts
import { BaseRepository } from '../common/repository';
import { prisma } from '../../config/database.config';
export class YourModuleRepository extends BaseRepository<YourEntity, CreateInput, UpdateInput> {
constructor() {
super(prisma, 'yourEntity');
}
async findByCustomField(value: string): Promise<YourEntity[]> {
return this.prisma.yourEntity.findMany({
where: { customField: value },
});
}
}
export const yourModuleRepository = new YourModuleRepository();
```
3. **Implement Service / Triển khai Service**:
```typescript
// src/modules/your-module/your-module.service.ts
import { yourModuleRepository } from './your-module.repository';
import { CreateYourEntityDto, UpdateYourEntityDto } from './your-module.dto';
export class YourModuleService {
async create(data: CreateYourEntityDto) {
// Business logic
return yourModuleRepository.create(data);
}
async findAll() {
return yourModuleRepository.findAll();
}
}
```
4. **Implement Controller / Triển khai Controller**:
```typescript
// src/modules/your-module/your-module.controller.ts
import { Request, Response } from 'express';
import { asyncHandler } from '../../middlewares/error.middleware';
import { YourModuleService } from './your-module.service';
export class YourModuleController {
private service = new YourModuleService();
create = asyncHandler(async (req: Request, res: Response) => {
const result = await this.service.create(req.body);
res.status(201).json({
success: true,
data: result,
message: 'Created successfully',
timestamp: new Date().toISOString(),
});
});
findAll = asyncHandler(async (req: Request, res: Response) => {
const result = await this.service.findAll();
res.json({
success: true,
data: result,
message: 'Retrieved successfully',
timestamp: new Date().toISOString(),
});
});
}
```
5. **Create Routes / Tạo Routes**:
```typescript
// src/modules/your-module/your-module.module.ts
import { Router } from 'express';
import { YourModuleController } from './your-module.controller';
import { validateDto } from '../../middlewares/validation.middleware';
export const createYourModuleRouter = (): Router => {
const router = Router();
const controller = new YourModuleController();
router.post('/', validateDto(createYourEntitySchema), controller.create);
router.get('/', controller.findAll);
return router;
};
```
6. **Register Routes / Đăng ký Routes**:
```typescript
// src/routes/index.ts
import { createYourModuleRouter } from '../modules/your-module/your-module.module';
router.use('/api/v1/your-entities', createYourModuleRouter());
```
### Adding Environment Variables / Thêm Biến Môi trường
1. **Update config schema / Cập nhật config schema**:
```typescript
// src/config/app.config.ts
const envSchema = z.object({
// ... existing fields
YOUR_NEW_VARIABLE: z.string().default('default-value'),
});
export const appConfig = {
// ... existing config
yourNewVariable: config.YOUR_NEW_VARIABLE,
};
```
2. **Update .env files / Cập nhật file .env**:
```bash
# .env.example
YOUR_NEW_VARIABLE=your-default-value
# .env.local.example
YOUR_NEW_VARIABLE=your-local-value
```
### Security Best Practices / Thực tiễn Bảo mật
- **Input Validation**: Always validate and sanitize user inputs using Zod
- **Authentication**: Use JWT tokens with reasonable expiration times
- **Authorization**: Implement proper RBAC for your endpoints
- **Rate Limiting**: Protect against abuse with distributed rate limiting
- **HTTPS**: Always use HTTPS in production
- **Secrets**: Never commit secrets to version control
- **Dependencies**: Keep dependencies updated and audit regularly
### Performance Considerations / Lưu ý Performance
- **Database Queries**: Use indexes for frequently queried fields
- **Caching**: Implement Redis caching for expensive operations
- **Connection Pooling**: Configure appropriate connection pool sizes
- **Async Operations**: Use proper async/await patterns
- **Memory Management**: Monitor memory usage and implement cleanup
- **Metrics**: Monitor performance with built-in Prometheus metrics
## Development Guidelines / Hướng dẫn Phát triển
### Comments / Comment Code
- Use bilingual comments for all public APIs and complex logic.
- Format: `EN` first, then `VI`.
- See `.cursor/skills/comment-code/SKILL.md` for details.
### Adding a Module / Thêm Module
1. Create `src/modules/<name>/`.
2. Implement `Controller`, `Service`.
3. Register routes in `src/routes/index.ts`.
### Code Style / Phong cách Code
- Follow the established patterns in existing modules
- Use TypeScript strictly with proper type annotations
- Implement proper error handling with custom error classes
- Add comprehensive tests for all new functionality

View File

@@ -0,0 +1,37 @@
// EN: ESLint v9 Flat Config for Template Service
// VI: ESLint v9 Flat Config cho Template Service
import goodgoConfig from '@goodgo/eslint-config';
export default [
// EN: Global ignores (must be first and standalone)
// VI: Ignores toàn cục (phải đặt đầu tiên và độc lập)
{
ignores: [
'**/*.test.ts',
'**/*.spec.ts',
'**/*.e2e.ts',
'**/__tests__/**',
'**/tests/**',
'dist/**',
'node_modules/**',
],
},
// EN: Apply base config
// VI: Áp dụng config cơ bản
...goodgoConfig,
// EN: Service-specific configuration
// VI: Cấu hình riêng cho service
{
files: ['src/**/*.ts'],
languageOptions: {
parserOptions: {
tsconfigRootDir: import.meta.dirname,
project: './tsconfig.json',
},
},
rules: {
// EN: Add service-specific rules here
// VI: Thêm rules riêng cho service ở đây
},
},
];

View File

@@ -0,0 +1,40 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: [
'**/__tests__/**/*.test.ts',
'**/__tests__/**/*.spec.ts',
'**/__tests__/**/*.e2e.ts',
'**/?(*.)+(spec|test).ts'
],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/main.ts',
'!src/config/**/*.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,
// EN: Clear mocks between tests to avoid state leakage
// VI: Xóa mocks giữa các test để tránh rò rỉ state
clearMocks: true,
// EN: Reset modules between tests for isolation
// VI: Reset modules giữa các test để cô lập
resetModules: true
};
export default config;

View File

@@ -0,0 +1,67 @@
{
"name": "@goodgo/service-template",
"version": "1.0.0",
"type": "module",
"description": "Template for creating new microservices",
"main": "./dist/main.js",
"scripts": {
"dev": "tsx watch src/main.ts",
"build": "tsc",
"start": "node dist/main.js",
"test": "jest",
"test:unit": "jest --testPathPattern='src/modules/.*\\.test\\.ts$'",
"test:e2e": "jest --testPathPattern='src/__tests__/.*\\.e2e\\.ts$'",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"lint": "eslint src --ext .ts",
"typecheck": "tsc --noEmit",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "tsx prisma/seed.ts",
"clean": "rm -rf dist"
},
"dependencies": {
"@goodgo/auth-sdk": "workspace:*",
"@goodgo/logger": "workspace:*",
"@goodgo/tracing": "workspace:*",
"@goodgo/types": "workspace:*",
"@neondatabase/serverless": "^1.0.2",
"@prisma/adapter-neon": "^7.2.0",
"@prisma/client": "^7.2.0",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"ioredis": "^5.9.0",
"opossum": "^9.0.0",
"prom-client": "^15.1.3",
"rate-limit-redis": "^4.3.1",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"zod": "^4.3.5"
},
"devDependencies": {
"@goodgo/eslint-config": "workspace:*",
"@goodgo/tsconfig": "workspace:*",
"@jest/globals": "^30.2.0",
"@types/cors": "^2.8.19",
"@types/dotenv": "^8.2.3",
"@types/express": "^5.0.6",
"@types/ioredis": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^25.0.3",
"@types/opossum": "^8.1.9",
"@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"jest": "^30.2.0",
"prisma": "^7.2.0",
"supertest": "^7.2.2",
"ts-jest": "^29.4.6",
"ts-node": "^10.9.2",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,28 @@
// Prisma 7 Configuration File
// https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/introduction/configuration
import { PrismaClient } from '@prisma/client';
import { neonConfig } from '@neondatabase/serverless';
import { Pool } from '@neondatabase/serverless';
import { PrismaNeon } from '@prisma/adapter-neon';
// EN: Get database URL from environment
// VI: Lấy database URL từ environment
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is required');
}
// EN: Configure Neon connection pool
// VI: Cấu hình connection pool cho Neon
neonConfig.webSocketConstructor = globalThis.WebSocket;
const pool = new Pool({ connectionString: databaseUrl });
const adapter = new PrismaNeon(pool);
// EN: Export configured Prisma Client
// VI: Export Prisma Client đã cấu hình
export const prisma = new PrismaClient({ adapter });
export default prisma;

View File

@@ -0,0 +1,48 @@
// EN: Prisma schema for microservice template
// VI: Schema Prisma cho template microservice
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
// EN: Feature model - represents a configurable feature in the system
// VI: Model Feature - đại diện cho một tính năng có thể cấu hình trong hệ thống
model Feature {
// EN: Primary key / Khóa chính
id String @id @default(cuid())
// EN: Feature name (unique identifier) / Tên tính năng (mã định danh duy nhất)
name String @unique
// EN: Human-readable title / Tiêu đề dễ đọc
title String?
// EN: Detailed description / Mô tả chi tiết
description String?
// EN: Feature configuration as JSON / Cấu hình tính năng dạng JSON
config Json?
// EN: Whether the feature is enabled / Tính năng có được bật không
enabled Boolean @default(true)
// EN: Feature version for migration purposes / Phiên bản tính năng cho mục đích migration
version String? @default("1.0.0")
// EN: Timestamps / Dấu thời gian
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// EN: Optional tags for categorization / Tags tùy chọn để phân loại
tags String[]
// EN: Metadata for extensibility / Metadata để mở rộng
metadata Json?
@@map("features")
}

View File

@@ -0,0 +1,111 @@
import { PrismaClient } from '@prisma/client';
import { logger } from '@goodgo/logger';
// EN: Initialize Prisma client for seeding
// VI: Khởi tạo Prisma client cho seeding
const prisma = new PrismaClient();
async function main() {
logger.info('Starting database seeding / Bắt đầu seeding database');
// EN: Seed initial features
// VI: Seed các tính năng ban đầu
const features = [
{
name: 'health-checks',
title: 'Health Checks',
description: 'EN: System health monitoring endpoints / VI: Endpoints giám sát sức khỏe hệ thống',
enabled: true,
version: '1.0.0',
tags: ['system', 'monitoring'],
config: {
endpoints: ['/health', '/health/ready', '/health/live'],
interval: 30000, // 30 seconds
},
},
{
name: 'metrics',
title: 'Prometheus Metrics',
description: 'EN: Application metrics collection / VI: Thu thập metrics ứng dụng',
enabled: true,
version: '1.0.0',
tags: ['observability', 'monitoring'],
config: {
endpoint: '/metrics',
format: 'prometheus',
defaultMetrics: true,
},
},
{
name: 'rate-limiting',
title: 'Rate Limiting',
description: 'EN: API rate limiting protection / VI: Bảo vệ giới hạn tốc độ API',
enabled: true,
version: '1.0.0',
tags: ['security', 'performance'],
config: {
windowMs: 900000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
standardHeaders: true,
legacyHeaders: false,
},
},
{
name: 'cors',
title: 'CORS Protection',
description: 'EN: Cross-Origin Resource Sharing configuration / VI: Cấu hình Cross-Origin Resource Sharing',
enabled: true,
version: '1.0.0',
tags: ['security', 'api'],
config: {
origins: ['http://localhost:3000'],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
},
},
{
name: 'tracing',
title: 'Distributed Tracing',
description: 'EN: Jaeger/OpenTelemetry distributed tracing / VI: Distributed tracing với Jaeger/OpenTelemetry',
enabled: false,
version: '1.0.0',
tags: ['observability', 'tracing'],
config: {
serviceName: 'microservice-template',
jaegerEndpoint: 'http://localhost:14268/api/traces',
samplingRate: 1.0,
},
},
];
// EN: Create features in database
// VI: Tạo features trong database
for (const feature of features) {
await prisma.feature.upsert({
where: { name: feature.name },
update: {
title: feature.title,
description: feature.description,
enabled: feature.enabled,
version: feature.version,
tags: feature.tags,
config: feature.config,
updatedAt: new Date(),
},
create: feature,
});
logger.info(`Seeded feature: ${feature.name} / Đã seed feature: ${feature.name}`);
}
logger.info('Database seeding completed / Hoàn thành seeding database');
}
main()
.catch((e) => {
logger.error('Error during database seeding / Lỗi trong quá trình seeding database', { error: e });
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@@ -0,0 +1,237 @@
import request from 'supertest';
import express from 'express';
import { createRouter } from '../routes';
// EN: Mock external dependencies for E2E tests
// VI: Mock dependencies bên ngoài cho E2E tests
jest.mock('../config/database.config', () => ({
connectDatabase: jest.fn().mockResolvedValue(undefined),
prisma: {
$queryRaw: jest.fn().mockResolvedValue([{ '1': 1 }]),
$disconnect: jest.fn().mockResolvedValue(undefined),
feature: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
}));
// EN: Set up mock implementations for E2E tests
// VI: Thiết lập implementations mock cho E2E tests
const { prisma } = require('../config/database.config');
// EN: Mock successful feature creation for E2E
// VI: Mock việc tạo feature thành công cho E2E
prisma.feature.create.mockImplementation((args: any) => {
const data = args.data;
return Promise.resolve({
id: `e2e-${data.name}-id`,
name: data.name,
title: data.title || null,
description: data.description || null,
config: data.config || {},
enabled: true,
version: '1.0.0',
tags: data.tags || [],
createdAt: new Date(),
updatedAt: new Date(),
});
});
// EN: Mock other feature operations
// VI: Mock các operations feature khác
prisma.feature.findMany.mockResolvedValue([]);
prisma.feature.findUnique.mockResolvedValue(null);
jest.mock('../config/redis.config', () => ({
getRedisClient: jest.fn().mockReturnValue({
call: jest.fn(),
}),
}));
jest.mock('../config/app.config', () => ({
appConfig: {
port: 3001,
nodeEnv: 'test',
corsOrigin: '*',
},
}));
jest.mock('@goodgo/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
jest.mock('@goodgo/auth-sdk', () => ({
verifyToken: jest.fn(),
decodeToken: jest.fn(),
createToken: jest.fn(),
extractTokenFromHeader: jest.fn(),
isTokenExpired: jest.fn(),
}));
jest.mock('@goodgo/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('@goodgo/tracing', () => ({
initTracing: jest.fn(),
}));
jest.mock('prom-client', () => ({
register: {
getMetricsAsJSON: jest.fn().mockReturnValue({}),
metrics: jest.fn().mockReturnValue('# Test metrics'),
},
}));
describe('Feature Endpoints E2E', () => {
let app: express.Application;
beforeAll(() => {
// EN: Set up test environment
// VI: Thiết lập môi trường test
process.env.NODE_ENV = 'test';
process.env.API_VERSION = 'v1';
app = express();
app.use(express.json());
app.use(createRouter());
});
describe('POST /api/v1/features', () => {
it('should create a feature successfully', async () => {
// EN: Arrange
// VI: Chuẩn bị
const featureData = {
name: 'test-feature',
title: 'Test Feature',
description: 'A test feature for E2E testing'
};
// EN: Act
// VI: Thực hiện
const response = await request(app)
.post('/api/v1/features')
.send(featureData)
.expect(201);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
message: 'Feature created successfully / Feature đã được tạo thành công',
});
expect(response.body.timestamp).toBeDefined();
});
it('should handle minimal request body', async () => {
// EN: Arrange
// VI: Chuẩn bị
const minimalData = { name: 'minimal-feature' };
// EN: Act
// VI: Thực hiện
const response = await request(app)
.post('/api/v1/features')
.send(minimalData)
.expect(201);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
data: { name: 'minimal-feature' },
message: 'Feature created successfully / Feature đã được tạo thành công',
});
});
it('should handle complex feature data', async () => {
// EN: Arrange
// VI: Chuẩn bị
const complexFeatureData = {
name: 'advanced-feature',
title: 'Advanced Feature',
description: 'Feature with metadata',
config: {
version: '1.0.0',
enabled: true,
priority: 1,
settings: {
timeout: 5000,
retries: 3
}
},
tags: ['advanced', 'test']
};
// EN: Act
// VI: Thực hiện
const response = await request(app)
.post('/api/v1/features')
.send(complexFeatureData)
.expect(201);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
message: 'Feature created successfully / Feature đã được tạo thành công',
});
expect(response.body.timestamp).toBeDefined();
});
it('should handle missing content-type header', async () => {
// EN: Act - Send request without content-type
// VI: Thực hiện - Gửi request không có content-type
const response = await request(app)
.post('/api/v1/features')
.send('not json data')
.expect(201);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
message: 'Feature created successfully / Feature đã được tạo thành công',
});
});
it('should handle large request payloads', async () => {
// EN: Arrange - Create large payload
// VI: Chuẩn bị - Tạo payload lớn
const largeFeatureData = {
name: 'large-feature',
title: 'Large Feature',
description: 'A'.repeat(500), // Large description
config: {
largeData: 'B'.repeat(1000), // Large config data
}
};
// EN: Act
// VI: Thực hiện
const response = await request(app)
.post('/api/v1/features')
.send(largeFeatureData)
.expect(201);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
message: 'Feature created successfully / Feature đã được tạo thành công',
});
});
});
});

View File

@@ -0,0 +1,150 @@
import request from 'supertest';
import express from 'express';
import { createRouter } from '../routes';
// EN: Mock external dependencies for E2E tests
// VI: Mock dependencies bên ngoài cho E2E tests
jest.mock('../config/database.config', () => ({
connectDatabase: jest.fn().mockResolvedValue(undefined),
prisma: {
$queryRaw: jest.fn().mockResolvedValue([{ '1': 1 }]),
$disconnect: jest.fn().mockResolvedValue(undefined),
},
}));
jest.mock('../config/redis.config', () => ({
getRedisClient: jest.fn().mockReturnValue({
call: jest.fn(),
}),
}));
jest.mock('../config/app.config', () => ({
appConfig: {
port: 3001,
nodeEnv: 'test',
corsOrigin: '*',
},
}));
jest.mock('@goodgo/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('@goodgo/auth-sdk', () => ({
verifyToken: jest.fn(),
decodeToken: jest.fn(),
createToken: jest.fn(),
extractTokenFromHeader: jest.fn(),
isTokenExpired: jest.fn(),
}));
jest.mock('@goodgo/tracing', () => ({
initTracing: jest.fn(),
}));
jest.mock('prom-client', () => ({
register: {
contentType: 'text/plain; version=0.0.4; charset=utf-8',
getMetricsAsJSON: jest.fn().mockReturnValue({}),
metrics: jest.fn().mockReturnValue('# Test metrics'),
},
}));
describe('Health Endpoints E2E', () => {
let app: express.Application;
beforeAll(() => {
// EN: Set up test environment
// VI: Thiết lập môi trường test
process.env.NODE_ENV = 'test';
process.env.API_VERSION = 'v1';
app = express();
app.use(express.json());
app.use(createRouter());
});
describe('GET /health', () => {
it('should return healthy status', async () => {
// EN: Act
// VI: Thực hiện
const response = await request(app)
.get('/health')
.expect(200);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
data: {
status: 'ok',
},
});
expect(response.body.timestamp).toBeDefined();
expect(response.body.data.timestamp).toBeDefined();
});
});
describe('GET /health/ready', () => {
it('should return ready status when database is connected', async () => {
// EN: Act
// VI: Thực hiện
const response = await request(app)
.get('/health/ready')
.expect(200);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
data: {
status: 'ready',
},
});
expect(response.body.timestamp).toBeDefined();
});
// EN: Note: Database error testing requires more complex mocking
// VI: Lưu ý: Test lỗi database cần mocking phức tạp hơn
// EN: This test is skipped in E2E context as the mock setup is complex
// VI: Test này bị bỏ qua trong context E2E vì setup mock phức tạp
});
describe('GET /health/live', () => {
it('should return live status', async () => {
// EN: Act
// VI: Thực hiện
const response = await request(app)
.get('/health/live')
.expect(200);
// EN: Assert
// VI: Kiểm tra
expect(response.body).toMatchObject({
success: true,
data: {
status: 'live',
},
});
expect(response.body.timestamp).toBeDefined();
});
});
describe('GET /metrics', () => {
it('should return metrics in Prometheus format', async () => {
// EN: Act
// VI: Thực hiện
const response = await request(app)
.get('/metrics')
.expect(200);
// EN: Assert
// VI: Kiểm tra
expect(response.text).toBeDefined();
expect(response.headers['content-type']).toContain('text/plain');
});
});
});

View File

@@ -0,0 +1,158 @@
import { jest } from '@jest/globals';
// EN: Extend global types for test utilities
// VI: Mở rộng global types cho test utilities
declare global {
var testUtils: {
createMockReq: (overrides?: any) => any;
createMockRes: () => any;
createMockNext: () => jest.Mock;
};
}
// EN: Mock environment variables for tests
// VI: Mock biến môi trường cho tests
process.env.NODE_ENV = 'test';
process.env.DATABASE_URL = 'postgresql://test:test@localhost:5432/test_db';
process.env.REDIS_URL = 'redis://localhost:6379/1';
process.env.PORT = '3001';
process.env.SERVICE_NAME = 'test-service';
process.env.API_VERSION = 'v1';
// EN: Mock external services to avoid real network calls
// VI: Mock các service bên ngoài để tránh gọi mạng thật
jest.mock('@goodgo/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
// EN: Auth SDK mocking is handled in individual test files
// VI: Auth SDK mocking được xử lý trong từng test file riêng biệt
jest.mock('@goodgo/tracing', () => ({
initTracing: jest.fn(),
}));
// EN: Mock database client to avoid real DB connections in unit tests
// VI: Mock database client để tránh kết nối DB thật trong unit tests
jest.mock('../config/database.config', () => ({
connectDatabase: jest.fn(),
prisma: {
$queryRaw: jest.fn(),
$disconnect: jest.fn(),
$connect: jest.fn(),
feature: {
create: jest.fn(),
findMany: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
}));
// EN: Set up default mock implementations
// VI: Thiết lập implementations mock mặc định
const { prisma } = require('../config/database.config');
// EN: Mock successful feature creation
// VI: Mock việc tạo feature thành công
prisma.feature.create.mockResolvedValue({
id: 'test-feature-id',
name: 'test-feature',
title: 'Test Feature',
description: 'Test description',
config: {},
enabled: true,
version: '1.0.0',
tags: [],
createdAt: new Date(),
updatedAt: new Date(),
});
// EN: Mock successful feature queries
// VI: Mock việc query feature thành công
prisma.feature.findMany.mockResolvedValue([]);
prisma.feature.findUnique.mockResolvedValue(null);
prisma.feature.update.mockResolvedValue({
id: 'test-feature-id',
name: 'test-feature',
title: 'Updated Feature',
description: 'Updated description',
config: {},
enabled: true,
version: '1.0.0',
tags: [],
createdAt: new Date(),
updatedAt: new Date(),
});
prisma.feature.delete.mockResolvedValue({});
// EN: Mock Redis client to avoid real Redis connections
// VI: Mock Redis client để tránh kết nối Redis thật
jest.mock('../config/redis.config', () => ({
getRedisClient: jest.fn().mockReturnValue({
call: jest.fn(),
connect: jest.fn(),
disconnect: jest.fn(),
}),
}));
// EN: Mock Prometheus registry to avoid global state issues in tests
// VI: Mock Prometheus registry để tránh vấn đề global state trong tests
jest.mock('prom-client', () => {
const mockRegistry = {
registerMetric: jest.fn(),
getMetricsAsJSON: jest.fn().mockReturnValue({}),
metrics: jest.fn().mockReturnValue(''),
};
return {
Registry: jest.fn().mockImplementation(() => mockRegistry),
register: mockRegistry,
collectDefaultMetrics: jest.fn(),
Gauge: jest.fn().mockImplementation(() => ({
set: jest.fn(),
inc: jest.fn(),
dec: jest.fn(),
})),
Counter: jest.fn().mockImplementation(() => ({
inc: jest.fn(),
add: jest.fn(),
})),
Histogram: jest.fn().mockImplementation(() => ({
observe: jest.fn(),
startTimer: jest.fn().mockReturnValue(jest.fn()),
})),
};
});
// EN: Global test utilities
// VI: Utilities test toàn cục
global.testUtils = {
// EN: Helper to create mock request/response objects
// VI: Helper để tạo mock request/response objects
createMockReq: (overrides = {}) => ({
body: {},
params: {},
query: {},
headers: {},
...overrides,
}),
createMockRes: () => {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.send = jest.fn().mockReturnValue(res);
return res;
},
// EN: Helper to create mock next function
// VI: Helper để tạo mock next function
createMockNext: () => jest.fn(),
};

View File

@@ -0,0 +1,83 @@
import path from 'path';
import dotenv from 'dotenv';
import { z } from 'zod';
// EN: Load environment variables (optional for local development without Docker)
// VI: Tải biến môi trường (tùy chọn cho phát triển local không dùng Docker)
// EN: In production, environment variables are set via Docker Compose or Kubernetes
// VI: Trong production, biến môi trường được set qua Docker Compose hoặc Kubernetes
// EN: Priority: Docker Compose > .env.local > .env > System environment
// VI: Ưu tiên: Docker Compose > .env.local > .env > Môi trường hệ thống
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
dotenv.config({ path: path.resolve(process.cwd(), '.env.local'), override: true });
/**
* EN: Environment variable schema
* VI: Schema biến môi trường
*/
const envSchema = z.object({
PORT: z.string().default('5000').transform(Number), // Reorder: default before transform
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
API_VERSION: z.string().default('v1'),
CORS_ORIGIN: z.string().optional().default('http://localhost:3000'),
SERVICE_NAME: z.string().default('microservice-template'),
TRACING_ENABLED: z.enum(['true', 'false']).default('false'),
JAEGER_ENDPOINT: z.string().optional(),
JWT_SECRET: z.string().default('default-jwt-secret-change-in-production'),
REDIS_URL: z.string().default('redis://localhost:6379'),
});
/**
* EN: Parse and validate environment variables
* VI: Phân tích và validate biến môi trường
*/
const env = envSchema.safeParse(process.env);
if (!env.success) {
console.error('❌ Invalid environment variables:', JSON.stringify(env.error.format(), null, 2));
process.exit(1);
}
const config = env.data;
/**
* EN: Application configuration object
* VI: Đối tượng cấu hình ứng dụng
*/
export const appConfig = {
// EN: Server port
// VI: Cổng server
port: config.PORT,
// EN: Node environment
// VI: Môi trường Node
nodeEnv: config.NODE_ENV,
// EN: API version
// VI: Phiên bản API
apiVersion: config.API_VERSION,
// EN: CORS origins
// VI: Các origin cho CORS
corsOrigin: config.CORS_ORIGIN.split(','),
// EN: Service name
// VI: Tên dịch vụ
serviceName: config.SERVICE_NAME,
// EN: Tracing configuration
// VI: Cấu hình tracing
tracing: {
enabled: config.TRACING_ENABLED === 'true',
jaegerEndpoint: config.JAEGER_ENDPOINT,
},
// EN: Redis URL
// VI: URL Redis
redisUrl: config.REDIS_URL,
// EN: JWT Secret for authentication
// VI: JWT Secret để xác thực
jwtSecret: config.JWT_SECRET,
};

View File

@@ -0,0 +1,39 @@
import { logger } from '@goodgo/logger';
import { PrismaClient } from '@prisma/client';
/**
* EN: Prisma client instance configured for the application
* VI: Instance Prisma client được cấu hình cho ứng dụng
*/
export const prisma = new PrismaClient({
// EN: Enable detailed logging in development, minimal in production
// VI: Bật ghi log chi tiết trong development, tối thiểu trong production
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
/**
* EN: Establish database connection on application startup
* VI: Thiết lập kết nối database khi khởi động ứng dụng
*/
export const connectDatabase = async (): Promise<void> => {
try {
// EN: Connect to database using Prisma
// VI: Kết nối tới database sử dụng Prisma
await prisma.$connect();
logger.info('Database connected successfully / Kết nối database thành công');
} catch (error) {
// EN: Log error and exit if database connection fails
// VI: Ghi log lỗi và thoát nếu kết nối database thất bại
logger.error('Database connection failed / Kết nối database thất bại', { error });
process.exit(1);
}
};
/**
* EN: Close database connection on application shutdown
* VI: Đóng kết nối database khi tắt ứng dụng
*/
export const disconnectDatabase = async (): Promise<void> => {
await prisma.$disconnect();
logger.info('Database disconnected / Đã ngắt kết nối database');
};

View File

@@ -0,0 +1,38 @@
import { logger } from '@goodgo/logger';
import Redis from 'ioredis';
import { appConfig } from './app.config';
// EN: Redis connection instance
// VI: Instance kết nối Redis
let redisClient: Redis | undefined;
/**
* EN: Get or create Redis client
* VI: Lấy hoặc tạo Redis client
*/
export const getRedisClient = (): Redis => {
if (!redisClient) {
redisClient = new Redis(appConfig.redisUrl, {
// EN: Retry strategy
// VI: Chiến lược thử lại
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
},
// EN: Reconnect on error
// VI: Tự động kết nối lại khi lỗi
maxRetriesPerRequest: 3,
});
redisClient.on('error', (err) => {
logger.error('Redis connection error', { error: err.message });
});
redisClient.on('connect', () => {
logger.info('Redis connected successfully');
});
}
return redisClient;
};

View File

@@ -0,0 +1,124 @@
import request from 'supertest';
import express from 'express';
import { setupSwagger, specs } from '../swagger';
// EN: Import actual swagger specs for testing
// VI: Import actual swagger specs để test
// EN: Type definition for OpenAPI specs
// VI: Định nghĩa type cho OpenAPI specs
interface OpenAPISpec {
openapi: string;
info: {
title: string;
version: string;
[key: string]: any;
};
servers?: Array<{ url: string; [key: string]: any }>;
components?: {
securitySchemes?: {
[key: string]: {
type: string;
scheme: string;
[key: string]: any;
};
};
schemas?: {
[key: string]: any;
};
[key: string]: any;
};
[key: string]: any;
}
describe('Swagger Documentation', () => {
let app: express.Application;
beforeEach(() => {
app = express();
app.use(express.json());
// Reset mock
(setupSwagger as jest.Mock).mockClear();
});
describe('specs', () => {
it('should have valid OpenAPI structure', () => {
const typedSpecs = specs as OpenAPISpec;
expect(typedSpecs.openapi).toBe('3.0.0');
expect(typedSpecs.info).toBeDefined();
expect(typedSpecs.info.title).toBe('Microservice Template API');
expect(typedSpecs.info.version).toBe('1.0.0');
expect(typedSpecs.servers).toBeDefined();
expect(typedSpecs.components).toBeDefined();
});
it('should define security schemes', () => {
const typedSpecs = specs as OpenAPISpec;
expect(typedSpecs.components?.securitySchemes).toBeDefined();
expect(typedSpecs.components?.securitySchemes?.bearerAuth).toBeDefined();
expect(typedSpecs.components?.securitySchemes?.bearerAuth?.type).toBe('http');
expect(typedSpecs.components?.securitySchemes?.bearerAuth?.scheme).toBe('bearer');
});
it('should define response schemas', () => {
const typedSpecs = specs as OpenAPISpec;
const schemas = typedSpecs.components?.schemas;
expect(schemas?.ApiResponse).toBeDefined();
expect(schemas?.ErrorResponse).toBeDefined();
expect(schemas?.Feature).toBeDefined();
expect(schemas?.CreateFeatureRequest).toBeDefined();
expect(schemas?.UpdateFeatureRequest).toBeDefined();
expect(schemas?.UserInfo).toBeDefined();
});
it('should have server configurations', () => {
const typedSpecs = specs as OpenAPISpec;
expect(typedSpecs.servers).toBeInstanceOf(Array);
expect(typedSpecs.servers?.length).toBeGreaterThan(0);
expect(typedSpecs.servers?.[0]?.url).toContain('localhost');
});
});
describe('setupSwagger', () => {
it('should be callable', () => {
expect(typeof setupSwagger).toBe('function');
});
it('should accept app and basePath parameters', () => {
const mockApp = {
use: jest.fn(),
get: jest.fn(),
} as any;
setupSwagger(mockApp, '/docs');
expect(setupSwagger).toHaveBeenCalledWith(mockApp, '/docs');
});
});
describe('Swagger UI endpoints', () => {
beforeEach(() => {
// Setup real swagger for integration test
const realSetupSwagger = jest.requireActual('../swagger').setupSwagger;
realSetupSwagger(app, '/test-docs');
});
it('should serve swagger json endpoint', async () => {
const response = await request(app)
.get('/test-docs.json')
.expect(200);
expect(response.headers['content-type']).toContain('application/json');
expect(response.body.openapi).toBe('3.0.0');
});
it('should serve swagger yaml endpoint', async () => {
const response = await request(app)
.get('/test-docs.yaml')
.expect(200);
expect(response.headers['content-type']).toContain('application/yaml');
expect(response.text).toBeDefined();
});
});
});

View File

@@ -0,0 +1,364 @@
import { Application } from 'express';
import swaggerJSDoc from 'swagger-jsdoc';
import swaggerUi from 'swagger-ui-express';
/**
* EN: Swagger/OpenAPI configuration for API documentation
* VI: Cấu hình Swagger/OpenAPI cho tài liệu API
*/
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'Microservice Template API',
version: '1.0.0',
description: 'A production-ready microservice template with comprehensive features',
contact: {
name: 'Development Team',
email: 'dev@goodgo.com',
},
license: {
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
},
},
servers: [
{
url: 'http://localhost:{port}',
description: 'Development server',
variables: {
port: {
default: '5000',
description: 'Port number for the development server',
},
},
},
{
url: 'https://api.example.com',
description: 'Production server',
},
],
components: {
securitySchemes: {
bearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: 'JWT Authorization header using the Bearer scheme',
},
},
schemas: {
ApiResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
description: 'Indicates if the request was successful',
},
data: {
description: 'Response data (varies by endpoint)',
},
message: {
type: 'string',
description: 'Human-readable message',
},
timestamp: {
type: 'string',
format: 'date-time',
description: 'ISO 8601 timestamp of the response',
},
},
required: ['success', 'timestamp'],
},
ErrorResponse: {
type: 'object',
properties: {
success: {
type: 'boolean',
example: false,
},
error: {
type: 'object',
properties: {
code: {
type: 'string',
description: 'Error code for programmatic handling',
example: 'VALIDATION_ERROR',
},
message: {
type: 'string',
description: 'Human-readable error message',
example: 'Validation failed',
},
details: {
description: 'Additional error details (optional)',
},
},
required: ['code', 'message'],
},
timestamp: {
type: 'string',
format: 'date-time',
},
},
required: ['success', 'error', 'timestamp'],
},
Feature: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'Unique identifier',
example: 'clh1x8qkq0000abcdefghijk',
},
name: {
type: 'string',
description: 'Unique feature name',
example: 'user-management',
},
title: {
type: 'string',
description: 'Human-readable title',
example: 'User Management',
},
description: {
type: 'string',
description: 'Detailed description',
example: 'Complete user management system',
},
config: {
type: 'object',
description: 'Feature-specific configuration',
example: { enabled: true, priority: 1 },
},
enabled: {
type: 'boolean',
description: 'Whether the feature is enabled',
example: true,
},
version: {
type: 'string',
description: 'Feature version',
example: '1.0.0',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Categorization tags',
example: ['auth', 'users'],
},
createdAt: {
type: 'string',
format: 'date-time',
description: 'Creation timestamp',
},
updatedAt: {
type: 'string',
format: 'date-time',
description: 'Last update timestamp',
},
},
required: ['id', 'name', 'enabled', 'tags', 'createdAt', 'updatedAt'],
},
CreateFeatureRequest: {
type: 'object',
properties: {
name: {
type: 'string',
minLength: 1,
maxLength: 100,
description: 'Unique feature name',
example: 'new-feature',
},
title: {
type: 'string',
maxLength: 200,
description: 'Human-readable title',
example: 'New Feature',
},
description: {
type: 'string',
maxLength: 1000,
description: 'Detailed description',
example: 'A new feature for the system',
},
config: {
type: 'object',
description: 'Feature configuration',
example: { enabled: true },
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Categorization tags',
example: ['feature', 'new'],
},
},
required: ['name'],
},
UpdateFeatureRequest: {
type: 'object',
properties: {
title: {
type: 'string',
maxLength: 200,
description: 'Human-readable title',
},
description: {
type: 'string',
maxLength: 1000,
description: 'Detailed description',
},
config: {
type: 'object',
description: 'Feature configuration',
},
enabled: {
type: 'boolean',
description: 'Whether the feature is enabled',
},
tags: {
type: 'array',
items: { type: 'string' },
description: 'Categorization tags',
},
},
},
UserInfo: {
type: 'object',
properties: {
userId: {
type: 'string',
description: 'Unique user identifier',
example: 'user-123',
},
email: {
type: 'string',
format: 'email',
description: 'User email address',
example: 'user@example.com',
},
role: {
type: 'string',
description: 'User role',
example: 'admin',
enum: ['admin', 'user', 'moderator'],
},
iat: {
type: 'number',
description: 'Token issued at timestamp',
},
exp: {
type: 'number',
description: 'Token expiration timestamp',
},
},
required: ['userId', 'email', 'role'],
},
HealthResponse: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
data: {
type: 'object',
properties: {
status: { type: 'string', example: 'ok' },
timestamp: { type: 'string', format: 'date-time' },
},
},
timestamp: { type: 'string', format: 'date-time' },
},
},
ReadinessResponse: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
data: {
type: 'object',
properties: {
status: { type: 'string', example: 'ready' },
},
},
timestamp: { type: 'string', format: 'date-time' },
},
},
LivenessResponse: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
data: {
type: 'object',
properties: {
status: { type: 'string', example: 'live' },
},
},
timestamp: { type: 'string', format: 'date-time' },
},
},
},
},
security: [
{
bearerAuth: [],
},
],
},
apis: ['./src/routes/*.ts', './src/modules/*/feature.module.ts'], // Paths to files containing OpenAPI definitions
};
/**
* EN: Generate OpenAPI specification
* VI: Tạo OpenAPI specification
*/
const specs = swaggerJSDoc(options);
/**
* EN: Setup Swagger UI middleware
* VI: Thiết lập Swagger UI middleware
*/
export const setupSwagger = (app: Application, basePath: string = '/api-docs') => {
// EN: Swagger page
// VI: Trang Swagger
app.use(basePath, swaggerUi.serve, swaggerUi.setup(specs, {
explorer: true,
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
docExpansion: 'none',
filter: true,
showExtensions: true,
showCommonExtensions: true,
syntaxHighlight: {
activate: true,
theme: 'arta',
},
},
customCss: `
.swagger-ui .topbar { display: none }
.swagger-ui .info .title { color: #3b4151 }
`,
customSiteTitle: 'Microservice Template API Documentation',
customfavIcon: '/favicon.ico',
}));
// EN: Swagger JSON endpoint
// VI: Endpoint Swagger JSON
app.get(`${basePath}.json`, (_req, res) => {
res.setHeader('Content-Type', 'application/json');
res.send(specs);
});
// EN: Swagger YAML endpoint
// VI: Endpoint Swagger YAML
app.get(`${basePath}.yaml`, (_req, res) => {
res.setHeader('Content-Type', 'application/yaml');
// Note: Would need yaml package for full YAML support
res.send(JSON.stringify(specs, null, 2));
});
console.log(`📚 Swagger documentation available at: http://localhost:5000${basePath}`);
};
export { specs };
export default specs;

View File

@@ -0,0 +1,125 @@
import { ErrorCode, ERROR_CODE_TO_STATUS, getStatusFromErrorCode, isOperationalError } from '../error-codes';
describe('Error Codes', () => {
describe('ErrorCode Enum', () => {
it('should contain all expected error codes', () => {
expect(ErrorCode.UNAUTHORIZED).toBe('AUTH_001');
expect(ErrorCode.NOT_FOUND).toBe('RESOURCE_001');
expect(ErrorCode.VALIDATION_ERROR).toBe('VALIDATION_001');
expect(ErrorCode.INTERNAL_ERROR).toBe('SYS_001');
expect(ErrorCode.DATABASE_ERROR).toBe('DB_001');
});
});
describe('ERROR_CODE_TO_STATUS mapping', () => {
it('should map authentication errors correctly', () => {
expect(ERROR_CODE_TO_STATUS[ErrorCode.UNAUTHORIZED]).toBe(401);
expect(ERROR_CODE_TO_STATUS[ErrorCode.FORBIDDEN]).toBe(403);
expect(ERROR_CODE_TO_STATUS[ErrorCode.INVALID_TOKEN]).toBe(401);
});
it('should map validation errors correctly', () => {
expect(ERROR_CODE_TO_STATUS[ErrorCode.VALIDATION_ERROR]).toBe(422);
expect(ERROR_CODE_TO_STATUS[ErrorCode.INVALID_FORMAT]).toBe(422);
expect(ERROR_CODE_TO_STATUS[ErrorCode.REQUIRED_FIELD]).toBe(422);
});
it('should map resource errors correctly', () => {
expect(ERROR_CODE_TO_STATUS[ErrorCode.NOT_FOUND]).toBe(404);
expect(ERROR_CODE_TO_STATUS[ErrorCode.ALREADY_EXISTS]).toBe(409);
expect(ERROR_CODE_TO_STATUS[ErrorCode.CONFLICT]).toBe(409);
});
it('should map system errors correctly', () => {
expect(ERROR_CODE_TO_STATUS[ErrorCode.INTERNAL_ERROR]).toBe(500);
expect(ERROR_CODE_TO_STATUS[ErrorCode.RATE_LIMIT_EXCEEDED]).toBe(429);
expect(ERROR_CODE_TO_STATUS[ErrorCode.SERVICE_UNAVAILABLE]).toBe(503);
});
it('should map database errors correctly', () => {
expect(ERROR_CODE_TO_STATUS[ErrorCode.DATABASE_ERROR]).toBe(500);
expect(ERROR_CODE_TO_STATUS[ErrorCode.CONNECTION_ERROR]).toBe(503);
expect(ERROR_CODE_TO_STATUS[ErrorCode.CONSTRAINT_VIOLATION]).toBe(422);
});
});
describe('getStatusFromErrorCode', () => {
it('should return correct status for known error codes', () => {
expect(getStatusFromErrorCode(ErrorCode.NOT_FOUND)).toBe(404);
expect(getStatusFromErrorCode(ErrorCode.UNAUTHORIZED)).toBe(401);
expect(getStatusFromErrorCode(ErrorCode.VALIDATION_ERROR)).toBe(422);
expect(getStatusFromErrorCode(ErrorCode.INTERNAL_ERROR)).toBe(500);
});
it('should return 500 for unknown error codes', () => {
expect(getStatusFromErrorCode('UNKNOWN_ERROR' as ErrorCode)).toBe(500);
});
});
describe('isOperationalError', () => {
it('should identify operational errors correctly', () => {
// These should be operational (true)
expect(isOperationalError(ErrorCode.UNAUTHORIZED)).toBe(true);
expect(isOperationalError(ErrorCode.NOT_FOUND)).toBe(true);
expect(isOperationalError(ErrorCode.VALIDATION_ERROR)).toBe(true);
expect(isOperationalError(ErrorCode.CONFLICT)).toBe(true);
expect(isOperationalError(ErrorCode.RATE_LIMIT_EXCEEDED)).toBe(true);
});
it('should identify programming errors correctly', () => {
// These should NOT be operational (false)
expect(isOperationalError(ErrorCode.INTERNAL_ERROR)).toBe(false);
expect(isOperationalError(ErrorCode.DATABASE_ERROR)).toBe(false);
expect(isOperationalError(ErrorCode.CONFIGURATION_ERROR)).toBe(false);
expect(isOperationalError(ErrorCode.FEATURE_CONFIG_INVALID)).toBe(false);
});
it('should handle edge cases', () => {
expect(isOperationalError('UNKNOWN_ERROR' as ErrorCode)).toBe(false);
});
});
describe('Error Code Coverage', () => {
it('should have status mapping for all error codes', () => {
const allErrorCodes = Object.values(ErrorCode);
allErrorCodes.forEach(code => {
expect(ERROR_CODE_TO_STATUS[code]).toBeDefined();
expect(typeof ERROR_CODE_TO_STATUS[code]).toBe('number');
expect(ERROR_CODE_TO_STATUS[code]).toBeGreaterThanOrEqual(100);
expect(ERROR_CODE_TO_STATUS[code]).toBeLessThan(600);
});
});
it('should have operational classification for all error codes', () => {
const allErrorCodes = Object.values(ErrorCode);
allErrorCodes.forEach(code => {
expect(typeof isOperationalError(code)).toBe('boolean');
});
});
});
describe('Error Code Categories', () => {
it('should have consistent naming patterns', () => {
const allErrorCodes = Object.values(ErrorCode);
// Check that all codes follow pattern: CATEGORY_XXX
allErrorCodes.forEach(code => {
expect(code).toMatch(/^[A-Z]+_\d{3}$/);
});
});
it('should have unique error codes', () => {
const allErrorCodes = Object.values(ErrorCode);
const uniqueCodes = new Set(allErrorCodes);
expect(uniqueCodes.size).toBe(allErrorCodes.length);
});
it('should have reasonable HTTP status codes', () => {
const allStatuses = Object.values(ERROR_CODE_TO_STATUS);
allStatuses.forEach(status => {
expect(status).toBeGreaterThanOrEqual(200);
expect(status).toBeLessThan(600);
});
});
});
});

View File

@@ -0,0 +1,200 @@
import {
HttpError,
BadRequestError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
ConflictError,
ValidationError,
InternalServerError,
} from '../http-error';
describe('HttpError Classes', () => {
describe('HttpError Base Class', () => {
it('should create HttpError with custom properties', () => {
const error = new HttpError('Test error', 400, 'TEST_ERROR', true, { field: 'test' });
expect(error.message).toBe('Test error');
expect(error.statusCode).toBe(400);
expect(error.errorCode).toBe('TEST_ERROR');
expect(error.isOperational).toBe(true);
expect(error.details).toEqual({ field: 'test' });
});
it('should convert to API response format', () => {
const error = new HttpError('Test error', 400, 'TEST_ERROR', true, { field: 'test' });
const apiResponse = error.toApiResponse();
expect(apiResponse).toEqual({
success: false,
error: {
code: 'TEST_ERROR',
message: 'Test error',
details: { field: 'test' },
},
timestamp: expect.any(String),
});
});
it('should have default values', () => {
const error = new HttpError('Test error');
expect(error.statusCode).toBe(500);
expect(error.errorCode).toBe('INTERNAL_ERROR');
expect(error.isOperational).toBe(true);
expect(error.details).toBeUndefined();
});
});
describe('BadRequestError', () => {
it('should create BadRequestError with correct defaults', () => {
const error = new BadRequestError('Invalid input');
expect(error.statusCode).toBe(400);
expect(error.errorCode).toBe('BAD_REQUEST');
expect(error.message).toBe('Invalid input');
expect(error.isOperational).toBe(true);
});
it('should use default message', () => {
const error = new BadRequestError();
expect(error.message).toBe('Bad Request / Yêu cầu không hợp lệ');
});
});
describe('UnauthorizedError', () => {
it('should create UnauthorizedError with correct defaults', () => {
const error = new UnauthorizedError('Invalid credentials');
expect(error.statusCode).toBe(401);
expect(error.errorCode).toBe('UNAUTHORIZED');
expect(error.message).toBe('Invalid credentials');
expect(error.isOperational).toBe(true);
});
it('should use default message', () => {
const error = new UnauthorizedError();
expect(error.message).toBe('Authentication required / Yêu cầu xác thực');
});
});
describe('ForbiddenError', () => {
it('should create ForbiddenError with correct defaults', () => {
const error = new ForbiddenError('Access denied');
expect(error.statusCode).toBe(403);
expect(error.errorCode).toBe('FORBIDDEN');
expect(error.message).toBe('Access denied');
expect(error.isOperational).toBe(true);
});
it('should use default message', () => {
const error = new ForbiddenError();
expect(error.message).toBe('Access denied / Truy cập bị từ chối');
});
});
describe('NotFoundError', () => {
it('should create NotFoundError with resource name', () => {
const error = new NotFoundError('User');
expect(error.statusCode).toBe(404);
expect(error.errorCode).toBe('NOT_FOUND');
expect(error.message).toBe('User not found / User không tìm thấy');
expect(error.isOperational).toBe(true);
});
it('should use default resource name', () => {
const error = new NotFoundError();
expect(error.message).toBe('Resource / Tài nguyên not found / Resource / Tài nguyên không tìm thấy');
});
});
describe('ConflictError', () => {
it('should create ConflictError with correct defaults', () => {
const error = new ConflictError('Resource already exists');
expect(error.statusCode).toBe(409);
expect(error.errorCode).toBe('CONFLICT');
expect(error.message).toBe('Resource already exists');
expect(error.isOperational).toBe(true);
});
it('should use default message', () => {
const error = new ConflictError();
expect(error.message).toBe('Resource conflict / Xung đột tài nguyên');
});
});
describe('ValidationError', () => {
it('should create ValidationError with correct defaults', () => {
const error = new ValidationError('Invalid email format');
expect(error.statusCode).toBe(422);
expect(error.errorCode).toBe('VALIDATION_ERROR');
expect(error.message).toBe('Invalid email format');
expect(error.isOperational).toBe(true);
});
it('should use default message', () => {
const error = new ValidationError();
expect(error.message).toBe('Validation failed / Validation thất bại');
});
});
describe('InternalServerError', () => {
it('should create InternalServerError with correct defaults', () => {
const error = new InternalServerError('Database connection failed');
expect(error.statusCode).toBe(500);
expect(error.errorCode).toBe('INTERNAL_ERROR');
expect(error.message).toBe('Database connection failed');
expect(error.isOperational).toBe(false); // Programming error
});
it('should use default message', () => {
const error = new InternalServerError();
expect(error.message).toBe('Internal server error / Lỗi máy chủ nội bộ');
});
});
describe('Error Inheritance', () => {
it('should maintain instanceof relationships', () => {
const badRequest = new BadRequestError();
const validation = new ValidationError();
expect(badRequest instanceof HttpError).toBe(true);
expect(badRequest instanceof BadRequestError).toBe(true);
expect(badRequest instanceof Error).toBe(true);
expect(validation instanceof HttpError).toBe(true);
expect(validation instanceof ValidationError).toBe(true);
expect(validation instanceof Error).toBe(true);
});
it('should have correct constructor names', () => {
const badRequest = new BadRequestError();
const notFound = new NotFoundError();
expect(badRequest.constructor.name).toBe('BadRequestError');
expect(notFound.constructor.name).toBe('NotFoundError');
});
});
describe('Stack Traces', () => {
it('should capture stack traces', () => {
const error = new HttpError('Test error');
expect(error.stack).toBeDefined();
expect(error.stack).toContain('HttpError');
expect(error.stack).toContain('Test error');
});
});
});

View File

@@ -0,0 +1,190 @@
/**
* EN: Centralized error codes for consistent error handling
* VI: Error codes tập trung để xử lý lỗi nhất quán
*/
export enum ErrorCode {
// EN: Authentication & Authorization Errors
// VI: Lỗi Authentication & Authorization
UNAUTHORIZED = 'AUTH_001',
FORBIDDEN = 'AUTH_002',
INVALID_TOKEN = 'AUTH_003',
TOKEN_EXPIRED = 'AUTH_004',
MISSING_PERMISSIONS = 'AUTH_005',
// EN: Validation Errors
// VI: Lỗi Validation
VALIDATION_ERROR = 'VALIDATION_001',
INVALID_FORMAT = 'VALIDATION_002',
REQUIRED_FIELD = 'VALIDATION_003',
INVALID_VALUE = 'VALIDATION_004',
// EN: Resource Errors
// VI: Lỗi Resource
NOT_FOUND = 'RESOURCE_001',
ALREADY_EXISTS = 'RESOURCE_002',
CONFLICT = 'RESOURCE_003',
DELETED = 'RESOURCE_004',
// EN: Business Logic Errors
// VI: Lỗi Business Logic
INVALID_OPERATION = 'BUSINESS_001',
INSUFFICIENT_FUNDS = 'BUSINESS_002',
LIMIT_EXCEEDED = 'BUSINESS_003',
EXPIRED = 'BUSINESS_004',
// EN: External Service Errors
// VI: Lỗi External Service
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_001',
SERVICE_UNAVAILABLE = 'EXTERNAL_002',
TIMEOUT = 'EXTERNAL_003',
NETWORK_ERROR = 'EXTERNAL_004',
// EN: Database Errors
// VI: Lỗi Database
DATABASE_ERROR = 'DB_001',
CONNECTION_ERROR = 'DB_002',
QUERY_ERROR = 'DB_003',
CONSTRAINT_VIOLATION = 'DB_004',
// EN: System Errors
// VI: Lỗi System
INTERNAL_ERROR = 'SYS_001',
CONFIGURATION_ERROR = 'SYS_002',
RATE_LIMIT_EXCEEDED = 'SYS_003',
MAINTENANCE_MODE = 'SYS_004',
// EN: Health Check Errors
// VI: Lỗi Health Check
HEALTH_CHECK_FAILED = 'HEALTH_001',
DATABASE_UNHEALTHY = 'HEALTH_002',
CACHE_UNHEALTHY = 'HEALTH_003',
EXTERNAL_DEPENDENCY_UNHEALTHY = 'HEALTH_004',
// EN: Feature-Specific Errors
// VI: Lỗi Feature-Specific
FEATURE_NOT_ENABLED = 'FEATURE_001',
FEATURE_CONFIG_INVALID = 'FEATURE_002',
FEATURE_DEPENDENCY_MISSING = 'FEATURE_003',
}
/**
* EN: Error code to HTTP status mapping
* VI: Mapping error code sang HTTP status
*/
export const ERROR_CODE_TO_STATUS: Record<ErrorCode, number> = {
// Auth errors
[ErrorCode.UNAUTHORIZED]: 401,
[ErrorCode.FORBIDDEN]: 403,
[ErrorCode.INVALID_TOKEN]: 401,
[ErrorCode.TOKEN_EXPIRED]: 401,
[ErrorCode.MISSING_PERMISSIONS]: 403,
// Validation errors
[ErrorCode.VALIDATION_ERROR]: 422,
[ErrorCode.INVALID_FORMAT]: 422,
[ErrorCode.REQUIRED_FIELD]: 422,
[ErrorCode.INVALID_VALUE]: 422,
// Resource errors
[ErrorCode.NOT_FOUND]: 404,
[ErrorCode.ALREADY_EXISTS]: 409,
[ErrorCode.CONFLICT]: 409,
[ErrorCode.DELETED]: 410,
// Business errors
[ErrorCode.INVALID_OPERATION]: 422,
[ErrorCode.INSUFFICIENT_FUNDS]: 422,
[ErrorCode.LIMIT_EXCEEDED]: 422,
[ErrorCode.EXPIRED]: 410,
// External service errors
[ErrorCode.EXTERNAL_SERVICE_ERROR]: 502,
[ErrorCode.SERVICE_UNAVAILABLE]: 503,
[ErrorCode.TIMEOUT]: 504,
[ErrorCode.NETWORK_ERROR]: 502,
// Database errors
[ErrorCode.DATABASE_ERROR]: 500,
[ErrorCode.CONNECTION_ERROR]: 503,
[ErrorCode.QUERY_ERROR]: 500,
[ErrorCode.CONSTRAINT_VIOLATION]: 422,
// System errors
[ErrorCode.INTERNAL_ERROR]: 500,
[ErrorCode.CONFIGURATION_ERROR]: 500,
[ErrorCode.RATE_LIMIT_EXCEEDED]: 429,
[ErrorCode.MAINTENANCE_MODE]: 503,
// Health errors
[ErrorCode.HEALTH_CHECK_FAILED]: 503,
[ErrorCode.DATABASE_UNHEALTHY]: 503,
[ErrorCode.CACHE_UNHEALTHY]: 503,
[ErrorCode.EXTERNAL_DEPENDENCY_UNHEALTHY]: 503,
// Feature errors
[ErrorCode.FEATURE_NOT_ENABLED]: 403,
[ErrorCode.FEATURE_CONFIG_INVALID]: 500,
[ErrorCode.FEATURE_DEPENDENCY_MISSING]: 500,
};
/**
* EN: Get HTTP status from error code
* VI: Lấy HTTP status từ error code
*/
export function getStatusFromErrorCode(errorCode: ErrorCode): number {
return ERROR_CODE_TO_STATUS[errorCode] || 500;
}
/**
* EN: Check if error code represents an operational error (not a programming error)
* VI: Kiểm tra error code có phải operational error (không phải programming error)
*/
export function isOperationalError(errorCode: ErrorCode): boolean {
const operationalCodes = [
// Auth errors
ErrorCode.UNAUTHORIZED,
ErrorCode.FORBIDDEN,
ErrorCode.INVALID_TOKEN,
ErrorCode.TOKEN_EXPIRED,
ErrorCode.MISSING_PERMISSIONS,
// Validation errors
ErrorCode.VALIDATION_ERROR,
ErrorCode.INVALID_FORMAT,
ErrorCode.REQUIRED_FIELD,
ErrorCode.INVALID_VALUE,
// Resource errors
ErrorCode.NOT_FOUND,
ErrorCode.ALREADY_EXISTS,
ErrorCode.CONFLICT,
ErrorCode.DELETED,
// Business errors
ErrorCode.INVALID_OPERATION,
ErrorCode.INSUFFICIENT_FUNDS,
ErrorCode.LIMIT_EXCEEDED,
ErrorCode.EXPIRED,
// External service errors
ErrorCode.EXTERNAL_SERVICE_ERROR,
ErrorCode.SERVICE_UNAVAILABLE,
ErrorCode.TIMEOUT,
ErrorCode.NETWORK_ERROR,
// System errors
ErrorCode.RATE_LIMIT_EXCEEDED,
ErrorCode.MAINTENANCE_MODE,
// Health errors
ErrorCode.HEALTH_CHECK_FAILED,
ErrorCode.DATABASE_UNHEALTHY,
ErrorCode.CACHE_UNHEALTHY,
ErrorCode.EXTERNAL_DEPENDENCY_UNHEALTHY,
// Feature errors
ErrorCode.FEATURE_NOT_ENABLED,
];
return operationalCodes.includes(errorCode);
}

View File

@@ -0,0 +1,161 @@
/**
* EN: Base HTTP error class for structured error handling
* VI: Class lỗi HTTP cơ sở để xử lý lỗi có cấu trúc
*/
export class HttpError extends Error {
public readonly statusCode: number;
public readonly errorCode: string;
public readonly isOperational: boolean;
public readonly details?: any;
constructor(
message: string,
statusCode: number = 500,
errorCode: string = 'INTERNAL_ERROR',
isOperational: boolean = true,
details?: any
) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = isOperational;
this.details = details;
// EN: Capture stack trace for debugging
// VI: Capture stack trace để debug
Error.captureStackTrace(this, this.constructor);
}
/**
* EN: Convert error to API response format
* VI: Chuyển lỗi thành định dạng response API
*/
toApiResponse() {
return {
success: false,
error: {
code: this.errorCode,
message: this.message,
...(this.details && { details: this.details }),
},
timestamp: new Date().toISOString(),
};
}
}
/**
* EN: 400 Bad Request Error
* VI: Lỗi 400 Bad Request
*/
export class BadRequestError extends HttpError {
constructor(message: string = 'Bad Request / Yêu cầu không hợp lệ', details?: any) {
super(message, 400, 'BAD_REQUEST', true, details);
}
}
/**
* EN: 401 Unauthorized Error
* VI: Lỗi 401 Unauthorized
*/
export class UnauthorizedError extends HttpError {
constructor(message: string = 'Authentication required / Yêu cầu xác thực', details?: any) {
super(message, 401, 'UNAUTHORIZED', true, details);
}
}
/**
* EN: 403 Forbidden Error
* VI: Lỗi 403 Forbidden
*/
export class ForbiddenError extends HttpError {
constructor(message: string = 'Access denied / Truy cập bị từ chối', details?: any) {
super(message, 403, 'FORBIDDEN', true, details);
}
}
/**
* EN: 404 Not Found Error
* VI: Lỗi 404 Not Found
*/
export class NotFoundError extends HttpError {
constructor(resource: string = 'Resource / Tài nguyên', details?: any) {
super(`${resource} not found / ${resource} không tìm thấy`, 404, 'NOT_FOUND', true, details);
}
}
/**
* EN: 409 Conflict Error
* VI: Lỗi 409 Conflict
*/
export class ConflictError extends HttpError {
constructor(message: string = 'Resource conflict / Xung đột tài nguyên', details?: any) {
super(message, 409, 'CONFLICT', true, details);
}
}
/**
* EN: 422 Unprocessable Entity Error (for validation)
* VI: Lỗi 422 Unprocessable Entity (cho validation)
*/
export class ValidationError extends HttpError {
constructor(message: string = 'Validation failed / Validation thất bại', details?: any) {
super(message, 422, 'VALIDATION_ERROR', true, details);
}
}
/**
* EN: 429 Too Many Requests Error
* VI: Lỗi 429 Too Many Requests
*/
export class RateLimitError extends HttpError {
constructor(message: string = 'Too many requests / Quá nhiều yêu cầu', details?: any) {
super(message, 429, 'RATE_LIMIT_EXCEEDED', true, details);
}
}
/**
* EN: 500 Internal Server Error
* VI: Lỗi 500 Internal Server Error
*/
export class InternalServerError extends HttpError {
constructor(message: string = 'Internal server error / Lỗi máy chủ nội bộ', details?: any) {
super(message, 500, 'INTERNAL_ERROR', false, details);
}
}
/**
* EN: 503 Service Unavailable Error
* VI: Lỗi 503 Service Unavailable
*/
export class ServiceUnavailableError extends HttpError {
constructor(message: string = 'Service temporarily unavailable / Dịch vụ tạm thời không khả dụng', details?: any) {
super(message, 503, 'SERVICE_UNAVAILABLE', true, details);
}
}
/**
* EN: Database Error
* VI: Lỗi Database
*/
export class DatabaseError extends HttpError {
constructor(message: string = 'Database error / Lỗi database', details?: any) {
super(message, 500, 'DATABASE_ERROR', false, details);
}
}
/**
* EN: External Service Error
* VI: Lỗi External Service
*/
export class ExternalServiceError extends HttpError {
constructor(service: string, message?: string, details?: any) {
super(
message || `External service error: ${service} / Lỗi dịch vụ bên ngoài: ${service}`,
502,
'EXTERNAL_SERVICE_ERROR',
true,
details
);
}
}

View File

@@ -0,0 +1,26 @@
// EN: Export all error classes and utilities
// VI: Export tất cả error classes và utilities
export {
HttpError,
BadRequestError,
UnauthorizedError,
ForbiddenError,
NotFoundError,
ConflictError,
ValidationError,
RateLimitError,
InternalServerError,
ServiceUnavailableError,
DatabaseError,
ExternalServiceError,
} from './http-error';
export {
ErrorCode,
ERROR_CODE_TO_STATUS,
getStatusFromErrorCode,
isOperationalError,
} from './error-codes';
export { createHttpError } from '../middlewares/error.middleware';

View File

@@ -0,0 +1,134 @@
import { logger } from '@goodgo/logger';
import { initTracing } from '@goodgo/tracing';
import cors from 'cors';
import express from 'express';
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import { RedisStore } from 'rate-limit-redis';
import { appConfig } from './config/app.config';
import { connectDatabase } from './config/database.config';
import { prisma } from './config/database.config';
import { getRedisClient } from './config/redis.config';
import { setupSwagger } from './docs/swagger';
import { correlationMiddleware } from './middlewares/correlation.middleware';
import { errorHandler, notFoundHandler } from './middlewares/error.middleware';
import { requestLogger } from './middlewares/logger.middleware';
import { metricsMiddleware } from './middlewares/metrics.middleware';
import { createRouter } from './routes';
// EN: Initialize tracing
// VI: Khởi tạo tracing
if (process.env.TRACING_ENABLED === 'true') {
initTracing({
serviceName: process.env.SERVICE_NAME || 'microservice',
otlpEndpoint: process.env.OTLP_ENDPOINT,
enabled: true,
});
}
const app = express();
// EN: Security middleware
// VI: Middleware bảo mật
app.use(helmet());
app.use(
cors({
origin: appConfig.corsOrigin,
credentials: true,
})
);
// EN: Rate limiting
// VI: Giới hạn số lượng request
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
// EN: Use Redis for distributed rate limiting
// VI: Sử dụng Redis để giới hạn rate phân tán
store: new RedisStore({
// @ts-expect-error - rate-limit-redis types mismatch with ioredis
sendCommand: (...args: string[]) => getRedisClient().call(...args),
}),
});
app.use('/api', limiter);
// EN: Correlation ID middleware (must be early)
// VI: Correlation ID middleware (phải đặt sớm)
app.use(correlationMiddleware());
// EN: Body parsing
// VI: Phân tích body request
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// EN: Request logging
// VI: Ghi log request
app.use(requestLogger);
// EN: Metrics
// VI: Metrics
app.use(metricsMiddleware);
// EN: Routes with async error handling
// VI: Routes với async error handling
app.use(createRouter());
// EN: Setup Swagger documentation
// VI: Thiết lập tài liệu Swagger
setupSwagger(app, '/api-docs');
// EN: Error handling
// VI: Xử lý lỗi
app.use(notFoundHandler);
app.use(errorHandler);
const startServer = async () => {
try {
await connectDatabase();
const server = app.listen(appConfig.port, () => {
logger.info(`Service started on port ${appConfig.port}`, {
port: appConfig.port,
nodeEnv: appConfig.nodeEnv,
});
});
// EN: Graceful shutdown
// VI: Đóng ứng dụng một cách an toàn
const shutdown = async (signal: string) => {
logger.info(`${signal} received, shutting down gracefully`);
server.close(async () => {
logger.info('HTTP server closed');
try {
await prisma.$disconnect();
logger.info('Database connection closed');
process.exit(0);
} catch (error) {
logger.error('Error during shutdown', { error });
process.exit(1);
}
});
// EN: Force shutdown after 10s
// VI: Buộc dừng sau 10 giây
setTimeout(() => {
logger.error('Forcing shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
} catch (error) {
logger.error('Failed to start server', { error });
process.exit(1);
}
};
startServer();

View File

@@ -0,0 +1,345 @@
import { Request, Response } from 'express';
import { authenticate, authorize, hasRole, hasAnyRole, isAuthenticated } from '../auth.middleware';
import { createToken, verifyToken, extractTokenFromHeader } from '@goodgo/auth-sdk';
// EN: Mock auth-sdk functions
// VI: Mock các function của auth-sdk
jest.mock('@goodgo/auth-sdk', () => ({
createToken: jest.fn(),
verifyToken: jest.fn(),
extractTokenFromHeader: jest.fn(),
}));
// EN: Setup createToken mock to return fake tokens
// VI: Setup mock createToken để trả về fake tokens
(createToken as jest.Mock)
.mockReturnValueOnce('fake-user-token')
.mockReturnValueOnce('fake-admin-token');
// EN: Mock express types
// VI: Mock express types
const mockNext = jest.fn();
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
// EN: Helper to create mock request/response
// VI: Helper để tạo mock request/response
const createMockReq = (overrides: any = {}): Partial<Request> => ({
headers: {},
...overrides,
});
const createMockRes = (): Partial<Response> => ({
status: mockStatus,
json: mockJson,
});
describe('Authentication Middleware', () => {
const jwtSecret = 'test-secret-key';
const validToken = 'fake-user-token';
const adminToken = 'fake-admin-token';
beforeEach(() => {
jest.clearAllMocks();
// EN: Setup default mock implementations
// VI: Setup implementations mock mặc định
(extractTokenFromHeader as jest.Mock).mockImplementation((header) => {
if (!header || typeof header !== 'string') return null;
const parts = header.split(' ');
return parts.length === 2 && parts[0] === 'Bearer' ? parts[1] : null;
});
(verifyToken as jest.Mock).mockImplementation((token, _options) => {
if (token === 'fake-user-token') {
return {
userId: 'user-123',
email: 'user@example.com',
role: 'user',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
};
}
if (token === 'fake-admin-token') {
return {
userId: 'admin-123',
email: 'admin@example.com',
role: 'admin',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
};
}
throw new Error('Invalid token');
});
});
describe('authenticate', () => {
it('should authenticate valid token and attach user to request', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
headers: { authorization: `Bearer ${validToken}` },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authenticate({ secret: jwtSecret });
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).toHaveBeenCalled();
expect(mockReq.user).toEqual({
userId: 'user-123',
email: 'user@example.com',
role: 'user',
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(mockStatus).not.toHaveBeenCalled();
});
it('should return 401 for missing authorization header', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq();
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authenticate({ secret: jwtSecret });
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(401);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'AUTH_001',
message: 'Authentication required / Yêu cầu xác thực',
},
timestamp: expect.any(String),
});
});
it('should return 401 for invalid token', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
headers: { authorization: 'Bearer invalid-token' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authenticate({ secret: jwtSecret });
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(401);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'AUTH_002',
message: 'Invalid or expired token / Token không hợp lệ hoặc hết hạn',
},
timestamp: expect.any(String),
});
});
it('should return 401 for malformed authorization header', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
headers: { authorization: 'InvalidFormat token123' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authenticate({ secret: jwtSecret });
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(401);
});
});
describe('authorize', () => {
it('should allow access for user with correct role', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
user: { userId: 'admin-123', email: 'admin@example.com', role: 'admin' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authorize('admin');
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).toHaveBeenCalled();
expect(mockStatus).not.toHaveBeenCalled();
});
it('should deny access for user with incorrect role', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
user: { userId: 'user-123', email: 'user@example.com', role: 'user' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authorize('admin');
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(403);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'AUTH_004',
message: 'Insufficient permissions / Không đủ quyền',
},
timestamp: expect.any(String),
});
});
it('should allow access for user with any of the allowed roles', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
user: { userId: 'user-123', email: 'user@example.com', role: 'user' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authorize('admin', 'user', 'moderator');
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).toHaveBeenCalled();
expect(mockStatus).not.toHaveBeenCalled();
});
it('should return 401 for unauthenticated user', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq(); // No user attached
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = authorize('admin');
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(401);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'AUTH_003',
message: 'Authentication required / Yêu cầu xác thực',
},
timestamp: expect.any(String),
});
});
});
describe('Utility Functions', () => {
const user = { userId: '123', email: 'test@example.com', role: 'user' };
const admin = { userId: '456', email: 'admin@example.com', role: 'admin' };
describe('hasRole', () => {
it('should return true for matching role', () => {
expect(hasRole(user, 'user')).toBe(true);
expect(hasRole(admin, 'admin')).toBe(true);
});
it('should return false for non-matching role', () => {
expect(hasRole(user, 'admin')).toBe(false);
expect(hasRole(admin, 'user')).toBe(false);
});
it('should return false for null/undefined user', () => {
expect(hasRole(null as any, 'user')).toBe(false);
expect(hasRole(undefined as any, 'admin')).toBe(false);
});
});
describe('hasAnyRole', () => {
it('should return true if user has any of the specified roles', () => {
expect(hasAnyRole(user, ['user', 'admin'])).toBe(true);
expect(hasAnyRole(admin, ['user', 'admin'])).toBe(true);
});
it('should return false if user does not have any of the specified roles', () => {
expect(hasAnyRole(user, ['admin', 'moderator'])).toBe(false);
});
it('should return false for null/undefined user', () => {
expect(hasAnyRole(null as any, ['user'])).toBe(false);
expect(hasAnyRole(undefined as any, ['admin'])).toBe(false);
});
});
describe('isAuthenticated', () => {
it('should return true for authenticated user', () => {
expect(isAuthenticated(user)).toBe(true);
expect(isAuthenticated(admin)).toBe(true);
});
it('should return false for null/undefined user', () => {
expect(isAuthenticated(null as any)).toBe(false);
expect(isAuthenticated(undefined as any)).toBe(false);
});
});
});
describe('Integration Test', () => {
it('should authenticate and authorize admin user successfully', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
headers: { authorization: `Bearer ${adminToken}` },
});
const mockRes = createMockRes();
const nextChain: jest.Mock[] = [jest.fn(), jest.fn()];
// EN: Act - Test both authenticate and authorize middlewares
// VI: Thực hiện - Test cả hai middleware authenticate và authorize
const authMiddleware = authenticate({ secret: jwtSecret });
const authorizeMiddleware = authorize('admin');
authMiddleware(mockReq as Request, mockRes as Response, nextChain[0]);
if (nextChain[0].mock.calls.length > 0) {
authorizeMiddleware(mockReq as Request, mockRes as Response, nextChain[1]);
}
// EN: Assert
// VI: Kiểm tra
expect(nextChain[0]).toHaveBeenCalled(); // authenticate passed
expect(nextChain[1]).toHaveBeenCalled(); // authorize passed
expect(mockReq.user?.role).toBe('admin');
expect(mockStatus).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,272 @@
import { Request, Response } from 'express';
import {
correlationMiddleware,
CORRELATION_ID_HEADER,
REQUEST_ID_HEADER,
getCorrelationId,
getRequestId,
generateCorrelationId,
validateCorrelationId,
} from '../correlation.middleware';
// EN: Mock express types
// VI: Mock express types
const mockNext = jest.fn();
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
const mockSetHeader = jest.fn();
const mockGet = jest.fn();
// EN: Helper to create mock request/response
// VI: Helper để tạo mock request/response
const createMockReq = (overrides: any = {}): Partial<Request> => ({
path: '/test',
method: 'GET',
headers: {},
ip: '127.0.0.1',
get: mockGet,
...overrides,
});
const createMockRes = (): Partial<Response> => ({
setHeader: mockSetHeader,
status: mockStatus,
json: mockJson,
end: jest.fn(),
write: jest.fn(),
on: jest.fn(),
});
describe('Correlation Middleware', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockReturnValue('test-user-agent');
});
describe('correlationMiddleware', () => {
it('should generate new correlation ID when not provided', () => {
const mockReq = createMockReq();
const mockRes = createMockRes();
const middleware = correlationMiddleware();
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockReq.correlationId).toBeDefined();
expect(mockReq.requestId).toBeDefined();
expect(mockReq.correlationId).not.toBe(mockReq.requestId);
expect(mockNext).toHaveBeenCalled();
});
it('should use provided correlation ID from header', () => {
const existingCorrelationId = 'existing-correlation-id';
const mockReq = createMockReq({
headers: { [CORRELATION_ID_HEADER]: existingCorrelationId },
});
const mockRes = createMockRes();
const middleware = correlationMiddleware();
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockReq.correlationId).toBe(existingCorrelationId);
expect(mockReq.requestId).toBeDefined();
expect(mockReq.requestId).not.toBe(existingCorrelationId);
});
it('should set correlation ID headers on response', () => {
const mockReq = createMockReq();
const mockRes = createMockRes();
const middleware = correlationMiddleware();
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockSetHeader).toHaveBeenCalledWith(CORRELATION_ID_HEADER, mockReq.correlationId);
expect(mockSetHeader).toHaveBeenCalledWith(REQUEST_ID_HEADER, mockReq.requestId);
});
it('should skip correlation ID for health check paths', () => {
const healthPaths = ['/health', '/health/ready', '/health/live', '/metrics'];
healthPaths.forEach(path => {
const mockReq = createMockReq({ path });
const mockRes = createMockRes();
const middleware = correlationMiddleware();
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockReq.correlationId).toBe('');
expect(mockReq.requestId).toBe('');
expect(mockSetHeader).not.toHaveBeenCalled();
});
});
it('should use custom header name', () => {
const customHeader = 'x-custom-correlation-id';
const mockReq = createMockReq({
headers: { [customHeader]: 'custom-id' },
});
const mockRes = createMockRes();
const middleware = correlationMiddleware({ headerName: customHeader });
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockReq.correlationId).toBe('custom-id');
expect(mockSetHeader).toHaveBeenCalledWith(customHeader, 'custom-id');
});
it('should use custom ID generator', () => {
const customId = 'custom-generated-id';
const mockReq = createMockReq();
const mockRes = createMockRes();
const middleware = correlationMiddleware({
generateId: () => customId
});
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockReq.correlationId).toBe(customId);
});
it('should handle case-insensitive header names', () => {
const correlationId = 'test-correlation-id';
const mockReq = createMockReq({
headers: { 'X-CORRELATION-ID': correlationId }, // Uppercase
});
const mockRes = createMockRes();
const middleware = correlationMiddleware();
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockReq.correlationId).toBe(correlationId);
});
});
describe('Utility Functions', () => {
it('should get correlation ID from request', () => {
const mockReq = createMockReq();
(mockReq as any).correlationId = 'test-id';
expect(getCorrelationId(mockReq as Request)).toBe('test-id');
});
it('should return empty string if no correlation ID', () => {
const mockReq = createMockReq();
expect(getCorrelationId(mockReq as Request)).toBe('');
});
it('should get request ID from request', () => {
const mockReq = createMockReq();
(mockReq as any).requestId = 'test-request-id';
expect(getRequestId(mockReq as Request)).toBe('test-request-id');
});
it('should generate valid correlation ID', () => {
const id = generateCorrelationId();
// UUID v4 regex
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
expect(uuidRegex.test(id)).toBe(true);
});
});
describe('validateCorrelationId', () => {
it('should pass when correlation ID is provided and valid', () => {
const correlationId = '123e4567-e89b-12d3-a456-426614174000';
const mockReq = createMockReq({
headers: { [CORRELATION_ID_HEADER]: correlationId },
});
const mockRes = createMockRes();
const middleware = validateCorrelationId({ required: true, uuidOnly: true });
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalled();
expect(mockStatus).not.toHaveBeenCalled();
});
it('should return 400 when required correlation ID is missing', () => {
const mockReq = createMockReq();
const mockRes = createMockRes();
const middleware = validateCorrelationId({ required: true });
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(400);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'MISSING_CORRELATION_ID',
message: `Missing required header: ${CORRELATION_ID_HEADER}`,
},
timestamp: expect.any(String),
});
});
it('should return 400 when correlation ID is not a valid UUID', () => {
const mockReq = createMockReq({
headers: { [CORRELATION_ID_HEADER]: 'invalid-uuid' },
});
const mockRes = createMockRes();
const middleware = validateCorrelationId({ uuidOnly: true });
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(400);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'INVALID_CORRELATION_ID',
message: `Invalid ${CORRELATION_ID_HEADER} format`,
},
timestamp: expect.any(String),
});
});
it('should pass when correlation ID is not required and not provided', () => {
const mockReq = createMockReq();
const mockRes = createMockRes();
const middleware = validateCorrelationId({ required: false });
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalled();
});
it('should use custom header name', () => {
const customHeader = 'x-custom-id';
const mockReq = createMockReq({
headers: { [customHeader]: 'some-value' },
});
const mockRes = createMockRes();
const middleware = validateCorrelationId({
required: true,
headerName: customHeader
});
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockNext).toHaveBeenCalled();
});
});
describe('Request Logging Integration', () => {
it('should attach correlation context to request', () => {
const mockReq = createMockReq();
const mockRes = createMockRes();
const middleware = correlationMiddleware();
middleware(mockReq as Request, mockRes as Response, mockNext);
expect(mockReq.correlationId).toBeDefined();
expect(typeof mockReq.correlationId).toBe('string');
expect(mockReq.correlationId!.length).toBeGreaterThan(0);
expect(mockReq.requestId).toBeDefined();
expect(typeof mockReq.requestId).toBe('string');
expect(mockReq.requestId!.length).toBeGreaterThan(0);
});
});
});

View File

@@ -0,0 +1,209 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import { validateDto } from '../validation.middleware';
// EN: Mock express types
// VI: Mock express types
const mockNext = jest.fn();
const mockJson = jest.fn();
const mockStatus = jest.fn().mockReturnValue({ json: mockJson });
// EN: Helper to create mock request/response
// VI: Helper để tạo mock request/response
const createMockReq = (overrides: any = {}): Partial<Request> => ({
body: {},
query: {},
params: {},
...overrides,
});
const createMockRes = (): Partial<Response> => ({
status: mockStatus,
json: mockJson,
});
describe('Validation Middleware', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('validateDto', () => {
const testSchema = z.object({
name: z.string().min(1),
age: z.number().min(0),
email: z.string().email().optional(),
});
it('should pass validation for valid data', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
body: { name: 'John', age: 25, email: 'john@example.com' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = validateDto(testSchema);
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).toHaveBeenCalled();
expect(mockReq.body).toEqual({
name: 'John',
age: 25,
email: 'john@example.com',
});
expect(mockStatus).not.toHaveBeenCalled();
});
it('should sanitize string inputs by trimming whitespace', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
body: { name: ' John ', age: 25 },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = validateDto(testSchema);
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockReq.body!.name).toBe('John'); // Trimmed
});
it('should return 400 for invalid data', () => {
// EN: Arrange
// VI: Chuẩn bị
const mockReq = createMockReq({
body: { name: '', age: -5 }, // Invalid: empty name, negative age
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = validateDto(testSchema);
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).not.toHaveBeenCalled();
expect(mockStatus).toHaveBeenCalledWith(400);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request data / Dữ liệu request không hợp lệ',
details: expect.any(Array),
},
timestamp: expect.any(String),
});
});
it('should validate query parameters', () => {
// EN: Arrange
// VI: Chuẩn bị
const querySchema = z.object({
page: z.string().transform(Number),
limit: z.string().transform(Number).optional(),
});
const mockReq = createMockReq({
query: { page: '1', limit: '10' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = validateDto(querySchema, 'query');
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).toHaveBeenCalled();
expect(mockReq.query).toEqual({ page: 1, limit: 10 });
});
it('should validate route parameters', () => {
// EN: Arrange
// VI: Chuẩn bị
const paramsSchema = z.object({
id: z.string().uuid(),
});
const mockReq = createMockReq({
params: { id: '123e4567-e89b-12d3-a456-426614174000' },
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = validateDto(paramsSchema, 'params');
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockNext).toHaveBeenCalled();
expect(mockReq.params!.id).toBe('123e4567-e89b-12d3-a456-426614174000');
});
it('should handle nested object sanitization', () => {
// EN: Arrange
// VI: Chuẩn bị
const nestedSchema = z.object({
user: z.object({
name: z.string(),
settings: z.object({
theme: z.string(),
}),
}),
});
const mockReq = createMockReq({
body: {
user: {
name: ' Alice ',
settings: {
theme: ' dark ',
},
},
},
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = validateDto(nestedSchema);
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockReq.body!.user.name).toBe('Alice');
expect(mockReq.body!.user.settings.theme).toBe('dark');
});
it('should handle array sanitization', () => {
// EN: Arrange
// VI: Chuẩn bị
const arraySchema = z.object({
tags: z.array(z.string()),
});
const mockReq = createMockReq({
body: {
tags: [' react ', ' typescript ', ' node '],
},
});
const mockRes = createMockRes();
// EN: Act
// VI: Thực hiện
const middleware = validateDto(arraySchema);
middleware(mockReq as Request, mockRes as Response, mockNext);
// EN: Assert
// VI: Kiểm tra
expect(mockReq.body!.tags).toEqual(['react', 'typescript', 'node']);
});
});
});

View File

@@ -0,0 +1,256 @@
import { verifyToken, extractTokenFromHeader } from '@goodgo/auth-sdk';
import { logger } from '@goodgo/logger';
import { ApiResponse } from '@goodgo/types';
import { Request, Response, NextFunction } from 'express';
/**
* EN: Extended Request interface with user information
* VI: Interface Request mở rộng với thông tin người dùng
*/
declare global {
namespace Express {
interface Request {
user?: {
userId: string;
email: string;
role: string;
iat?: number;
exp?: number;
};
}
}
}
/**
* EN: Authentication middleware - verifies JWT tokens
* VI: Middleware xác thực - xác minh JWT tokens
*
* @param options - Configuration options / Tùy chọn cấu hình
*/
export const authenticate = (options: {
secret: string;
ignoreExpiration?: boolean;
} = { secret: process.env.JWT_SECRET || 'default-secret' }) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
// EN: Extract token from Authorization header
// VI: Trích xuất token từ header Authorization
const token = extractTokenFromHeader(req.headers.authorization);
if (!token) {
logger.warn('No authentication token provided / Không có token xác thực được cung cấp', {
path: req.path,
method: req.method,
});
const response: ApiResponse = {
success: false,
error: {
code: 'AUTH_001',
message: 'Authentication required / Yêu cầu xác thực',
},
timestamp: new Date().toISOString(),
};
res.status(401).json(response);
return;
}
// EN: Verify token
// VI: Xác minh token
const payload = verifyToken(token, {
secret: options.secret,
ignoreExpiration: options.ignoreExpiration,
});
// EN: Attach user information to request
// VI: Gắn thông tin người dùng vào request
req.user = {
userId: payload.userId,
email: payload.email,
role: payload.role,
iat: payload.iat,
exp: payload.exp,
};
logger.debug('User authenticated successfully / Người dùng đã được xác thực thành công', {
userId: payload.userId,
email: payload.email,
role: payload.role,
});
next();
} catch (error: any) {
logger.warn('Authentication failed / Xác thực thất bại', {
error: error.message,
path: req.path,
method: req.method,
});
const response: ApiResponse = {
success: false,
error: {
code: 'AUTH_002',
message: 'Invalid or expired token / Token không hợp lệ hoặc hết hạn',
},
timestamp: new Date().toISOString(),
};
res.status(401).json(response);
}
};
};
/**
* EN: Role-based authorization middleware
* VI: Middleware phân quyền dựa trên vai trò
*
* @param allowedRoles - Array of roles that can access the resource / Mảng các vai trò được phép truy cập tài nguyên
*/
export const authorize = (...allowedRoles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
// EN: Check if user is authenticated
// VI: Kiểm tra người dùng đã được xác thực chưa
if (!req.user) {
logger.warn('Authorization attempted without authentication / Phân quyền được thử mà không xác thực', {
path: req.path,
method: req.method,
});
const response: ApiResponse = {
success: false,
error: {
code: 'AUTH_003',
message: 'Authentication required / Yêu cầu xác thực',
},
timestamp: new Date().toISOString(),
};
res.status(401).json(response);
return;
}
// EN: Check if user has required role
// VI: Kiểm tra người dùng có vai trò cần thiết không
if (!allowedRoles.includes(req.user.role)) {
logger.warn('Access denied - insufficient permissions / Truy cập bị từ chối - không đủ quyền', {
userId: req.user.userId,
userRole: req.user.role,
requiredRoles: allowedRoles,
path: req.path,
method: req.method,
});
const response: ApiResponse = {
success: false,
error: {
code: 'AUTH_004',
message: 'Insufficient permissions / Không đủ quyền',
},
timestamp: new Date().toISOString(),
};
res.status(403).json(response);
return;
}
logger.debug('Authorization successful / Phân quyền thành công', {
userId: req.user.userId,
userRole: req.user.role,
path: req.path,
method: req.method,
});
next();
};
};
/**
* EN: Combined auth and authorization middleware
* VI: Middleware kết hợp xác thực và phân quyền
*
* @param secret - JWT secret key / Khóa bí mật JWT
* @param allowedRoles - Array of allowed roles / Mảng vai trò được phép
*/
export const requireAuth = (
secret: string = process.env.JWT_SECRET || 'default-secret',
...allowedRoles: string[]
) => {
return [authenticate({ secret }), authorize(...allowedRoles)];
};
/**
* EN: Optional authentication middleware - doesn't fail if no token provided
* VI: Middleware xác thực tùy chọn - không thất bại nếu không có token
*
* @param options - Configuration options / Tùy chọn cấu hình
*/
export const optionalAuth = (options: {
secret: string;
ignoreExpiration?: boolean;
} = { secret: process.env.JWT_SECRET || 'default-secret' }) => {
return (req: Request, _res: Response, next: NextFunction) => {
try {
const token = extractTokenFromHeader(req.headers.authorization);
if (token) {
const payload = verifyToken(token, {
secret: options.secret,
ignoreExpiration: options.ignoreExpiration,
});
req.user = {
userId: payload.userId,
email: payload.email,
role: payload.role,
iat: payload.iat,
exp: payload.exp,
};
logger.debug('Optional authentication successful / Xác thực tùy chọn thành công', {
userId: payload.userId,
});
}
next();
} catch (error: any) {
// EN: For optional auth, just continue without user info
// VI: Với optional auth, chỉ tiếp tục mà không có thông tin user
logger.debug('Optional authentication skipped / Xác thực tùy chọn bị bỏ qua', {
reason: error.message,
});
next();
}
};
};
/**
* EN: Check if user has specific role (utility function)
* VI: Kiểm tra người dùng có vai trò cụ thể (hàm tiện ích)
*
* @param user - User object from request / Đối tượng user từ request
* @param role - Role to check / Vai trò cần kiểm tra
* @returns True if user has the role / True nếu user có vai trò
*/
export const hasRole = (user: Express.Request['user'], role: string): boolean => {
return user?.role === role;
};
/**
* EN: Check if user has any of the specified roles (utility function)
* VI: Kiểm tra người dùng có bất kỳ vai trò nào trong danh sách (hàm tiện ích)
*
* @param user - User object from request / Đối tượng user từ request
* @param roles - Array of roles to check / Mảng vai trò cần kiểm tra
* @returns True if user has any of the roles / True nếu user có bất kỳ vai trò nào
*/
export const hasAnyRole = (user: Express.Request['user'], roles: string[]): boolean => {
return user ? roles.includes(user.role) : false;
};
/**
* EN: Check if user is authenticated (utility function)
* VI: Kiểm tra người dùng đã được xác thực (hàm tiện ích)
*
* @param user - User object from request / Đối tượng user từ request
* @returns True if user is authenticated / True nếu user đã được xác thực
*/
export const isAuthenticated = (user: Express.Request['user']): boolean => {
return !!user;
};

View File

@@ -0,0 +1,248 @@
import { randomUUID } from 'crypto';
import { logger } from '@goodgo/logger';
import { Request, Response, NextFunction } from 'express';
/**
* EN: Correlation ID header name
* VI: Tên header cho Correlation ID
*/
export const CORRELATION_ID_HEADER = 'x-correlation-id';
export const REQUEST_ID_HEADER = 'x-request-id';
/**
* EN: Extended Request interface with correlation ID
* VI: Interface Request mở rộng với correlation ID
*/
declare global {
namespace Express {
interface Request {
correlationId: string;
requestId: string;
}
}
}
/**
* EN: Correlation ID middleware - generates and propagates correlation IDs
* VI: Middleware Correlation ID - tạo và truyền correlation IDs
*/
export const correlationMiddleware = (
options: {
headerName?: string;
generateId?: () => string;
skipPaths?: string[];
} = {}
) => {
const {
headerName = CORRELATION_ID_HEADER,
generateId = randomUUID,
skipPaths = ['/health', '/metrics', '/favicon.ico'],
} = options;
return (req: Request, res: Response, next: NextFunction) => {
// EN: Skip correlation ID for certain paths
// VI: Bỏ qua correlation ID cho một số paths
if (skipPaths.some(path => req.path.startsWith(path))) {
req.correlationId = '';
req.requestId = '';
return next();
}
// EN: Get correlation ID from header or generate new one
// VI: Lấy correlation ID từ header hoặc tạo mới
const correlationId = req.headers[headerName.toLowerCase()] as string || generateId();
// EN: Generate unique request ID for this specific request
// VI: Tạo request ID duy nhất cho request này
const requestId = generateId();
// EN: Attach to request object
// VI: Gắn vào request object
req.correlationId = correlationId;
req.requestId = requestId;
// EN: Add correlation ID to response headers
// VI: Thêm correlation ID vào response headers
res.setHeader(headerName, correlationId);
res.setHeader(REQUEST_ID_HEADER, requestId);
// EN: Add to logger context
// VI: Thêm vào logger context
logger.info('Request started / Request bắt đầu', {
correlationId,
requestId,
method: req.method,
url: req.url,
userAgent: req.get('User-Agent'),
ip: req.ip,
});
// EN: Store original end method
// VI: Lưu original end method
const originalEnd = res.end;
const originalJson = res.json;
const originalSend = res.send;
// EN: Override response methods to log completion
// VI: Override response methods để log completion
const logCompletion = () => {
logger.info('Request completed / Request hoàn thành', {
correlationId,
requestId,
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration: Date.now() - (req as any).startTime,
});
};
// EN: Track request start time
// VI: Theo dõi thời gian bắt đầu request
(req as any).startTime = Date.now();
// EN: Override end method
// VI: Override end method
res.end = function(chunk?: any, encodingOrCb?: BufferEncoding | (() => void), cb?: () => void): Response {
logCompletion();
// EN: Handle different overloads of end method
// VI: Xử lý các overloads khác nhau của end method
if (typeof encodingOrCb === 'function') {
return (originalEnd as any).call(this, chunk, encodingOrCb);
}
if (encodingOrCb !== undefined && cb !== undefined) {
return (originalEnd as any).call(this, chunk, encodingOrCb, cb);
}
if (encodingOrCb !== undefined) {
return (originalEnd as any).call(this, chunk, encodingOrCb);
}
return (originalEnd as any).call(this, chunk);
};
// EN: Override json method
// VI: Override json method
res.json = function(body?: any) {
logCompletion();
return originalJson.call(this, body);
};
// EN: Override send method
// VI: Override send method
res.send = function(body?: any) {
logCompletion();
return originalSend.call(this, body);
};
next();
};
};
/**
* EN: Get correlation ID from request
* VI: Lấy correlation ID từ request
*/
export const getCorrelationId = (req: Request): string => {
return req.correlationId || '';
};
/**
* EN: Get request ID from request
* VI: Lấy request ID từ request
*/
export const getRequestId = (req: Request): string => {
return req.requestId || '';
};
/**
* EN: Create child logger with correlation context
* VI: Tạo child logger với correlation context
*/
export const createCorrelationLogger = (req: Request): ReturnType<typeof logger.child> => {
return logger.child({
correlationId: req.correlationId,
requestId: req.requestId,
});
};
/**
* EN: Middleware to add correlation ID to outgoing HTTP requests
* VI: Middleware để thêm correlation ID vào outgoing HTTP requests
*/
export const correlationHttpClient = (correlationId?: string) => {
return {
headers: correlationId ? {
[CORRELATION_ID_HEADER]: correlationId,
} : {},
};
};
/**
* EN: Generate correlation ID
* VI: Tạo correlation ID
*/
export const generateCorrelationId = (): string => {
return randomUUID();
};
/**
* EN: Middleware to validate correlation ID format
* VI: Middleware để validate correlation ID format
*/
export const validateCorrelationId = (
options: {
required?: boolean;
headerName?: string;
uuidOnly?: boolean;
} = {}
) => {
const {
required = false,
headerName = CORRELATION_ID_HEADER,
uuidOnly = false,
} = options;
return (req: Request, res: Response, next: NextFunction): void => {
const correlationId = req.headers[headerName.toLowerCase()] as string;
if (required && !correlationId) {
logger.warn(`Missing required correlation ID header: ${headerName}`, {
path: req.path,
method: req.method,
});
res.status(400).json({
success: false,
error: {
code: 'MISSING_CORRELATION_ID',
message: `Missing required header: ${headerName}`,
},
timestamp: new Date().toISOString(),
});
return;
}
if (correlationId && uuidOnly) {
// EN: Basic UUID v4 validation
// VI: Validation UUID v4 cơ bản
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(correlationId)) {
logger.warn(`Invalid correlation ID format: ${correlationId}`, {
path: req.path,
method: req.method,
});
res.status(400).json({
success: false,
error: {
code: 'INVALID_CORRELATION_ID',
message: `Invalid ${headerName} format`,
},
timestamp: new Date().toISOString(),
});
return;
}
}
next();
};
};

View File

@@ -0,0 +1,205 @@
import { logger } from '@goodgo/logger';
import express from 'express';
import { ErrorCode, getStatusFromErrorCode, isOperationalError } from '../errors/error-codes';
import { HttpError } from '../errors/http-error';
/**
* EN: Global error handler middleware with enhanced error handling
* VI: Middleware xử lý lỗi toàn cục với enhanced error handling
*/
export const errorHandler = (
err: any,
req: express.Request,
res: express.Response,
_next: express.NextFunction
): void => {
let statusCode = 500;
let errorCode = ErrorCode.INTERNAL_ERROR;
let message = 'Internal server error / Lỗi máy chủ nội bộ';
let details: any = undefined;
let isOperational = false;
// EN: Handle HttpError instances (our custom errors)
// VI: Xử lý HttpError instances (custom errors của chúng ta)
if (err instanceof HttpError) {
statusCode = err.statusCode;
errorCode = err.errorCode as ErrorCode;
message = err.message;
details = err.details;
isOperational = err.isOperational;
}
// EN: Handle Prisma errors
// VI: Xử lý Prisma errors
else if (err.code && typeof err.code === 'string') {
if (err.code === 'P2002') {
// Unique constraint violation
statusCode = 409;
errorCode = ErrorCode.CONSTRAINT_VIOLATION;
message = 'Resource already exists / Tài nguyên đã tồn tại';
isOperational = true;
} else if (err.code.startsWith('P1')) {
// Database connection/query errors
statusCode = 500;
errorCode = ErrorCode.DATABASE_ERROR;
message = 'Database operation failed / Thao tác database thất bại';
isOperational = false;
} else if (err.code.startsWith('P2')) {
// Data validation errors
statusCode = 422;
errorCode = ErrorCode.VALIDATION_ERROR;
message = 'Data validation failed / Validation dữ liệu thất bại';
isOperational = true;
}
}
// EN: Handle JWT errors
// VI: Xử lý JWT errors
else if (err.name === 'JsonWebTokenError') {
statusCode = 401;
errorCode = ErrorCode.INVALID_TOKEN;
message = 'Invalid authentication token / Token xác thực không hợp lệ';
isOperational = true;
} else if (err.name === 'TokenExpiredError') {
statusCode = 401;
errorCode = ErrorCode.TOKEN_EXPIRED;
message = 'Authentication token expired / Token xác thực đã hết hạn';
isOperational = true;
}
// EN: Handle Zod validation errors
// VI: Xử lý Zod validation errors
else if (err.name === 'ZodError') {
statusCode = 422;
errorCode = ErrorCode.VALIDATION_ERROR;
message = 'Validation failed / Validation thất bại';
details = err.errors.map((e: any) => ({
field: e.path.join('.'),
message: e.message,
code: e.code,
}));
isOperational = true;
}
// EN: Handle Express/Multer file upload errors
// VI: Xử lý Express/Multer file upload errors
else if (err.name === 'MulterError') {
statusCode = 400;
errorCode = ErrorCode.INVALID_FORMAT;
message = 'File upload error / Lỗi upload file';
isOperational = true;
}
// EN: Handle rate limiting errors
// VI: Xử lý rate limiting errors
else if (err.message && err.message.includes('Too many requests')) {
statusCode = 429;
errorCode = ErrorCode.RATE_LIMIT_EXCEEDED;
message = 'Rate limit exceeded / Vượt quá giới hạn tốc độ';
isOperational = true;
}
// EN: Handle generic errors
// VI: Xử lý generic errors
else {
// EN: Try to map error message patterns
// VI: Thử map error message patterns
const errorMessage = err.message?.toLowerCase() || '';
if (errorMessage.includes('not found')) {
statusCode = 404;
errorCode = ErrorCode.NOT_FOUND;
message = err.message;
isOperational = true;
} else if (errorMessage.includes('unauthorized') || errorMessage.includes('not authenticated')) {
statusCode = 401;
errorCode = ErrorCode.UNAUTHORIZED;
message = err.message;
isOperational = true;
} else if (errorMessage.includes('forbidden') || errorMessage.includes('not allowed')) {
statusCode = 403;
errorCode = ErrorCode.FORBIDDEN;
message = err.message;
isOperational = true;
} else if (errorMessage.includes('validation') || errorMessage.includes('invalid')) {
statusCode = 422;
errorCode = ErrorCode.VALIDATION_ERROR;
message = err.message;
isOperational = true;
}
}
// EN: Prepare error details for logging
// VI: Chuẩn bị chi tiết lỗi để logging
const errorDetails = {
message: err.message,
name: err.name,
code: err.code,
statusCode,
errorCode,
isOperational,
stack: err.stack,
url: req.url,
method: req.method,
userAgent: req.get('User-Agent'),
ip: req.ip,
userId: (req as any).user?.userId,
details,
};
// EN: Log error with appropriate level
// VI: Log lỗi với level phù hợp
if (!isOperational || statusCode >= 500) {
logger.error('Unhandled error occurred / Lỗi không mong muốn xảy ra', errorDetails);
} else {
logger.warn('Operational error occurred / Lỗi operational xảy ra', errorDetails);
}
// EN: Prepare response based on environment
// VI: Chuẩn bị response dựa trên environment
const isProduction = process.env.NODE_ENV === 'production';
const response = {
success: false,
error: {
code: errorCode,
message: isProduction && statusCode >= 500 ? 'Internal server error / Lỗi máy chủ nội bộ' : message,
...(details && !isProduction && { details }),
},
timestamp: new Date().toISOString(),
};
res.status(statusCode).json(response);
};
/**
* EN: 404 Not Found handler with enhanced error details
* VI: Handler 404 Not Found với enhanced error details
*/
export const notFoundHandler = (
req: express.Request,
_res: express.Response,
next: express.NextFunction
): void => {
const error = new HttpError(
`Route ${req.originalUrl} not found / Route ${req.originalUrl} không tìm thấy`,
404,
ErrorCode.NOT_FOUND
);
next(error);
};
/**
* EN: Async error wrapper to catch promise rejections
* VI: Async error wrapper để catch promise rejections
*/
export const asyncHandler = (fn: Function) => {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};
/**
* EN: Create HttpError from error code
* VI: Tạo HttpError từ error code
*/
export const createHttpError = (errorCode: ErrorCode, message?: string, details?: any): HttpError => {
const statusCode = getStatusFromErrorCode(errorCode);
const isOperational = isOperationalError(errorCode);
return new HttpError(message || `${errorCode}`, statusCode, errorCode, isOperational, details);
};

View File

@@ -0,0 +1,40 @@
import { logger } from '@goodgo/logger';
import { Request, Response, NextFunction } from 'express';
import { getCorrelationId, getRequestId } from './correlation.middleware';
/**
* EN: Enhanced request logger with correlation ID support
* VI: Request logger nâng cao với hỗ trợ correlation ID
*/
export const requestLogger = (req: Request, res: Response, next: NextFunction): void => {
// EN: Skip detailed logging for health checks and metrics (already logged by correlation middleware)
// VI: Bỏ qua logging chi tiết cho health checks và metrics (đã được log bởi correlation middleware)
if (req.path.startsWith('/health') || req.path.startsWith('/metrics')) {
return next();
}
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const correlationId = getCorrelationId(req);
const requestId = getRequestId(req);
logger.info('Request processed / Request đã xử lý', {
correlationId,
requestId,
method: req.method,
path: req.path,
query: req.query,
statusCode: res.statusCode,
duration: `${duration}ms`,
contentLength: res.get('Content-Length') || 0,
userAgent: req.get('User-Agent'),
ip: req.ip,
userId: (req as any).user?.userId,
});
});
next();
};

View File

@@ -0,0 +1,191 @@
import { Request, Response, NextFunction } from 'express';
import client from 'prom-client';
import { getCorrelationId } from './correlation.middleware';
// EN: Create a Registry which registers the metrics
// VI: Tạo Registry để đăng ký các metrics
const register = client.register;
// EN: Collect default metrics
// VI: Thu thập các metrics mặc định
client.collectDefaultMetrics({ register });
// EN: Create histogram for HTTP request duration
// VI: Tạo histogram cho thời lượng request HTTP
const httpRequestDurationSeconds = new client.Histogram({
name: 'http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds / Thời lượng request HTTP tính bằng giây',
labelNames: ['method', 'route', 'status_code', 'correlation_id'],
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5, 10],
});
// EN: Create counter for total HTTP requests
// VI: Tạo counter cho tổng số request HTTP
const httpRequestsTotal = new client.Counter({
name: 'http_requests_total',
help: 'Total number of HTTP requests / Tổng số request HTTP',
labelNames: ['method', 'route', 'status_code'],
});
// EN: Create gauge for active requests
// VI: Tạo gauge cho active requests
const activeRequests = new client.Gauge({
name: 'http_active_requests',
help: 'Number of active HTTP requests / Số lượng request HTTP đang hoạt động',
});
// EN: Create counter for HTTP request errors
// VI: Tạo counter cho lỗi HTTP request
const httpRequestErrors = new client.Counter({
name: 'http_request_errors_total',
help: 'Total number of HTTP request errors / Tổng số lỗi HTTP request',
labelNames: ['method', 'route', 'error_type'],
});
// EN: Create histogram for request payload size
// VI: Tạo histogram cho kích thước payload request
const requestPayloadSize = new client.Histogram({
name: 'http_request_payload_size_bytes',
help: 'Size of HTTP request payloads in bytes / Kích thước payload request HTTP tính bằng bytes',
labelNames: ['method', 'route'],
buckets: [100, 1000, 10000, 100000, 1000000],
});
// EN: Create histogram for response payload size
// VI: Tạo histogram cho kích thước payload response
const responsePayloadSize = new client.Histogram({
name: 'http_response_payload_size_bytes',
help: 'Size of HTTP response payloads in bytes / Kích thước payload response HTTP tính bằng bytes',
labelNames: ['method', 'route', 'status_code'],
buckets: [100, 1000, 10000, 100000, 1000000],
});
/**
* EN: Enhanced middleware to collect comprehensive HTTP metrics
* VI: Middleware nâng cao để thu thập metrics HTTP toàn diện
*
* @param req - Express request
* @param res - Express response
* @param next - Next function
*/
export const metricsMiddleware = (req: Request, res: Response, next: NextFunction) => {
// EN: Increment active requests
// VI: Tăng active requests
activeRequests.inc();
// EN: Start timer
// VI: Bắt đầu bấm giờ
const start = process.hrtime.bigint();
// EN: Track request payload size
// VI: Theo dõi kích thước payload request
const requestContentLength = parseInt(req.get('content-length') || '0', 10);
if (requestContentLength > 0) {
const route = req.route ? req.route.path : req.path;
requestPayloadSize
.labels(req.method, route)
.observe(requestContentLength);
}
// EN: Store original response methods to intercept
// VI: Lưu original response methods để intercept
const originalWrite = res.write;
let responseSize = 0;
// EN: Override write method to track response size
// VI: Override write method để track response size
res.write = function(chunk: any, encodingOrCb?: BufferEncoding | ((error?: Error | null) => void), cb?: (error?: Error | null) => void): boolean {
if (chunk && typeof chunk !== 'function') {
responseSize += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(String(chunk));
}
// EN: Handle different overloads of write method
// VI: Xử lý các overloads khác nhau của write method
if (typeof encodingOrCb === 'function') {
return (originalWrite as any).call(this, chunk, encodingOrCb);
}
if (encodingOrCb !== undefined && cb !== undefined) {
return (originalWrite as any).call(this, chunk, encodingOrCb, cb);
}
if (encodingOrCb !== undefined) {
return (originalWrite as any).call(this, chunk, encodingOrCb);
}
return (originalWrite as any).call(this, chunk);
};
// EN: Listen for response finish event
// VI: Lắng nghe sự kiện kết thúc response
res.on('finish', () => {
// EN: Decrement active requests
// VI: Giảm active requests
activeRequests.dec();
// EN: Calculate duration
// VI: Tính toán thời lượng
const end = process.hrtime.bigint();
const durationNanoseconds = end - start;
const durationInSeconds = Number(durationNanoseconds) / 1e9;
// EN: Normalize path to avoid high cardinality
// VI: Chuẩn hóa path để tránh high cardinality
const route = normalizeRoutePath(req);
// EN: Get correlation ID for metrics
// VI: Lấy correlation ID cho metrics
const correlationId = getCorrelationId(req) || 'unknown';
// EN: Record duration with correlation ID
// VI: Ghi nhận thời lượng với correlation ID
httpRequestDurationSeconds
.labels(req.method, route, res.statusCode.toString(), correlationId)
.observe(durationInSeconds);
// EN: Increment request counter
// VI: Tăng bộ đếm request
httpRequestsTotal
.labels(req.method, route, res.statusCode.toString())
.inc();
// EN: Record response payload size
// VI: Ghi nhận kích thước payload response
if (responseSize > 0) {
responsePayloadSize
.labels(req.method, route, res.statusCode.toString())
.observe(responseSize);
}
// EN: Track errors
// VI: Theo dõi lỗi
if (res.statusCode >= 400) {
const errorType = res.statusCode >= 500 ? 'server_error' : 'client_error';
httpRequestErrors
.labels(req.method, route, errorType)
.inc();
}
});
next();
};
/**
* EN: Normalize route path to prevent high cardinality metrics
* VI: Chuẩn hóa route path để ngăn high cardinality metrics
*/
function normalizeRoutePath(req: Request): string {
// EN: If route is defined, use it (Express route pattern)
// VI: Nếu route được định nghĩa, sử dụng nó (Express route pattern)
if (req.route && req.route.path) {
return req.route.path;
}
// EN: For API routes, normalize IDs
// VI: Với API routes, normalize IDs
let path = req.path;
// EN: Replace UUIDs and numeric IDs with placeholders
// VI: Thay thế UUIDs và numeric IDs bằng placeholders
path = path.replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, ':uuid');
path = path.replace(/\d+/g, ':id');
return path;
}

View File

@@ -0,0 +1,105 @@
import { logger } from '@goodgo/logger';
import { Request, Response, NextFunction } from 'express';
import { z, ZodError } from 'zod';
/**
* EN: Middleware to validate request data using Zod schemas
* VI: Middleware để validate dữ liệu request sử dụng Zod schemas
*
* @param schema - Zod schema to validate against / Schema Zod để validate
* @param property - Request property to validate ('body', 'query', 'params') / Property request để validate
*/
export const validateDto = (schema: z.ZodTypeAny, property: 'body' | 'query' | 'params' = 'body') => {
return (req: Request, res: Response, next: NextFunction) => {
try {
// EN: Sanitize input by trimming strings
// VI: Sanitize input bằngcách trim strings
const sanitizedData = sanitizeInput(req[property]);
// EN: Validate the sanitized data
// VI: Validate dữ liệu đã được sanitize
const validatedData = schema.parse(sanitizedData);
// EN: Replace the original data with validated data
// VI: Thay thế dữ liệu gốc bằng dữ liệu đã validate
(req as any)[property] = validatedData;
logger.debug('Request validation successful / Validation request thành công', {
property,
});
return next();
} catch (error) {
if (error instanceof ZodError) {
logger.warn('Request validation failed / Validation request thất bại', {
property,
errors: error.issues, // Zod 4: error.errors → error.issues
body: req.body,
});
// EN: Return structured validation error
// VI: Trả về lỗi validation có cấu trúc
return res.status(400).json({
success: false,
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid request data / Dữ liệu request không hợp lệ',
details: error.issues.map(err => ({
field: err.path.join('.'),
message: err.message,
code: err.code,
})),
},
timestamp: new Date().toISOString(),
});
}
// EN: Handle unexpected errors
// VI: Xử lý lỗi không mong muốn
logger.error('Unexpected validation error / Lỗi validation không mong muốn', { error });
return res.status(500).json({
success: false,
error: {
code: 'INTERNAL_ERROR',
message: 'Internal server error / Lỗi máy chủ nội bộ',
},
timestamp: new Date().toISOString(),
});
}
};
};
/**
* EN: Recursively sanitize input by trimming strings and cleaning data
* VI: Sanitize input một cách đệ quy bằng cách trim strings và làm sạch dữ liệu
*/
function sanitizeInput(data: any): any {
if (typeof data === 'string') {
// EN: Trim whitespace and normalize
// VI: Trim whitespace và normalize
return data.trim();
}
if (Array.isArray(data)) {
// EN: Sanitize array elements
// VI: Sanitize các phần tử trong array
return data.map(sanitizeInput);
}
if (data !== null && typeof data === 'object') {
// EN: Sanitize object properties
// VI: Sanitize các properties của object
const sanitized: any = {};
for (const [key, value] of Object.entries(data)) {
sanitized[key] = sanitizeInput(value);
}
return sanitized;
}
// EN: Return primitive values as-is
// VI: Trả về primitive values như nguyên bản
return data;
}
// EN: Note: For multiple validations, chain validateDto middlewares in routes
// VI: Lưu ý: Cho multiple validations, chain validateDto middlewares trong routes

View File

@@ -0,0 +1,72 @@
import { logger } from '@goodgo/logger';
import { getRedisClient } from '../../config/redis.config';
/**
* EN: Service for caching data (Redis wrapper)
* VI: Service cho việc caching dữ liệu (Redis wrapper)
*/
export class CacheService {
/**
* EN: Get value from cache
* VI: Lấy giá trị từ cache
*/
async get<T>(key: string): Promise<T | null> {
try {
const data = await getRedisClient().get(key);
if (!data) return null;
return JSON.parse(data) as T;
} catch (error) {
logger.error('Cache get error', { key, error });
return null;
}
}
/**
* EN: Set value in cache
* VI: Lưu giá trị vào cache
*/
async set(key: string, value: any, ttlSeconds?: number): Promise<void> {
try {
const stringValue = JSON.stringify(value);
if (ttlSeconds) {
await getRedisClient().setex(key, ttlSeconds, stringValue);
} else {
await getRedisClient().set(key, stringValue);
}
} catch (error) {
logger.error('Cache set error', { key, error });
}
}
/**
* EN: Get from cache or fetch from source if missing
* VI: Lấy từ cache hoặc lấy từ nguồn nếu không có
*/
async getOrSet<T>(
key: string,
fetchFn: () => Promise<T>,
ttlSeconds: number = 300
): Promise<T> {
const cached = await this.get<T>(key);
if (cached) return cached;
const data = await fetchFn();
await this.set(key, data, ttlSeconds);
return data;
}
/**
* EN: Delete from cache
* VI: Xóa khỏi cache
*/
async del(key: string): Promise<void> {
try {
await getRedisClient().del(key);
} catch (error) {
logger.error('Cache del error', { key, error });
}
}
}
export const cacheService = new CacheService();

View File

@@ -0,0 +1,50 @@
import { logger } from '@goodgo/logger';
import CircuitBreaker from 'opossum';
/**
* EN: Circuit Breaker Configuration
* VI: Cấu hình Circuit Breaker
*/
const defaultOptions: CircuitBreaker.Options = {
timeout: 3000, // 3 seconds
errorThresholdPercentage: 50,
resetTimeout: 30000, // 30 seconds
};
/**
* EN: Create a circuit breaker for an async function
* VI: Tạo circuit breaker cho một hàm bất đồng bộ
*
* @param action - Async function to protect
* @param name - Name of the circuit breaker
* @param options - Override default options
*/
export const createCircuitBreaker = <TArgs extends any[], TResult>(
action: (...args: TArgs) => Promise<TResult>,
name: string,
options: Partial<CircuitBreaker.Options> = {}
): CircuitBreaker<TArgs, TResult> => {
const breaker = new CircuitBreaker(action, {
...defaultOptions,
...options,
name,
});
breaker.on('open', () => {
logger.warn(`Circuit Breaker OPEN: ${name}`);
});
breaker.on('halfOpen', () => {
logger.info(`Circuit Breaker HALF-OPEN: ${name}`);
});
breaker.on('close', () => {
logger.info(`Circuit Breaker CLOSED: ${name}`);
});
breaker.on('fallback', () => {
logger.warn(`Circuit Breaker FALLBACK: ${name}`);
});
return breaker;
};

View File

@@ -0,0 +1,220 @@
import { logger } from '@goodgo/logger';
import { PrismaClient } from '@prisma/client';
import { DatabaseError } from '../../errors/http-error';
/**
* EN: Base repository class providing common database operations
* VI: Base repository class cung cấp các thao tác database chung
*/
export abstract class BaseRepository<T, CreateInput, UpdateInput> {
protected prisma: PrismaClient;
protected modelName: string;
constructor(prisma: PrismaClient, modelName: string) {
this.prisma = prisma;
this.modelName = modelName;
}
/**
* EN: Find entity by ID
* VI: Tìm entity theo ID
*/
async findById(id: string): Promise<T | null> {
try {
logger.debug(`Finding ${this.modelName} by ID / Tìm ${this.modelName} theo ID`, { id });
const entity = await (this.prisma as any)[this.modelName].findUnique({
where: { id },
});
logger.debug(`${this.modelName} ${entity ? 'found' : 'not found'} / ${this.modelName} ${entity ? 'đã tìm thấy' : 'không tìm thấy'}`, { id });
return entity;
} catch (error: any) {
logger.error(`Failed to find ${this.modelName} by ID / Không thể tìm ${this.modelName} theo ID`, { error, id });
throw new DatabaseError(`Failed to find ${this.modelName}`, { id, originalError: error });
}
}
/**
* EN: Find entity by unique field
* VI: Tìm entity theo field duy nhất
*/
async findByUnique(field: string, value: any): Promise<T | null> {
try {
logger.debug(`Finding ${this.modelName} by ${field} / Tìm ${this.modelName} theo ${field}`, { field, value });
const entity = await (this.prisma as any)[this.modelName].findUnique({
where: { [field]: value },
});
logger.debug(`${this.modelName} ${entity ? 'found' : 'not found'} / ${this.modelName} ${entity ? 'đã tìm thấy' : 'không tìm thấy'}`, { field, value });
return entity;
} catch (error: any) {
logger.error(`Failed to find ${this.modelName} by ${field} / Không thể tìm ${this.modelName} theo ${field}`, { error, field, value });
throw new DatabaseError(`Failed to find ${this.modelName}`, { field, value, originalError: error });
}
}
/**
* EN: Find all entities with optional filtering
* VI: Tìm tất cả entities với filtering tùy chọn
*/
async findAll(options?: {
where?: any;
orderBy?: any;
skip?: number;
take?: number;
include?: any;
}): Promise<T[]> {
try {
logger.debug(`Finding all ${this.modelName} / Tìm tất cả ${this.modelName}`, options);
const entities = await (this.prisma as any)[this.modelName].findMany(options || {});
logger.debug(`Found ${entities.length} ${this.modelName} entities / Đã tìm thấy ${entities.length} ${this.modelName} entities`);
return entities;
} catch (error: any) {
logger.error(`Failed to find all ${this.modelName} / Không thể tìm tất cả ${this.modelName}`, { error, options });
throw new DatabaseError(`Failed to find ${this.modelName} entities`, { options, originalError: error });
}
}
/**
* EN: Create new entity
* VI: Tạo entity mới
*/
async create(data: CreateInput): Promise<T> {
try {
logger.debug(`Creating new ${this.modelName} / Tạo ${this.modelName} mới`, { data });
const entity = await (this.prisma as any)[this.modelName].create({
data,
});
logger.debug(`${this.modelName} created successfully / ${this.modelName} đã được tạo thành công`, { id: (entity as any).id });
return entity;
} catch (error: any) {
logger.error(`Failed to create ${this.modelName} / Không thể tạo ${this.modelName}`, { error, data });
throw new DatabaseError(`Failed to create ${this.modelName}`, { data, originalError: error });
}
}
/**
* EN: Update entity by ID
* VI: Cập nhật entity theo ID
*/
async update(id: string, data: UpdateInput): Promise<T> {
try {
logger.debug(`Updating ${this.modelName} / Cập nhật ${this.modelName}`, { id, data });
const entity = await (this.prisma as any)[this.modelName].update({
where: { id },
data,
});
logger.debug(`${this.modelName} updated successfully / ${this.modelName} đã được cập nhật thành công`, { id });
return entity;
} catch (error: any) {
if (error.code === 'P2025') {
logger.warn(`${this.modelName} not found for update / ${this.modelName} không tìm thấy để cập nhật`, { id });
throw new DatabaseError(`${this.modelName} not found`, { id });
}
logger.error(`Failed to update ${this.modelName} / Không thể cập nhật ${this.modelName}`, { error, id, data });
throw new DatabaseError(`Failed to update ${this.modelName}`, { id, data, originalError: error });
}
}
/**
* EN: Delete entity by ID
* VI: Xóa entity theo ID
*/
async delete(id: string): Promise<boolean> {
try {
logger.debug(`Deleting ${this.modelName} / Xóa ${this.modelName}`, { id });
await (this.prisma as any)[this.modelName].delete({
where: { id },
});
logger.debug(`${this.modelName} deleted successfully / ${this.modelName} đã được xóa thành công`, { id });
return true;
} catch (error: any) {
if (error.code === 'P2025') {
logger.warn(`${this.modelName} not found for deletion / ${this.modelName} không tìm thấy để xóa`, { id });
throw new DatabaseError(`${this.modelName} not found`, { id });
}
logger.error(`Failed to delete ${this.modelName} / Không thể xóa ${this.modelName}`, { error, id });
throw new DatabaseError(`Failed to delete ${this.modelName}`, { id, originalError: error });
}
}
/**
* EN: Count entities with optional filtering
* VI: Đếm entities với filtering tùy chọn
*/
async count(where?: any): Promise<number> {
try {
logger.debug(`Counting ${this.modelName} / Đếm ${this.modelName}`, { where });
const count = await (this.prisma as any)[this.modelName].count({
where,
});
logger.debug(`Counted ${count} ${this.modelName} entities / Đã đếm ${count} ${this.modelName} entities`);
return count;
} catch (error: any) {
logger.error(`Failed to count ${this.modelName} / Không thể đếm ${this.modelName}`, { error, where });
throw new DatabaseError(`Failed to count ${this.modelName}`, { where, originalError: error });
}
}
/**
* EN: Check if entity exists by ID
* VI: Kiểm tra entity có tồn tại theo ID
*/
async exists(id: string): Promise<boolean> {
try {
const count = await this.count({ id });
return count > 0;
} catch (error: any) {
logger.error(`Failed to check if ${this.modelName} exists / Không thể kiểm tra ${this.modelName} có tồn tại`, { error, id });
throw error;
}
}
/**
* EN: Execute transaction with multiple operations
* VI: Thực thi transaction với nhiều operations
*/
async transaction<R>(callback: (tx: any) => Promise<R>): Promise<R> {
try {
logger.debug(`Starting ${this.modelName} transaction / Bắt đầu transaction ${this.modelName}`);
const result = await this.prisma.$transaction(async (tx: any) => {
return await callback(tx);
});
logger.debug(`${this.modelName} transaction completed successfully / Transaction ${this.modelName} đã hoàn thành thành công`);
return result;
} catch (error: any) {
logger.error(`${this.modelName} transaction failed / Transaction ${this.modelName} thất bại`, { error });
throw new DatabaseError(`${this.modelName} transaction failed`, { originalError: error });
}
}
}
/**
* EN: Generic repository interface for type safety
* VI: Generic repository interface để type safety
*/
export interface IRepository<T, CreateInput, UpdateInput> {
findById(id: string): Promise<T | null>;
findByUnique(field: string, value: any): Promise<T | null>;
findAll(options?: any): Promise<T[]>;
create(data: CreateInput): Promise<T>;
update(id: string, data: UpdateInput): Promise<T>;
delete(id: string): Promise<boolean>;
count(where?: any): Promise<number>;
exists(id: string): Promise<boolean>;
}

View File

@@ -0,0 +1,314 @@
import { FeatureRepository } from '../feature.repository';
import { ConflictError } from '../../../errors/http-error';
// EN: Mock Prisma client
// VI: Mock Prisma client
const mockPrismaClient = {
feature: {
findUnique: jest.fn(),
findMany: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
count: jest.fn(),
},
$transaction: jest.fn(),
};
jest.mock('../../../config/database.config', () => ({
prisma: mockPrismaClient,
}));
describe('FeatureRepository', () => {
let repository: FeatureRepository;
let mockPrisma: any;
beforeEach(() => {
jest.clearAllMocks();
repository = new FeatureRepository();
mockPrisma = mockPrismaClient;
});
describe('findById', () => {
it('should return feature when found', async () => {
const mockFeature = { id: '1', name: 'test-feature', enabled: true };
mockPrisma.feature.findUnique.mockResolvedValue(mockFeature);
const result = await repository.findById('1');
expect(mockPrisma.feature.findUnique).toHaveBeenCalledWith({ where: { id: '1' } });
expect(result).toEqual(mockFeature);
});
it('should return null when feature not found', async () => {
mockPrisma.feature.findUnique.mockResolvedValue(null);
const result = await repository.findById('1');
expect(result).toBeNull();
});
});
describe('findByName', () => {
it('should return feature when found by name', async () => {
const mockFeature = { id: '1', name: 'test-feature', enabled: true };
mockPrisma.feature.findUnique.mockResolvedValue(mockFeature);
const result = await repository.findByName('test-feature');
expect(mockPrisma.feature.findUnique).toHaveBeenCalledWith({
where: { name: 'test-feature' }
});
expect(result).toEqual(mockFeature);
});
});
describe('findAll', () => {
it('should return all features with default options', async () => {
const mockFeatures = [
{ id: '1', name: 'feature-1' },
{ id: '2', name: 'feature-2' },
];
mockPrisma.feature.findMany.mockResolvedValue(mockFeatures);
const result = await repository.findAll();
expect(mockPrisma.feature.findMany).toHaveBeenCalledWith({});
expect(result).toEqual(mockFeatures);
});
it('should return features with custom options', async () => {
const options = { where: { enabled: true }, orderBy: { createdAt: 'desc' } };
const mockFeatures = [{ id: '1', name: 'enabled-feature' }];
mockPrisma.feature.findMany.mockResolvedValue(mockFeatures);
const result = await repository.findAll(options);
expect(mockPrisma.feature.findMany).toHaveBeenCalledWith(options);
expect(result).toEqual(mockFeatures);
});
});
describe('create', () => {
it('should create feature successfully when name is unique', async () => {
const createData = { name: 'new-feature', title: 'New Feature' };
const mockFeature = { id: '1', ...createData, enabled: true };
// Mock no existing feature
mockPrisma.feature.findUnique.mockResolvedValue(null);
mockPrisma.feature.create.mockResolvedValue(mockFeature);
const result = await repository.create(createData);
expect(mockPrisma.feature.findUnique).toHaveBeenCalledWith({
where: { name: 'new-feature' }
});
expect(mockPrisma.feature.create).toHaveBeenCalledWith({ data: createData });
expect(result).toEqual(mockFeature);
});
it('should throw ConflictError when feature name already exists', async () => {
const createData = { name: 'existing-feature' };
const existingFeature = { id: '1', name: 'existing-feature' };
mockPrisma.feature.findUnique.mockResolvedValue(existingFeature);
await expect(repository.create(createData)).rejects.toThrow(ConflictError);
expect(mockPrisma.feature.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
it('should update feature successfully', async () => {
const updateData = { title: 'Updated Title' };
const mockFeature = { id: '1', name: 'test-feature', title: 'Updated Title' };
mockPrisma.feature.update.mockResolvedValue(mockFeature);
const result = await repository.update('1', updateData);
expect(mockPrisma.feature.update).toHaveBeenCalledWith({
where: { id: '1' },
data: updateData,
});
expect(result).toEqual(mockFeature);
});
});
describe('delete', () => {
it('should delete feature successfully', async () => {
mockPrisma.feature.delete.mockResolvedValue({});
const result = await repository.delete('1');
expect(mockPrisma.feature.delete).toHaveBeenCalledWith({
where: { id: '1' }
});
expect(result).toBe(true);
});
});
describe('count', () => {
it('should return count of features', async () => {
mockPrisma.feature.count.mockResolvedValue(5);
const result = await repository.count();
expect(mockPrisma.feature.count).toHaveBeenCalledWith({ where: undefined });
expect(result).toBe(5);
});
it('should return count with where clause', async () => {
const where = { enabled: true };
mockPrisma.feature.count.mockResolvedValue(3);
const result = await repository.count(where);
expect(mockPrisma.feature.count).toHaveBeenCalledWith({ where });
expect(result).toBe(3);
});
});
describe('exists', () => {
it('should return true when feature exists', async () => {
mockPrisma.feature.count.mockResolvedValue(1);
const result = await repository.exists('1');
expect(mockPrisma.feature.count).toHaveBeenCalledWith({ where: { id: '1' } });
expect(result).toBe(true);
});
it('should return false when feature does not exist', async () => {
mockPrisma.feature.count.mockResolvedValue(0);
const result = await repository.exists('1');
expect(result).toBe(false);
});
});
describe('toggleEnabled', () => {
it('should toggle feature from disabled to enabled', async () => {
const existingFeature = { id: '1', name: 'test-feature', enabled: false };
const updatedFeature = { ...existingFeature, enabled: true };
mockPrisma.feature.findUnique.mockResolvedValue(existingFeature);
mockPrisma.feature.update.mockResolvedValue(updatedFeature);
const result = await repository.toggleEnabled('1');
expect(mockPrisma.feature.update).toHaveBeenCalledWith({
where: { id: '1' },
data: { enabled: true },
});
expect(result).toEqual(updatedFeature);
});
it('should throw ConflictError when feature not found', async () => {
mockPrisma.feature.findUnique.mockResolvedValue(null);
await expect(repository.toggleEnabled('1')).rejects.toThrow(ConflictError);
expect(mockPrisma.feature.update).not.toHaveBeenCalled();
});
});
describe('findByTags', () => {
it('should return features matching tags', async () => {
const tags = ['web', 'api'];
const mockFeatures = [
{ id: '1', name: 'web-feature', tags: ['web'] },
{ id: '2', name: 'api-feature', tags: ['api'] },
];
mockPrisma.feature.findMany.mockResolvedValue(mockFeatures);
const result = await repository.findByTags(tags);
expect(mockPrisma.feature.findMany).toHaveBeenCalledWith({
where: {
tags: {
hasSome: tags,
},
},
orderBy: { createdAt: 'desc' },
});
expect(result).toEqual(mockFeatures);
});
});
describe('findEnabled', () => {
it('should return only enabled features', async () => {
const mockFeatures = [
{ id: '1', name: 'enabled-feature', enabled: true },
{ id: '2', name: 'disabled-feature', enabled: false },
];
mockPrisma.feature.findMany.mockResolvedValue([mockFeatures[0]]);
const result = await repository.findEnabled();
expect(mockPrisma.feature.findMany).toHaveBeenCalledWith({
where: { enabled: true },
orderBy: { createdAt: 'desc' },
});
expect(result).toEqual([mockFeatures[0]]);
});
});
describe('search', () => {
it('should search features by query', async () => {
const query = 'test';
const mockFeatures = [
{ id: '1', name: 'test-feature', title: 'Test Feature' },
];
mockPrisma.feature.findMany.mockResolvedValue(mockFeatures);
const result = await repository.search(query);
expect(mockPrisma.feature.findMany).toHaveBeenCalledWith({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ title: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
],
},
take: 10,
orderBy: { createdAt: 'desc' },
});
expect(result).toEqual(mockFeatures);
});
});
describe('getStatistics', () => {
it('should return feature statistics', async () => {
const mockFeatures = [
{ id: '1', name: 'feature1', tags: ['web', 'api'], enabled: true },
{ id: '2', name: 'feature2', tags: ['web'], enabled: false },
{ id: '3', name: 'feature3', tags: ['mobile'], enabled: true },
];
mockPrisma.feature.count
.mockResolvedValueOnce(3) // total
.mockResolvedValueOnce(2) // enabled
.mockResolvedValueOnce(1); // disabled
mockPrisma.feature.findMany.mockResolvedValue(mockFeatures);
const result = await repository.getStatistics();
expect(result).toEqual({
total: 3,
enabled: 2,
disabled: 1,
byTag: {
web: 2,
api: 1,
mobile: 1,
},
});
});
});
});

View File

@@ -0,0 +1,122 @@
import { FeatureService } from '../feature.service';
import { logger } from '@goodgo/logger';
import { featureRepository } from '../feature.repository';
// EN: Mock the logger to avoid console output during tests
// VI: Mock logger để tránh output console trong tests
jest.mock('@goodgo/logger');
// EN: Mock feature repository
// VI: Mock feature repository
jest.mock('../feature.repository', () => ({
featureRepository: {
create: jest.fn(),
findById: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
findAll: jest.fn(),
},
}));
describe('FeatureService', () => {
let featureService: FeatureService;
beforeEach(() => {
// EN: Clear all mocks before each test
// VI: Xóa tất cả mocks trước mỗi test
jest.clearAllMocks();
featureService = new FeatureService();
});
describe('create', () => {
it('should create a feature successfully', async () => {
// EN: Arrange
// VI: Chuẩn bị
const testData = { name: 'test-feature', title: 'Test Feature', description: 'A test feature' };
const mockFeature = {
id: 'test-id',
name: testData.name,
title: testData.title,
description: testData.description,
config: {},
enabled: true,
version: '1.0.0',
tags: [],
createdAt: new Date(),
updatedAt: new Date(),
};
(featureRepository.create as jest.Mock).mockResolvedValue(mockFeature);
// EN: Act
// VI: Thực hiện
const result = await featureService.create(testData);
// EN: Assert
// VI: Kiểm tra
expect(featureRepository.create).toHaveBeenCalledWith(testData);
expect(logger.info).toHaveBeenCalledWith('Creating feature / Tạo feature', { data: testData });
expect(logger.info).toHaveBeenCalledWith('Feature created successfully / Feature đã được tạo thành công', { featureId: mockFeature.id });
expect(result).toEqual(mockFeature);
});
it('should handle minimal data', async () => {
// EN: Arrange
// VI: Chuẩn bị
const minimalData = { name: 'minimal-feature' };
const mockFeature = {
id: 'minimal-id',
name: minimalData.name,
config: {},
enabled: true,
version: '1.0.0',
tags: [],
createdAt: new Date(),
updatedAt: new Date(),
};
(featureRepository.create as jest.Mock).mockResolvedValue(mockFeature);
// EN: Act
// VI: Thực hiện
const result = await featureService.create(minimalData);
// EN: Assert
// VI: Kiểm tra
expect(logger.info).toHaveBeenCalledWith('Creating feature / Tạo feature', { data: minimalData });
expect(result).toEqual(mockFeature);
});
it('should handle complex data structures', async () => {
// EN: Arrange
// VI: Chuẩn bị
const complexData = {
name: 'advanced-feature',
title: 'Advanced Feature',
description: 'Feature with complex data',
config: { enabled: true, priority: 1 },
tags: ['advanced', 'complex']
};
const mockFeature = {
id: 'complex-id',
name: complexData.name,
title: complexData.title,
description: complexData.description,
config: complexData.config,
enabled: true,
version: '1.0.0',
tags: complexData.tags,
createdAt: new Date(),
updatedAt: new Date(),
};
(featureRepository.create as jest.Mock).mockResolvedValue(mockFeature);
// EN: Act
// VI: Thực hiện
const result = await featureService.create(complexData);
// EN: Assert
// VI: Kiểm tra
expect(logger.info).toHaveBeenCalledWith('Creating feature / Tạo feature', { data: complexData });
expect(result).toEqual(mockFeature);
});
});
});

View File

@@ -0,0 +1,181 @@
import { ApiResponse } from '@goodgo/types';
import { Request, Response } from 'express';
import { asyncHandler } from '../../middlewares/error.middleware';
import { FeatureService } from './feature.service';
/**
* EN: Controller for Feature module
* VI: Controller cho module Feature
*/
export class FeatureController {
private featureService: FeatureService;
constructor() {
// EN: Service initialization
// VI: Khởi tạo service
this.featureService = new FeatureService();
}
/**
* EN: Create a new feature
* VI: Tạo một feature mới
*
* @param req - Express request
* @param res - Express response
*/
create = asyncHandler(async (req: Request, res: Response): Promise<void> => {
const featureData = req.body;
const feature = await this.featureService.create(featureData);
const response: ApiResponse = {
success: true,
data: feature,
message: 'Feature created successfully / Feature đã được tạo thành công',
timestamp: new Date().toISOString(),
};
res.status(201).json(response);
});
/**
* EN: Get all features
* VI: Lấy tất cả features
*/
getAll = asyncHandler(async (_req: Request, res: Response): Promise<void> => {
const features = await this.featureService.findAll();
const response: ApiResponse = {
success: true,
data: features,
message: 'Features retrieved successfully / Features đã được lấy thành công',
timestamp: new Date().toISOString(),
};
res.json(response);
});
/**
* EN: Get feature by ID
* VI: Lấy feature theo ID
*/
getById = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const feature = await this.featureService.findById(id);
if (!feature) {
res.status(404).json({
success: false,
error: {
code: 'FEATURE_003',
message: 'Feature not found / Không tìm thấy feature',
},
timestamp: new Date().toISOString(),
});
return;
}
const response: ApiResponse = {
success: true,
data: feature,
message: 'Feature retrieved successfully / Feature đã được lấy thành công',
timestamp: new Date().toISOString(),
};
res.json(response);
} catch (error: any) {
res.status(500).json({
success: false,
error: {
code: 'FEATURE_004',
message: error.message || 'Failed to retrieve feature / Không thể lấy feature',
},
timestamp: new Date().toISOString(),
});
}
};
/**
* EN: Update feature
* VI: Cập nhật feature
*/
update = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const updateData = req.body;
const feature = await this.featureService.update(id, updateData);
const response: ApiResponse = {
success: true,
data: feature,
message: 'Feature updated successfully / Feature đã được cập nhật thành công',
timestamp: new Date().toISOString(),
};
res.json(response);
} catch (error: any) {
res.status(400).json({
success: false,
error: {
code: 'FEATURE_005',
message: error.message || 'Failed to update feature / Không thể cập nhật feature',
},
timestamp: new Date().toISOString(),
});
}
};
/**
* EN: Delete feature
* VI: Xóa feature
*/
delete = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
await this.featureService.delete(id);
const response: ApiResponse = {
success: true,
message: 'Feature deleted successfully / Feature đã được xóa thành công',
timestamp: new Date().toISOString(),
};
res.json(response);
} catch (error: any) {
res.status(400).json({
success: false,
error: {
code: 'FEATURE_006',
message: error.message || 'Failed to delete feature / Không thể xóa feature',
},
timestamp: new Date().toISOString(),
});
}
};
/**
* EN: Toggle feature status
* VI: Chuyển đổi trạng thái feature
*/
toggle = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const feature = await this.featureService.toggle(id);
const response: ApiResponse = {
success: true,
data: feature,
message: `Feature ${feature.enabled ? 'enabled' : 'disabled'} successfully / Feature đã được ${feature.enabled ? 'bật' : 'tắt'} thành công`,
timestamp: new Date().toISOString(),
};
res.json(response);
} catch (error: any) {
res.status(400).json({
success: false,
error: {
code: 'FEATURE_007',
message: error.message || 'Failed to toggle feature / Không thể chuyển đổi feature',
},
timestamp: new Date().toISOString(),
});
}
};
}

View File

@@ -0,0 +1,42 @@
import { z } from 'zod';
/**
* EN: DTO for creating a new feature
* VI: DTO để tạo feature mới
*/
export const createFeatureDtoSchema = z.object({
name: z.string().min(1, 'Name is required / Tên là bắt buộc').max(100, 'Name must be less than 100 characters / Tên phải ít hơn 100 ký tự'),
title: z.string().max(200, 'Title must be less than 200 characters / Tiêu đề phải ít hơn 200 ký tự').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự').optional(),
config: z.record(z.string(), z.any()).optional(),
tags: z.array(z.string()).optional(),
});
export type CreateFeatureDto = z.infer<typeof createFeatureDtoSchema>;
/**
* EN: DTO for updating a feature
* VI: DTO để cập nhật feature
*/
export const updateFeatureDtoSchema = z.object({
title: z.string().max(200, 'Title must be less than 200 characters / Tiêu đề phải ít hơn 200 ký tự').optional(),
description: z.string().max(1000, 'Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự').optional(),
config: z.record(z.string(), z.any()).optional(),
enabled: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});
export type UpdateFeatureDto = z.infer<typeof updateFeatureDtoSchema>;
/**
* EN: Query parameters for feature listing
* VI: Tham số query để liệt kê features
*/
export const getFeaturesQuerySchema = z.object({
enabled: z.string().transform(val => val === 'true').optional(),
tags: z.string().transform(val => val ? val.split(',') : undefined).optional(),
limit: z.string().transform(Number).optional(),
offset: z.string().transform(Number).optional(),
});
export type GetFeaturesQuery = z.infer<typeof getFeaturesQuerySchema>;

View File

@@ -0,0 +1,356 @@
import { Router } from 'express';
import { validateDto } from '../../middlewares/validation.middleware';
import { FeatureController } from './feature.controller';
import { createFeatureDtoSchema, updateFeatureDtoSchema } from './feature.dto';
/**
* EN: Create and configure feature routes
* VI: Tạo và cấu hình routes cho feature
*/
export const createFeatureRouter = (): Router => {
const router = Router();
const featureController = new FeatureController();
// EN: Public routes - no authentication required
// VI: Routes công khai - không yêu cầu xác thực
/**
* @swagger
* /api/{version}/features:
* get:
* summary: Get all features
* description: Retrieve a list of all features in the system
* tags: [Features]
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* responses:
* 200:
* description: Features retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* type: array
* items:
* $ref: '#/components/schemas/Feature'
*/
router.get('/', featureController.getAll);
/**
* @swagger
* /api/{version}/features/{id}:
* get:
* summary: Get feature by ID
* description: Retrieve a specific feature by its unique identifier
* tags: [Features]
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Feature unique identifier
* responses:
* 200:
* description: Feature retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* $ref: '#/components/schemas/Feature'
* 404:
* description: Feature not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/:id', featureController.getById);
// EN: Protected routes - authentication and authorization required
// VI: Routes được bảo vệ - yêu cầu xác thực và phân quyền
/**
* @swagger
* /api/{version}/features:
* post:
* summary: Create a new feature
* description: Create a new feature in the system. Requires admin privileges.
* tags: [Features]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/CreateFeatureRequest'
* example:
* name: "user-dashboard"
* title: "User Dashboard"
* description: "Dashboard for user management"
* config: { enabled: true, priority: 1 }
* tags: ["ui", "users"]
* responses:
* 201:
* description: Feature created successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* $ref: '#/components/schemas/Feature'
* 400:
* description: Validation error or feature already exists
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 401:
* description: Authentication required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 403:
* description: Insufficient permissions
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.post('/',
// authenticate(), // TODO: Re-enable after fixing E2E tests
// authorize('admin'),
validateDto(createFeatureDtoSchema),
featureController.create
);
/**
* @swagger
* /api/{version}/features/{id}:
* put:
* summary: Update feature
* description: Update an existing feature. Requires admin privileges.
* tags: [Features]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Feature unique identifier
* requestBody:
* required: true
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/UpdateFeatureRequest'
* example:
* title: "Updated Dashboard"
* enabled: false
* config: { priority: 2 }
* responses:
* 200:
* description: Feature updated successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* $ref: '#/components/schemas/Feature'
* 400:
* description: Validation error
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 401:
* description: Authentication required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 403:
* description: Insufficient permissions
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Feature not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.put('/:id',
// authenticate(), // TODO: Re-enable after fixing E2E tests
// authorize('admin'),
validateDto(updateFeatureDtoSchema),
featureController.update
);
/**
* @swagger
* /api/{version}/features/{id}:
* delete:
* summary: Delete feature
* description: Delete a feature from the system. Requires admin privileges.
* tags: [Features]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Feature unique identifier
* responses:
* 200:
* description: Feature deleted successfully
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ApiResponse'
* 401:
* description: Authentication required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 403:
* description: Insufficient permissions
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Feature not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.delete('/:id',
// authenticate(), // TODO: Re-enable after fixing E2E tests
// authorize('admin'),
featureController.delete
);
/**
* @swagger
* /api/{version}/features/{id}/toggle:
* patch:
* summary: Toggle feature status
* description: Enable or disable a feature. Requires admin privileges.
* tags: [Features]
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: version
* required: true
* schema:
* type: string
* default: v1
* description: API version
* - in: path
* name: id
* required: true
* schema:
* type: string
* description: Feature unique identifier
* responses:
* 200:
* description: Feature status toggled successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* $ref: '#/components/schemas/Feature'
* 401:
* description: Authentication required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 403:
* description: Insufficient permissions
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* 404:
* description: Feature not found
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.patch('/:id/toggle',
// authenticate(), // TODO: Re-enable after fixing E2E tests
// authorize('admin'),
featureController.toggle
);
return router;
};

View File

@@ -0,0 +1,236 @@
import { logger } from '@goodgo/logger';
import { prisma } from '../../config/database.config';
import { ConflictError } from '../../errors/http-error';
import { BaseRepository, IRepository } from '../common/repository';
// EN: Feature entity type from Prisma
// VI: Feature entity type từ Prisma
type Feature = {
id: string;
name: string;
title: string | null;
description: string | null;
config: any;
enabled: boolean;
version: string | null;
tags: string[];
createdAt: Date;
updatedAt: Date;
};
// EN: Input types for create/update operations
// VI: Input types cho create/update operations
type CreateFeatureInput = {
name: string;
title?: string;
description?: string;
config?: any;
tags?: string[];
};
type UpdateFeatureInput = {
title?: string;
description?: string;
config?: any;
enabled?: boolean;
tags?: string[];
};
/**
* EN: Feature repository implementing repository pattern
* VI: Feature repository implement repository pattern
*/
export class FeatureRepository extends BaseRepository<Feature, CreateFeatureInput, UpdateFeatureInput>
implements IRepository<Feature, CreateFeatureInput, UpdateFeatureInput> {
constructor() {
super(prisma, 'feature');
}
/**
* EN: Find feature by name (unique field)
* VI: Tìm feature theo tên (field duy nhất)
*/
async findByName(name: string): Promise<Feature | null> {
return this.findByUnique('name', name);
}
/**
* EN: Find features by tags
* VI: Tìm features theo tags
*/
async findByTags(tags: string[]): Promise<Feature[]> {
try {
logger.debug('Finding features by tags / Tìm features theo tags', { tags });
const features = await (this.prisma as any).feature.findMany({
where: {
tags: {
hasSome: tags,
},
},
orderBy: { createdAt: 'desc' },
});
logger.debug(`Found ${features.length} features by tags / Đã tìm thấy ${features.length} features theo tags`, { tags });
return features;
} catch (error) {
logger.error('Failed to find features by tags / Không thể tìm features theo tags', { error, tags });
throw this.handleDatabaseError(error, { tags });
}
}
/**
* EN: Find enabled features only
* VI: Tìm chỉ features đã được bật
*/
async findEnabled(): Promise<Feature[]> {
try {
logger.debug('Finding enabled features / Tìm features đã được bật');
const features = await (this.prisma as any).feature.findMany({
where: { enabled: true },
orderBy: { createdAt: 'desc' },
});
logger.debug(`Found ${features.length} enabled features / Đã tìm thấy ${features.length} features đã được bật`);
return features;
} catch (error) {
logger.error('Failed to find enabled features / Không thể tìm features đã được bật', { error });
throw this.handleDatabaseError(error);
}
}
/**
* EN: Create feature with duplicate name check
* VI: Tạo feature với kiểm tra tên trùng lặp
*/
async create(data: CreateFeatureInput): Promise<Feature> {
try {
// EN: Check for duplicate name
// VI: Kiểm tra tên trùng lặp
const existingFeature = await this.findByName(data.name);
if (existingFeature) {
logger.warn('Feature with this name already exists / Feature với tên này đã tồn tại', { name: data.name });
throw new ConflictError('Feature with this name already exists / Feature với tên này đã tồn tại', { name: data.name });
}
return await super.create(data);
} catch (error) {
if (error instanceof ConflictError) {
throw error;
}
throw error;
}
}
/**
* EN: Toggle feature enabled/disabled status
* VI: Bật/tắt trạng thái feature
*/
async toggleEnabled(id: string): Promise<Feature> {
try {
logger.debug('Toggling feature enabled status / Chuyển đổi trạng thái feature', { id });
const feature = await this.findById(id);
if (!feature) {
throw new ConflictError('Feature not found / Feature không tìm thấy', { id });
}
const updatedFeature = await this.update(id, {
enabled: !feature.enabled,
});
logger.debug(`Feature ${updatedFeature.enabled ? 'enabled' : 'disabled'} / Feature đã được ${updatedFeature.enabled ? 'bật' : 'tắt'}`, { id });
return updatedFeature;
} catch (error) {
if (error instanceof ConflictError) {
throw error;
}
logger.error('Failed to toggle feature status / Không thể chuyển đổi trạng thái feature', { error, id });
throw this.handleDatabaseError(error, { id });
}
}
/**
* EN: Search features by name or description
* VI: Tìm kiếm features theo tên hoặc mô tả
*/
async search(query: string, limit: number = 10): Promise<Feature[]> {
try {
logger.debug('Searching features / Tìm kiếm features', { query, limit });
const features = await (this.prisma as any).feature.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ title: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } },
],
},
take: limit,
orderBy: { createdAt: 'desc' },
});
logger.debug(`Found ${features.length} features matching search / Đã tìm thấy ${features.length} features khớp với tìm kiếm`, { query });
return features;
} catch (error) {
logger.error('Failed to search features / Không thể tìm kiếm features', { error, query });
throw this.handleDatabaseError(error, { query });
}
}
/**
* EN: Get feature statistics
* VI: Lấy thống kê feature
*/
async getStatistics(): Promise<{
total: number;
enabled: number;
disabled: number;
byTag: Record<string, number>;
}> {
try {
logger.debug('Getting feature statistics / Lấy thống kê feature');
const [total, enabled, disabled, features] = await Promise.all([
this.count(),
this.count({ enabled: true }),
this.count({ enabled: false }),
this.findAll(),
]);
// EN: Count by tags
// VI: Đếm theo tags
const byTag: Record<string, number> = {};
features.forEach(feature => {
feature.tags.forEach(tag => {
byTag[tag] = (byTag[tag] || 0) + 1;
});
});
const statistics = { total, enabled, disabled, byTag };
logger.debug('Feature statistics retrieved / Thống kê feature đã được lấy', statistics);
return statistics;
} catch (error) {
logger.error('Failed to get feature statistics / Không thể lấy thống kê feature', { error });
throw this.handleDatabaseError(error);
}
}
/**
* EN: Handle database-specific errors
* VI: Xử lý lỗi database-specific
*/
private handleDatabaseError(error: any, context?: any) {
if (error.code === 'P2002') {
return new ConflictError('Feature with this name already exists / Feature với tên này đã tồn tại', context);
}
return error;
}
}
// EN: Singleton instance
// VI: Singleton instance
export const featureRepository = new FeatureRepository();

View File

@@ -0,0 +1,114 @@
import { logger } from '@goodgo/logger';
import { NotFoundError } from '../../errors/http-error';
import { featureRepository } from './feature.repository';
/**
* EN: Service for managing features in the system
* VI: Service để quản lý các features trong hệ thống
*/
export class FeatureService {
/**
* EN: Create a new feature
* VI: Tạo một feature mới
*/
async create(data: { name: string; title?: string; description?: string; config?: any; tags?: string[] }) {
logger.info('Creating feature / Tạo feature', { data });
const feature = await featureRepository.create(data);
logger.info('Feature created successfully / Feature đã được tạo thành công', { featureId: feature.id });
return feature;
}
/**
* EN: Get all features
* VI: Lấy tất cả features
*/
async findAll() {
logger.info('Fetching all features / Lấy tất cả features');
const features = await featureRepository.findAll({
orderBy: { createdAt: 'desc' },
});
logger.info(`Retrieved ${features.length} features / Đã lấy ${features.length} features`);
return features;
}
/**
* EN: Get feature by ID
* VI: Lấy feature theo ID
*/
async findById(id: string) {
logger.info('Fetching feature by ID / Lấy feature theo ID', { id });
const feature = await featureRepository.findById(id);
if (!feature) {
logger.warn('Feature not found / Không tìm thấy feature', { id });
throw new NotFoundError('Feature', { id });
}
logger.info('Feature retrieved successfully / Feature đã được lấy thành công', { id });
return feature;
}
/**
* EN: Get feature by name
* VI: Lấy feature theo tên
*/
async findByName(name: string) {
logger.info('Fetching feature by name / Lấy feature theo tên', { name });
const feature = await featureRepository.findByName(name);
if (!feature) {
logger.warn('Feature not found / Không tìm thấy feature', { name });
return null;
}
logger.info('Feature retrieved successfully / Feature đã được lấy thành công', { name });
return feature;
}
/**
* EN: Update feature
* VI: Cập nhật feature
*/
async update(id: string, data: Partial<{ title?: string; description?: string; config?: any; enabled?: boolean; tags?: string[] }>) {
logger.info('Updating feature / Cập nhật feature', { id, data });
const feature = await featureRepository.update(id, data);
logger.info('Feature updated successfully / Feature đã được cập nhật thành công', { id });
return feature;
}
/**
* EN: Delete feature
* VI: Xóa feature
*/
async delete(id: string) {
logger.info('Deleting feature / Xóa feature', { id });
await featureRepository.delete(id);
logger.info('Feature deleted successfully / Feature đã được xóa thành công', { id });
return true;
}
/**
* EN: Toggle feature enabled/disabled status
* VI: Bật/tắt trạng thái feature
*/
async toggle(id: string) {
logger.info('Toggling feature status / Chuyển đổi trạng thái feature', { id });
const updatedFeature = await featureRepository.toggleEnabled(id);
logger.info(`Feature ${updatedFeature.enabled ? 'enabled' : 'disabled'} / Feature đã được ${updatedFeature.enabled ? 'bật' : 'tắt'}`, { id });
return updatedFeature;
}
}

View File

@@ -0,0 +1,8 @@
// EN: Export all feature-related modules
// VI: Export tất cả các modules liên quan đến feature
export { FeatureService } from './feature.service';
export { FeatureController } from './feature.controller';
export { createFeatureRouter } from './feature.module';
export { featureRepository } from './feature.repository';
export * from './feature.dto';

View File

@@ -0,0 +1,113 @@
import { Request, Response } from 'express';
import { HealthController } from '../health.controller';
import { prisma } from '../../../config/database.config';
jest.mock('../../../config/database.config');
describe('HealthController', () => {
let healthController: HealthController;
let mockReq: Partial<Request>;
let mockRes: Partial<Response>;
let mockJson: jest.Mock;
let mockStatus: jest.Mock;
beforeEach(() => {
healthController = new HealthController();
mockJson = jest.fn();
mockStatus = jest.fn().mockReturnValue({ json: mockJson });
mockReq = {};
mockRes = {
json: mockJson,
status: mockStatus,
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('health', () => {
it('should return healthy status', async () => {
// EN: Act
// VI: Thực hiện
await healthController.health(mockReq as Request, mockRes as Response);
// EN: Assert
// VI: Kiểm tra
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: {
status: 'ok',
timestamp: expect.any(String),
},
timestamp: expect.any(String),
});
// EN: Verify timestamp is valid ISO string
// VI: Xác minh timestamp là ISO string hợp lệ
const response = mockJson.mock.calls[0][0];
expect(new Date(response.timestamp).toISOString()).toBe(response.timestamp);
expect(new Date(response.data.timestamp).toISOString()).toBe(response.data.timestamp);
});
});
describe('ready', () => {
it('should return ready status when database is connected', async () => {
// EN: Arrange
// VI: Chuẩn bị
(prisma.$queryRaw as jest.Mock).mockResolvedValue([{ '1': 1 }]);
// EN: Act
// VI: Thực hiện
await healthController.ready(mockReq as Request, mockRes as Response);
// EN: Assert
// VI: Kiểm tra
expect(prisma.$queryRaw).toHaveBeenCalledWith(expect.anything());
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: { status: 'ready' },
timestamp: expect.any(String),
});
});
it('should return 503 when database connection fails', async () => {
// EN: Arrange
// VI: Chuẩn bị
const dbError = new Error('Database connection failed');
(prisma.$queryRaw as jest.Mock).mockRejectedValue(dbError);
// EN: Act
// VI: Thực hiện
await healthController.ready(mockReq as Request, mockRes as Response);
// EN: Assert
// VI: Kiểm tra
expect(mockStatus).toHaveBeenCalledWith(503);
expect(mockJson).toHaveBeenCalledWith({
success: false,
error: {
code: 'HEALTH_001',
message: 'Service not ready',
},
timestamp: expect.any(String),
});
});
});
describe('live', () => {
it('should return live status', async () => {
// EN: Act
// VI: Thực hiện
await healthController.live(mockReq as Request, mockRes as Response);
// EN: Assert
// VI: Kiểm tra
expect(mockJson).toHaveBeenCalledWith({
success: true,
data: { status: 'live' },
timestamp: expect.any(String),
});
});
});
});

View File

@@ -0,0 +1,67 @@
import { ApiResponse } from '@goodgo/types';
import { Request, Response } from 'express';
import { prisma } from '../../config/database.config';
/**
* EN: Controller for health checks
* VI: Controller cho các kiểm tra sức khỏe hệ thống
*/
export class HealthController {
/**
* EN: Basic liveness probe
* VI: Kiểm tra liveness cơ bản
*/
health = async (_req: Request, res: Response): Promise<void> => {
const response: ApiResponse<{ status: string; timestamp: string }> = {
success: true,
data: {
status: 'ok',
timestamp: new Date().toISOString(),
},
timestamp: new Date().toISOString(),
};
res.json(response);
};
/**
* EN: Readiness probe (checks database connection)
* VI: Kiểm tra readiness (kiểm tra kết nối database)
*/
ready = async (_req: Request, res: Response): Promise<void> => {
try {
// EN: Check database connection
// VI: Kiểm tra kết nối database
await prisma.$queryRaw`SELECT 1`;
res.json({
success: true,
data: { status: 'ready' },
timestamp: new Date().toISOString(),
});
} catch {
// EN: Return 503 if database is not ready
// VI: Trả về 503 nếu database chưa sẵn sàng
res.status(503).json({
success: false,
error: {
code: 'HEALTH_001',
message: 'Service not ready',
},
timestamp: new Date().toISOString(),
});
}
};
/**
* EN: Alias for health check
* VI: Alias cho kiểm tra sức khỏe
*/
live = async (_req: Request, res: Response): Promise<void> => {
res.json({
success: true,
data: { status: 'live' },
timestamp: new Date().toISOString(),
});
};
}

View File

@@ -0,0 +1,35 @@
import { logger } from '@goodgo/logger';
import { Request, Response } from 'express';
import { register } from 'prom-client';
/**
* EN: Controller for handling metrics requests
* VI: Controller xử lý các request liên quan đến metrics
*/
export class MetricsController {
/**
* EN: Get current metrics in Prometheus format
* VI: Lấy metrics hiện tại theo định dạng Prometheus
*
* @param _req - Express request (unused/chưa dùng)
* @param res - Express response
*/
public async getMetrics(_req: Request, res: Response): Promise<void> {
try {
// EN: Set content type for Prometheus
// VI: Thiết lập content type cho Prometheus
res.set('Content-Type', register.contentType);
// EN: Return metrics
// VI: Trả về metrics
res.end(await register.metrics());
} catch (error) {
// EN: Log error and return 500
// VI: Ghi log lỗi và trả về 500
logger.error('Error getting metrics', { error });
res.status(500).send('Error getting metrics');
}
}
}

View File

@@ -0,0 +1,141 @@
import { ApiResponse } from '@goodgo/types';
import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware';
import { createFeatureRouter } from '../modules/feature/feature.module';
import { HealthController } from '../modules/health/health.controller';
import { MetricsController } from '../modules/metrics/metrics.controller';
export const createRouter = (): Router => {
const router = Router();
const healthController = new HealthController();
const apiVersion = process.env.API_VERSION || 'v1';
// EN: Health check endpoints
// VI: Endpoints kiểm tra sức khỏe
/**
* @swagger
* /health:
* get:
* summary: Basic liveness probe
* description: Returns basic health status for liveness probes
* tags: [Health]
* responses:
* 200:
* description: Service is healthy
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/HealthResponse'
*/
router.get('/health', healthController.health);
/**
* @swagger
* /health/ready:
* get:
* summary: Readiness probe
* description: Checks if service is ready to handle requests (includes database connectivity)
* tags: [Health]
* responses:
* 200:
* description: Service is ready
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ReadinessResponse'
* 503:
* description: Service is not ready
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/health/ready', healthController.ready);
/**
* @swagger
* /health/live:
* get:
* summary: Liveness probe
* description: Basic liveness check for container orchestration
* tags: [Health]
* responses:
* 200:
* description: Service is alive
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/LivenessResponse'
*/
router.get('/health/live', healthController.live);
// EN: Authentication demo endpoint
// VI: Endpoint demo xác thực
/**
* @swagger
* /auth/me:
* get:
* summary: Get current user information
* description: Returns information about the currently authenticated user
* tags: [Authentication]
* security:
* - bearerAuth: []
* responses:
* 200:
* description: User information retrieved successfully
* content:
* application/json:
* schema:
* allOf:
* - $ref: '#/components/schemas/ApiResponse'
* - type: object
* properties:
* data:
* $ref: '#/components/schemas/UserInfo'
* 401:
* description: Authentication required
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
*/
router.get('/auth/me', authenticate(), (req, res) => {
const response: ApiResponse = {
success: true,
data: req.user,
message: 'User information retrieved / Thông tin người dùng đã được lấy',
timestamp: new Date().toISOString(),
};
res.json(response);
});
// API routes
router.use(`/api/${apiVersion}/features`, createFeatureRouter());
// EN: Metrics endpoint
// VI: Endpoint metrics
/**
* @swagger
* /metrics:
* get:
* summary: Get Prometheus metrics
* description: Returns application metrics in Prometheus format for monitoring
* tags: [Monitoring]
* responses:
* 200:
* description: Metrics in Prometheus format
* content:
* text/plain:
* schema:
* type: string
* example: "# HELP http_requests_total Total number of HTTP requests\n# TYPE http_requests_total counter\nhttp_requests_total{method=\"GET\",route=\"/health\",status=\"200\"} 42"
*/
const metricsController = new MetricsController();
router.get('/metrics', metricsController.getMetrics);
return router;
};

View File

@@ -0,0 +1,14 @@
{
"extends": "@goodgo/tsconfig/node.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["jest", "node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}