Migrate
This commit is contained in:
40
microservices/services/_template_dot_net/.env.example
Normal file
40
microservices/services/_template_dot_net/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Environment / Môi Trường
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
|
||||
# Database / Cơ Sở Dữ Liệu
|
||||
# PostgreSQL connection string (Neon or local)
|
||||
DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT Authentication / Xác Thực JWT
|
||||
JWT_SECRET=your-secret-key-min-32-characters-long-here
|
||||
JWT_ISSUER=goodgo-platform
|
||||
JWT_AUDIENCE=goodgo-services
|
||||
JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_EXPIRY_DAYS=7
|
||||
|
||||
# API Configuration / Cấu Hình API
|
||||
API_PORT=5000
|
||||
API_BASE_PATH=/api/v1/myservice
|
||||
|
||||
# Observability / Quan Sát
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=myservice
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=Information
|
||||
SEQ_URL=http://localhost:5341
|
||||
|
||||
# Feature Flags
|
||||
FEATURE_SWAGGER_ENABLED=true
|
||||
FEATURE_DETAILED_ERRORS=true
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PERMITS_PER_MINUTE=100
|
||||
RATE_LIMIT_QUEUE_LIMIT=10
|
||||
|
||||
# Health Checks
|
||||
HEALTHCHECK_TIMEOUT_SECONDS=5
|
||||
75
microservices/services/_template_dot_net/.gitignore
vendored
Normal file
75
microservices/services/_template_dot_net/.gitignore
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
# Build results
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.user
|
||||
*.userosscache
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.sln.docstates
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
packages/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# Coverage
|
||||
TestResults/
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
|
||||
# Secrets
|
||||
appsettings.*.json
|
||||
!appsettings.json
|
||||
!appsettings.Development.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# JetBrains
|
||||
*.resharper
|
||||
|
||||
# dotnet tools
|
||||
.config/dotnet-tools.json
|
||||
|
||||
# Migration scripts (only keep structure)
|
||||
Migrations/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
~$*
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>14.0</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591;CA2017</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<Authors>GoodGo Team</Authors>
|
||||
<Company>GoodGo</Company>
|
||||
<Copyright>© 2026 GoodGo. All rights reserved.</Copyright>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
66
microservices/services/_template_dot_net/Dockerfile
Normal file
66
microservices/services/_template_dot_net/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# Build stage / Giai đoạn build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy project files for layer caching
|
||||
# VI: Sao chép các file project để tận dụng layer caching
|
||||
COPY ["src/MyService.API/MyService.API.csproj", "src/MyService.API/"]
|
||||
COPY ["src/MyService.Domain/MyService.Domain.csproj", "src/MyService.Domain/"]
|
||||
COPY ["src/MyService.Infrastructure/MyService.Infrastructure.csproj", "src/MyService.Infrastructure/"]
|
||||
COPY ["Directory.Build.props", "./"]
|
||||
|
||||
# EN: Restore dependencies
|
||||
# VI: Khôi phục dependencies
|
||||
RUN dotnet restore "src/MyService.API/MyService.API.csproj"
|
||||
|
||||
# EN: Copy all source code
|
||||
# VI: Sao chép toàn bộ source code
|
||||
COPY src/ ./src/
|
||||
|
||||
# EN: Build the application
|
||||
# VI: Build ứng dụng
|
||||
WORKDIR "/src/src/MyService.API"
|
||||
RUN dotnet build "MyService.API.csproj" -c Release -o /app/build --no-restore
|
||||
|
||||
# Publish stage / Giai đoạn publish
|
||||
FROM build AS publish
|
||||
RUN dotnet publish "MyService.API.csproj" -c Release -o /app/publish /p:UseAppHost=false --no-restore
|
||||
|
||||
# Runtime stage / Giai đoạn runtime
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
|
||||
# EN: Create non-root user for security
|
||||
# VI: Tạo user non-root cho bảo mật
|
||||
RUN groupadd -g 1001 dotnetuser && \
|
||||
useradd -u 1001 -g dotnetuser -s /bin/sh dotnetuser
|
||||
|
||||
# EN: Copy published application
|
||||
# VI: Sao chép ứng dụng đã publish
|
||||
COPY --from=publish /app/publish .
|
||||
|
||||
# EN: Change ownership to non-root user
|
||||
# VI: Thay đổi quyền sở hữu sang user non-root
|
||||
RUN chown -R dotnetuser:dotnetuser /app
|
||||
|
||||
# EN: Switch to non-root user
|
||||
# VI: Chuyển sang user non-root
|
||||
USER dotnetuser
|
||||
|
||||
# EN: Expose port
|
||||
# VI: Mở cổng
|
||||
EXPOSE 8080
|
||||
|
||||
# EN: Set environment variables
|
||||
# VI: Thiết lập biến môi trường
|
||||
ENV ASPNETCORE_URLS=http://+:8080
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
|
||||
# EN: Health check
|
||||
# VI: Kiểm tra health
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/health/live || exit 1
|
||||
|
||||
# EN: Start the application
|
||||
# VI: Khởi động ứng dụng
|
||||
ENTRYPOINT ["dotnet", "MyService.API.dll"]
|
||||
11
microservices/services/_template_dot_net/MyService.slnx
Normal file
11
microservices/services/_template_dot_net/MyService.slnx
Normal file
@@ -0,0 +1,11 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/MyService.API/MyService.API.csproj" />
|
||||
<Project Path="src/MyService.Domain/MyService.Domain.csproj" />
|
||||
<Project Path="src/MyService.Infrastructure/MyService.Infrastructure.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/MyService.FunctionalTests/MyService.FunctionalTests.csproj" />
|
||||
<Project Path="tests/MyService.UnitTests/MyService.UnitTests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
72
microservices/services/_template_dot_net/docker-compose.yml
Normal file
72
microservices/services/_template_dot_net/docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
version: '3.8'
|
||||
|
||||
# EN: Docker Compose for local development
|
||||
# VI: Docker Compose cho phát triển local
|
||||
|
||||
services:
|
||||
myservice-api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: myservice-api
|
||||
ports:
|
||||
- "5000:8080"
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- DATABASE_URL=Host=postgres;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
|
||||
- REDIS_URL=redis:6379
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- myservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health/live"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: myservice-postgres
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: myservice_db
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- myservice-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: myservice-redis
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- myservice-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
|
||||
networks:
|
||||
myservice-network:
|
||||
driver: bridge
|
||||
271
microservices/services/_template_dot_net/docs/en/ARCHITECTURE.md
Normal file
271
microservices/services/_template_dot_net/docs/en/ARCHITECTURE.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Architecture Documentation
|
||||
|
||||
> Detailed architecture documentation for the .NET 10 Microservice Template.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "API Layer"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
end
|
||||
|
||||
subgraph "Domain Layer"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
end
|
||||
|
||||
subgraph "Infrastructure Layer"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
### 1. Domain Layer (MyService.Domain)
|
||||
|
||||
The heart of the application containing pure business logic. This layer:
|
||||
- Has **ZERO** external dependencies (except MediatR.Contracts for events)
|
||||
- Contains only POCO classes
|
||||
- Implements DDD tactical patterns
|
||||
|
||||
#### Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
|
||||
| **AggregatesModel** | Aggregate roots with their entities and value objects |
|
||||
| **Events** | Domain events for cross-aggregate communication |
|
||||
| **Exceptions** | Domain-specific exceptions for business rule violations |
|
||||
|
||||
### 2. Infrastructure Layer (MyService.Infrastructure)
|
||||
|
||||
Technical implementations and external concerns:
|
||||
- Database access (EF Core)
|
||||
- Repository implementations
|
||||
- External service integrations
|
||||
|
||||
### 3. API Layer (MyService.API)
|
||||
|
||||
Application entry point and CQRS implementation:
|
||||
- Controllers for HTTP handling
|
||||
- Commands for write operations
|
||||
- Queries for read operations
|
||||
- MediatR behaviors for cross-cutting concerns
|
||||
|
||||
## CQRS Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Controller
|
||||
participant MediatR
|
||||
participant LoggingBehavior
|
||||
participant ValidatorBehavior
|
||||
participant TransactionBehavior
|
||||
participant CommandHandler
|
||||
participant Repository
|
||||
participant DbContext
|
||||
|
||||
Client->>Controller: HTTP Request
|
||||
Controller->>MediatR: Send(Command)
|
||||
MediatR->>LoggingBehavior: Handle
|
||||
LoggingBehavior->>ValidatorBehavior: Next()
|
||||
ValidatorBehavior->>TransactionBehavior: Next()
|
||||
TransactionBehavior->>CommandHandler: Next()
|
||||
CommandHandler->>Repository: Add/Update/Delete
|
||||
Repository->>DbContext: SaveEntitiesAsync()
|
||||
DbContext-->>Repository: Success
|
||||
Repository-->>CommandHandler: Result
|
||||
CommandHandler-->>Controller: Response
|
||||
Controller-->>Client: HTTP Response
|
||||
```
|
||||
|
||||
## Domain Events
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
AR[Aggregate Root] -->|Raises| DE[Domain Event]
|
||||
DE -->|Dispatched by| CTX[DbContext]
|
||||
CTX -->|Publishes to| M[MediatR]
|
||||
M -->|Handled by| H1[Handler 1]
|
||||
M -->|Handled by| H2[Handler 2]
|
||||
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DE fill:#f39c12,stroke:#d68910,color:#fff
|
||||
style M fill:#9b59b6,stroke:#7d3c98,color:#fff
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Sample Aggregate
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
samples {
|
||||
uuid id PK
|
||||
varchar(200) name
|
||||
varchar(1000) description
|
||||
int status_id FK
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
sample_statuses {
|
||||
int id PK
|
||||
varchar(50) name
|
||||
}
|
||||
|
||||
samples ||--o{ sample_statuses : has
|
||||
```
|
||||
|
||||
## MediatR Pipeline
|
||||
|
||||
```
|
||||
Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Log start/end Validate Begin/Commit
|
||||
+ timing with Transaction
|
||||
FluentValidation
|
||||
```
|
||||
|
||||
### Behavior Order
|
||||
|
||||
1. **LoggingBehavior** - Logs request handling with timing
|
||||
2. **ValidatorBehavior** - Validates request using FluentValidation
|
||||
3. **TransactionBehavior** - Wraps command handlers in database transactions
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exception Hierarchy
|
||||
|
||||
```
|
||||
Exception
|
||||
└── DomainException
|
||||
└── SampleDomainException
|
||||
```
|
||||
|
||||
### Problem Details (RFC 7807)
|
||||
|
||||
All errors are returned in Problem Details format:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7807",
|
||||
"title": "Validation Error",
|
||||
"status": 400,
|
||||
"detail": "One or more validation errors occurred.",
|
||||
"errors": {
|
||||
"Name": ["Name is required"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
HC[Health Check Endpoint]
|
||||
HC --> |/health/live| L[Liveness]
|
||||
HC --> |/health/ready| R[Readiness]
|
||||
HC --> |/health| F[Full Status]
|
||||
|
||||
R --> PG[(PostgreSQL)]
|
||||
R --> RD[(Redis)]
|
||||
|
||||
style HC fill:#3498db,stroke:#2980b9,color:#fff
|
||||
style L fill:#2ecc71,stroke:#27ae60,color:#fff
|
||||
style R fill:#f39c12,stroke:#d68910,color:#fff
|
||||
```
|
||||
|
||||
## Deployment Architecture
|
||||
|
||||
### Docker Compose (Local)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myservice-api:
|
||||
build: .
|
||||
ports: ["5000:8080"]
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
```
|
||||
|
||||
### Kubernetes (Production)
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: myservice-api
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: myservice:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8080
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Authentication**: JWT Bearer token (configure in production)
|
||||
2. **Authorization**: Role-based access control
|
||||
3. **Input Validation**: FluentValidation on all requests
|
||||
4. **SQL Injection**: EF Core parameterized queries
|
||||
5. **Secrets**: Environment variables, never in code
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
1. **Connection Pooling**: EF Core with Npgsql connection resilience
|
||||
2. **Async/Await**: All I/O operations are async
|
||||
3. **Response Caching**: Add caching headers for queries
|
||||
4. **Database Indexes**: Configure in EntityConfigurations
|
||||
|
||||
## References
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers)
|
||||
- [.NET Microservices Architecture Guide](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/)
|
||||
- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html)
|
||||
- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
|
||||
265
microservices/services/_template_dot_net/docs/en/README.md
Normal file
265
microservices/services/_template_dot_net/docs/en/README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# .NET 10 Microservice Template
|
||||
|
||||
> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns.
|
||||
|
||||
## Overview
|
||||
|
||||
This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR
|
||||
- **Clean Architecture** - Domain, Infrastructure, API layered separation
|
||||
- **EF Core 10** - PostgreSQL with connection resilience
|
||||
- **FluentValidation** - Request validation
|
||||
- **API Versioning** - URL segment versioning
|
||||
- **Health Checks** - Kubernetes-ready probes
|
||||
- **Structured Logging** - Serilog with console and Seq
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (or use Docker) |
|
||||
|
||||
```bash
|
||||
# Check .NET version
|
||||
dotnet --version
|
||||
# Should output: 10.0.xxx
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create New Service
|
||||
|
||||
```bash
|
||||
# Copy template to new service
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Navigate to service directory
|
||||
cd services/your-service-name
|
||||
|
||||
# Rename all occurrences of "MyService" to "YourService"
|
||||
find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} +
|
||||
find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} +
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your configuration
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 3. Run with Docker
|
||||
|
||||
```bash
|
||||
# Start all services (API + PostgreSQL + Redis)
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f myservice-api
|
||||
```
|
||||
|
||||
### 4. Run Locally
|
||||
|
||||
```bash
|
||||
# Restore dependencies
|
||||
dotnet restore
|
||||
|
||||
# Build all projects
|
||||
dotnet build
|
||||
|
||||
# Run the API
|
||||
dotnet run --project src/MyService.API
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
_template_dot_net/
|
||||
├── src/
|
||||
│ ├── MyService.API/ # Presentation Layer (Controllers, CQRS)
|
||||
│ │ ├── Controllers/ # API endpoints
|
||||
│ │ ├── Application/ # CQRS Implementation
|
||||
│ │ │ ├── Commands/ # Write operations (MediatR)
|
||||
│ │ │ ├── Queries/ # Read operations
|
||||
│ │ │ ├── Behaviors/ # MediatR pipeline behaviors
|
||||
│ │ │ └── Validations/ # FluentValidation validators
|
||||
│ │ ├── Middleware/ # Custom middleware
|
||||
│ │ └── Program.cs # Application entry point
|
||||
│ │
|
||||
│ ├── MyService.Domain/ # Domain Layer (Pure business logic)
|
||||
│ │ ├── AggregatesModel/ # Aggregate roots and entities
|
||||
│ │ ├── Events/ # Domain events
|
||||
│ │ ├── Exceptions/ # Domain exceptions
|
||||
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
|
||||
│ │
|
||||
│ └── MyService.Infrastructure/ # Infrastructure Layer (Data access)
|
||||
│ ├── EntityConfigurations/ # EF Core Fluent API configurations
|
||||
│ ├── Repositories/ # Repository implementations
|
||||
│ ├── Idempotency/ # Request idempotency handling
|
||||
│ └── MyServiceContext.cs # DbContext with Unit of Work
|
||||
│
|
||||
├── tests/
|
||||
│ ├── MyService.UnitTests/ # Unit tests (Domain, Application)
|
||||
│ └── MyService.FunctionalTests/ # Integration tests (API endpoints)
|
||||
│
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Local development setup
|
||||
├── global.json # .NET SDK version pinning
|
||||
└── Directory.Build.props # Common MSBuild properties
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `GET` | `/api/v1/samples` | Get all samples |
|
||||
| `GET` | `/api/v1/samples/{id}` | Get sample by ID |
|
||||
| `POST` | `/api/v1/samples` | Create new sample |
|
||||
| `PUT` | `/api/v1/samples/{id}` | Update sample |
|
||||
| `DELETE` | `/api/v1/samples/{id}` | Delete sample |
|
||||
| `PATCH` | `/api/v1/samples/{id}/status` | Change status |
|
||||
|
||||
### Health Endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `/health` | Full health status |
|
||||
| `/health/live` | Liveness probe |
|
||||
| `/health/ready` | Readiness probe |
|
||||
|
||||
## CQRS Pattern
|
||||
|
||||
### Commands (Write Operations)
|
||||
|
||||
```csharp
|
||||
// Define command
|
||||
public record CreateSampleCommand(string Name, string? Description)
|
||||
: IRequest<CreateSampleCommandResult>;
|
||||
|
||||
// Handle command
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
|
||||
{
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
_repository.Add(sample);
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries (Read Operations)
|
||||
|
||||
```csharp
|
||||
// Define query
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Root
|
||||
|
||||
```csharp
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
public string Name => _name;
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
public Sample(string name, string? description) {
|
||||
// Business logic validation
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
// Domain event
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
public void Activate() {
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
// State transition
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
dotnet test
|
||||
|
||||
# Run with coverage
|
||||
dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura
|
||||
|
||||
# Run specific test project
|
||||
dotnet test tests/MyService.UnitTests
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | Environment name | `Development` |
|
||||
| `DATABASE_URL` | PostgreSQL connection string | - |
|
||||
| `REDIS_URL` | Redis connection string | - |
|
||||
| `JWT_SECRET` | JWT signing secret (min 32 chars) | - |
|
||||
|
||||
### appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Build
|
||||
|
||||
```bash
|
||||
# Build Docker image
|
||||
docker build -t myservice:latest .
|
||||
|
||||
# Run container
|
||||
docker run -p 5000:8080 --env-file .env myservice:latest
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
See [ARCHITECTURE.md](./ARCHITECTURE.md) for Kubernetes deployment manifests.
|
||||
|
||||
## What's New in .NET 10
|
||||
|
||||
- **C# 14** language features
|
||||
- Improved **Native AOT** support
|
||||
- Better **async/await** performance
|
||||
- Enhanced **JSON serialization**
|
||||
- Performance improvements across the board
|
||||
- 3-year **LTS** support (until November 2028)
|
||||
|
||||
## Resources
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Reference architecture
|
||||
- [.NET 10 Documentation](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10)
|
||||
- [DDD with .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|
||||
- [MediatR](https://github.com/jbogard/MediatR) - CQRS library
|
||||
- [FluentValidation](https://docs.fluentvalidation.net/) - Validation library
|
||||
|
||||
## License
|
||||
|
||||
Proprietary - GoodGo Platform
|
||||
271
microservices/services/_template_dot_net/docs/vi/ARCHITECTURE.md
Normal file
271
microservices/services/_template_dot_net/docs/vi/ARCHITECTURE.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Tài Liệu Kiến Trúc
|
||||
|
||||
> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10.
|
||||
|
||||
## Tổng Quan Kiến Trúc
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Lớp API"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
end
|
||||
|
||||
subgraph "Lớp Domain"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
end
|
||||
|
||||
subgraph "Lớp Infrastructure"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
## Trách Nhiệm Các Lớp
|
||||
|
||||
### 1. Lớp Domain (MyService.Domain)
|
||||
|
||||
Trái tim của ứng dụng chứa business logic thuần túy. Lớp này:
|
||||
- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events)
|
||||
- Chỉ chứa các class POCO
|
||||
- Triển khai các tactical patterns của DDD
|
||||
|
||||
#### Thành Phần
|
||||
|
||||
| Thành phần | Mục Đích |
|
||||
|------------|----------|
|
||||
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
|
||||
| **AggregatesModel** | Aggregate roots với entities và value objects |
|
||||
| **Events** | Domain events cho giao tiếp cross-aggregate |
|
||||
| **Exceptions** | Domain exceptions cho vi phạm business rules |
|
||||
|
||||
### 2. Lớp Infrastructure (MyService.Infrastructure)
|
||||
|
||||
Triển khai kỹ thuật và các mối quan tâm bên ngoài:
|
||||
- Truy cập database (EF Core)
|
||||
- Triển khai repositories
|
||||
- Tích hợp external services
|
||||
|
||||
### 3. Lớp API (MyService.API)
|
||||
|
||||
Điểm vào ứng dụng và triển khai CQRS:
|
||||
- Controllers để xử lý HTTP
|
||||
- Commands cho các thao tác ghi
|
||||
- Queries cho các thao tác đọc
|
||||
- MediatR behaviors cho cross-cutting concerns
|
||||
|
||||
## Luồng CQRS
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client
|
||||
participant Controller
|
||||
participant MediatR
|
||||
participant LoggingBehavior
|
||||
participant ValidatorBehavior
|
||||
participant TransactionBehavior
|
||||
participant CommandHandler
|
||||
participant Repository
|
||||
participant DbContext
|
||||
|
||||
Client->>Controller: HTTP Request
|
||||
Controller->>MediatR: Send(Command)
|
||||
MediatR->>LoggingBehavior: Handle
|
||||
LoggingBehavior->>ValidatorBehavior: Next()
|
||||
ValidatorBehavior->>TransactionBehavior: Next()
|
||||
TransactionBehavior->>CommandHandler: Next()
|
||||
CommandHandler->>Repository: Add/Update/Delete
|
||||
Repository->>DbContext: SaveEntitiesAsync()
|
||||
DbContext-->>Repository: Success
|
||||
Repository-->>CommandHandler: Result
|
||||
CommandHandler-->>Controller: Response
|
||||
Controller-->>Client: HTTP Response
|
||||
```
|
||||
|
||||
## Domain Events
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
AR[Aggregate Root] -->|Phát sinh| DE[Domain Event]
|
||||
DE -->|Dispatch bởi| CTX[DbContext]
|
||||
CTX -->|Publish tới| M[MediatR]
|
||||
M -->|Xử lý bởi| H1[Handler 1]
|
||||
M -->|Xử lý bởi| H2[Handler 2]
|
||||
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DE fill:#f39c12,stroke:#d68910,color:#fff
|
||||
style M fill:#9b59b6,stroke:#7d3c98,color:#fff
|
||||
```
|
||||
|
||||
## Schema Database
|
||||
|
||||
### Sample Aggregate
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
samples {
|
||||
uuid id PK
|
||||
varchar(200) name
|
||||
varchar(1000) description
|
||||
int status_id FK
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
sample_statuses {
|
||||
int id PK
|
||||
varchar(50) name
|
||||
}
|
||||
|
||||
samples ||--o{ sample_statuses : has
|
||||
```
|
||||
|
||||
## Pipeline MediatR
|
||||
|
||||
```
|
||||
Request → LoggingBehavior → ValidatorBehavior → TransactionBehavior → Handler → Response
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Log start/end Validate Begin/Commit
|
||||
+ timing với Transaction
|
||||
FluentValidation
|
||||
```
|
||||
|
||||
### Thứ Tự Behaviors
|
||||
|
||||
1. **LoggingBehavior** - Ghi log xử lý request với timing
|
||||
2. **ValidatorBehavior** - Validate request sử dụng FluentValidation
|
||||
3. **TransactionBehavior** - Bao bọc command handlers trong database transactions
|
||||
|
||||
## Xử Lý Lỗi
|
||||
|
||||
### Phân Cấp Exceptions
|
||||
|
||||
```
|
||||
Exception
|
||||
└── DomainException
|
||||
└── SampleDomainException
|
||||
```
|
||||
|
||||
### Problem Details (RFC 7807)
|
||||
|
||||
Tất cả lỗi được trả về theo định dạng Problem Details:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://tools.ietf.org/html/rfc7807",
|
||||
"title": "Lỗi Validation",
|
||||
"status": 400,
|
||||
"detail": "Một hoặc nhiều lỗi validation đã xảy ra.",
|
||||
"errors": {
|
||||
"Name": ["Tên là bắt buộc"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Health Checks
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
HC[Health Check Endpoint]
|
||||
HC --> |/health/live| L[Liveness]
|
||||
HC --> |/health/ready| R[Readiness]
|
||||
HC --> |/health| F[Full Status]
|
||||
|
||||
R --> PG[(PostgreSQL)]
|
||||
R --> RD[(Redis)]
|
||||
|
||||
style HC fill:#3498db,stroke:#2980b9,color:#fff
|
||||
style L fill:#2ecc71,stroke:#27ae60,color:#fff
|
||||
style R fill:#f39c12,stroke:#d68910,color:#fff
|
||||
```
|
||||
|
||||
## Kiến Trúc Deployment
|
||||
|
||||
### Docker Compose (Local)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
myservice-api:
|
||||
build: .
|
||||
ports: ["5000:8080"]
|
||||
depends_on:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
```
|
||||
|
||||
### Kubernetes (Production)
|
||||
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: myservice-api
|
||||
spec:
|
||||
replicas: 3
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: api
|
||||
image: myservice:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health/live
|
||||
port: 8080
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health/ready
|
||||
port: 8080
|
||||
```
|
||||
|
||||
## Cân Nhắc Bảo Mật
|
||||
|
||||
1. **Authentication**: JWT Bearer token (cấu hình trong production)
|
||||
2. **Authorization**: Role-based access control
|
||||
3. **Input Validation**: FluentValidation trên tất cả requests
|
||||
4. **SQL Injection**: EF Core parameterized queries
|
||||
5. **Secrets**: Biến môi trường, không bao giờ trong code
|
||||
|
||||
## Tối Ưu Hiệu Năng
|
||||
|
||||
1. **Connection Pooling**: EF Core với Npgsql connection resilience
|
||||
2. **Async/Await**: Tất cả I/O operations đều async
|
||||
3. **Response Caching**: Thêm caching headers cho queries
|
||||
4. **Database Indexes**: Cấu hình trong EntityConfigurations
|
||||
|
||||
## Tài Liệu Tham Khảo
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers)
|
||||
- [Hướng dẫn Kiến trúc .NET Microservices](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/)
|
||||
- [Domain-Driven Design](https://martinfowler.com/bliki/DomainDrivenDesign.html)
|
||||
- [CQRS Pattern](https://docs.microsoft.com/en-us/azure/architecture/patterns/cqrs)
|
||||
265
microservices/services/_template_dot_net/docs/vi/README.md
Normal file
265
microservices/services/_template_dot_net/docs/vi/README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Template Microservice .NET 10
|
||||
|
||||
> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture.
|
||||
|
||||
## Tổng Quan
|
||||
|
||||
Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR
|
||||
- **Clean Architecture** - Phân tầng Domain, Infrastructure, API
|
||||
- **EF Core 10** - PostgreSQL với connection resilience
|
||||
- **FluentValidation** - Validation request
|
||||
- **API Versioning** - Versioning theo URL segment
|
||||
- **Health Checks** - Probes sẵn sàng cho Kubernetes
|
||||
- **Structured Logging** - Serilog với console và Seq
|
||||
|
||||
## Yêu Cầu
|
||||
|
||||
| Yêu cầu | Phiên bản |
|
||||
|---------|-----------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (hoặc dùng Docker) |
|
||||
|
||||
```bash
|
||||
# Kiểm tra phiên bản .NET
|
||||
dotnet --version
|
||||
# Kết quả nên là: 10.0.xxx
|
||||
```
|
||||
|
||||
## Bắt Đầu Nhanh
|
||||
|
||||
### 1. Tạo Service Mới
|
||||
|
||||
```bash
|
||||
# Sao chép template sang service mới
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Di chuyển đến thư mục service
|
||||
cd services/your-service-name
|
||||
|
||||
# Đổi tên tất cả "MyService" thành "YourService"
|
||||
find . -type f -name "*.cs" -exec sed -i '' 's/MyService/YourService/g' {} +
|
||||
find . -type f -name "*.csproj" -exec sed -i '' 's/MyService/YourService/g' {} +
|
||||
```
|
||||
|
||||
### 2. Cấu Hình Môi Trường
|
||||
|
||||
```bash
|
||||
# Sao chép template môi trường
|
||||
cp .env.example .env
|
||||
|
||||
# Chỉnh sửa với cấu hình của bạn
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 3. Chạy với Docker
|
||||
|
||||
```bash
|
||||
# Khởi động tất cả services (API + PostgreSQL + Redis)
|
||||
docker-compose up -d
|
||||
|
||||
# Xem logs
|
||||
docker-compose logs -f myservice-api
|
||||
```
|
||||
|
||||
### 4. Chạy Local
|
||||
|
||||
```bash
|
||||
# Khôi phục dependencies
|
||||
dotnet restore
|
||||
|
||||
# Build tất cả projects
|
||||
dotnet build
|
||||
|
||||
# Chạy API
|
||||
dotnet run --project src/MyService.API
|
||||
```
|
||||
|
||||
## Cấu Trúc Dự Án
|
||||
|
||||
```
|
||||
_template_dot_net/
|
||||
├── src/
|
||||
│ ├── MyService.API/ # Lớp Presentation (Controllers, CQRS)
|
||||
│ │ ├── Controllers/ # Các API endpoints
|
||||
│ │ ├── Application/ # Triển khai CQRS
|
||||
│ │ │ ├── Commands/ # Thao tác ghi (MediatR)
|
||||
│ │ │ ├── Queries/ # Thao tác đọc
|
||||
│ │ │ ├── Behaviors/ # MediatR pipeline behaviors
|
||||
│ │ │ └── Validations/ # FluentValidation validators
|
||||
│ │ ├── Middleware/ # Custom middleware
|
||||
│ │ └── Program.cs # Điểm vào ứng dụng
|
||||
│ │
|
||||
│ ├── MyService.Domain/ # Lớp Domain (Business logic thuần túy)
|
||||
│ │ ├── AggregatesModel/ # Aggregate roots và entities
|
||||
│ │ ├── Events/ # Domain events
|
||||
│ │ ├── Exceptions/ # Domain exceptions
|
||||
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
|
||||
│ │
|
||||
│ └── MyService.Infrastructure/ # Lớp Infrastructure (Truy cập dữ liệu)
|
||||
│ ├── EntityConfigurations/ # Cấu hình EF Core Fluent API
|
||||
│ ├── Repositories/ # Triển khai repositories
|
||||
│ ├── Idempotency/ # Xử lý idempotency request
|
||||
│ └── MyServiceContext.cs # DbContext với Unit of Work
|
||||
│
|
||||
├── tests/
|
||||
│ ├── MyService.UnitTests/ # Unit tests (Domain, Application)
|
||||
│ └── MyService.FunctionalTests/ # Integration tests (API endpoints)
|
||||
│
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Thiết lập phát triển local
|
||||
├── global.json # Pin phiên bản .NET SDK
|
||||
└── Directory.Build.props # Thuộc tính MSBuild chung
|
||||
```
|
||||
|
||||
## Các Endpoint API
|
||||
|
||||
| Method | Endpoint | Mô Tả |
|
||||
|--------|----------|-------|
|
||||
| `GET` | `/api/v1/samples` | Lấy tất cả samples |
|
||||
| `GET` | `/api/v1/samples/{id}` | Lấy sample theo ID |
|
||||
| `POST` | `/api/v1/samples` | Tạo sample mới |
|
||||
| `PUT` | `/api/v1/samples/{id}` | Cập nhật sample |
|
||||
| `DELETE` | `/api/v1/samples/{id}` | Xóa sample |
|
||||
| `PATCH` | `/api/v1/samples/{id}/status` | Thay đổi trạng thái |
|
||||
|
||||
### Health Endpoints
|
||||
|
||||
| Endpoint | Mục Đích |
|
||||
|----------|----------|
|
||||
| `/health` | Trạng thái health đầy đủ |
|
||||
| `/health/live` | Kiểm tra sống |
|
||||
| `/health/ready` | Kiểm tra sẵn sàng |
|
||||
|
||||
## Pattern CQRS
|
||||
|
||||
### Commands (Thao Tác Ghi)
|
||||
|
||||
```csharp
|
||||
// Định nghĩa command
|
||||
public record CreateSampleCommand(string Name, string? Description)
|
||||
: IRequest<CreateSampleCommandResult>;
|
||||
|
||||
// Xử lý command
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
public async Task<CreateSampleCommandResult> Handle(CreateSampleCommand request, CancellationToken ct)
|
||||
{
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
_repository.Add(sample);
|
||||
await _repository.UnitOfWork.SaveEntitiesAsync(ct);
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Queries (Thao Tác Đọc)
|
||||
|
||||
```csharp
|
||||
// Định nghĩa query
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
```
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Aggregate Root
|
||||
|
||||
```csharp
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
public string Name => _name;
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
public Sample(string name, string? description) {
|
||||
// Validation business logic
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Tên sample không được để trống");
|
||||
|
||||
// Domain event
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
public void Activate() {
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Chỉ sample draft mới có thể kích hoạt");
|
||||
// Chuyển đổi trạng thái
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Kiểm Thử
|
||||
|
||||
```bash
|
||||
# Chạy tất cả tests
|
||||
dotnet test
|
||||
|
||||
# Chạy với coverage
|
||||
dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=cobertura
|
||||
|
||||
# Chạy project test cụ thể
|
||||
dotnet test tests/MyService.UnitTests
|
||||
```
|
||||
|
||||
## Cấu Hình
|
||||
|
||||
### Biến Môi Trường
|
||||
|
||||
| Biến | Mô Tả | Mặc định |
|
||||
|------|-------|----------|
|
||||
| `ASPNETCORE_ENVIRONMENT` | Tên môi trường | `Development` |
|
||||
| `DATABASE_URL` | Connection string PostgreSQL | - |
|
||||
| `REDIS_URL` | Connection string Redis | - |
|
||||
| `JWT_SECRET` | Secret ký JWT (tối thiểu 32 ký tự) | - |
|
||||
|
||||
### appsettings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=myservice;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Triển Khai
|
||||
|
||||
### Docker Build
|
||||
|
||||
```bash
|
||||
# Build Docker image
|
||||
docker build -t myservice:latest .
|
||||
|
||||
# Chạy container
|
||||
docker run -p 5000:8080 --env-file .env myservice:latest
|
||||
```
|
||||
|
||||
### Kubernetes
|
||||
|
||||
Xem [ARCHITECTURE.md](./ARCHITECTURE.md) để biết manifests triển khai Kubernetes.
|
||||
|
||||
## Có Gì Mới Trong .NET 10
|
||||
|
||||
- Tính năng ngôn ngữ **C# 14**
|
||||
- Hỗ trợ **Native AOT** được cải thiện
|
||||
- Hiệu suất **async/await** tốt hơn
|
||||
- **JSON serialization** được nâng cao
|
||||
- Cải thiện hiệu suất toàn diện
|
||||
- Hỗ trợ **LTS** 3 năm (đến tháng 11/2028)
|
||||
|
||||
## Tài Nguyên
|
||||
|
||||
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers) - Kiến trúc tham chiếu
|
||||
- [Tài liệu .NET 10](https://docs.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10)
|
||||
- [DDD với .NET](https://docs.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/)
|
||||
- [MediatR](https://github.com/jbogard/MediatR) - Thư viện CQRS
|
||||
- [FluentValidation](https://docs.fluentvalidation.net/) - Thư viện validation
|
||||
|
||||
## Giấy Phép
|
||||
|
||||
Độc quyền - GoodGo Platform
|
||||
7
microservices/services/_template_dot_net/global.json
Normal file
7
microservices/services/_template_dot_net/global.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.101",
|
||||
"rollForward": "latestMinor",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for logging request handling.
|
||||
/// VI: MediatR behavior để logging việc xử lý request.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public LoggingBehavior(ILogger<LoggingBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Handling {RequestName} / Đang xử lý {RequestName}",
|
||||
requestName);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var response = await next();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Handled {RequestName} in {ElapsedMs}ms / Đã xử lý {RequestName} trong {ElapsedMs}ms",
|
||||
requestName, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
|
||||
_logger.LogError(ex,
|
||||
"Error handling {RequestName} after {ElapsedMs}ms / Lỗi xử lý {RequestName} sau {ElapsedMs}ms",
|
||||
requestName, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MyService.Infrastructure;
|
||||
|
||||
namespace MyService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for handling database transactions.
|
||||
/// VI: MediatR behavior để xử lý database transactions.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly MyServiceContext _dbContext;
|
||||
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public TransactionBehavior(
|
||||
MyServiceContext dbContext,
|
||||
ILogger<TransactionBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
// EN: Skip transaction for queries (read operations)
|
||||
// VI: Bỏ qua transaction cho queries (các thao tác đọc)
|
||||
if (requestName.EndsWith("Query"))
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
// EN: Skip if already in a transaction
|
||||
// VI: Bỏ qua nếu đã trong transaction
|
||||
if (_dbContext.HasActiveTransaction)
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
var strategy = _dbContext.Database.CreateExecutionStrategy();
|
||||
|
||||
return await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var transaction = await _dbContext.BeginTransactionAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Begin transaction {TransactionId} for {RequestName} / Bắt đầu transaction {TransactionId} cho {RequestName}",
|
||||
transaction?.TransactionId, requestName);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await next();
|
||||
|
||||
if (transaction != null)
|
||||
{
|
||||
await _dbContext.CommitTransactionAsync(transaction);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Committed transaction {TransactionId} for {RequestName} / Đã commit transaction {TransactionId} cho {RequestName}",
|
||||
transaction.TransactionId, requestName);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Error during transaction {TransactionId} for {RequestName} / Lỗi trong transaction {TransactionId} cho {RequestName}",
|
||||
transaction?.TransactionId, requestName);
|
||||
|
||||
_dbContext.RollbackTransaction();
|
||||
throw;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// EN: MediatR behavior for FluentValidation integration.
|
||||
/// VI: MediatR behavior để tích hợp FluentValidation.
|
||||
/// </summary>
|
||||
/// <typeparam name="TRequest">EN: Request type / VI: Loại request</typeparam>
|
||||
/// <typeparam name="TResponse">EN: Response type / VI: Loại response</typeparam>
|
||||
public class ValidatorBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
private readonly ILogger<ValidatorBehavior<TRequest, TResponse>> _logger;
|
||||
|
||||
public ValidatorBehavior(
|
||||
IEnumerable<IValidator<TRequest>> validators,
|
||||
ILogger<ValidatorBehavior<TRequest, TResponse>> logger)
|
||||
{
|
||||
_validators = validators ?? throw new ArgumentNullException(nameof(validators));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var requestName = typeof(TRequest).Name;
|
||||
|
||||
if (!_validators.Any())
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Validating {RequestName} / Đang validate {RequestName}",
|
||||
requestName);
|
||||
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
|
||||
var validationResults = await Task.WhenAll(
|
||||
_validators.Select(v => v.ValidateAsync(context, cancellationToken)));
|
||||
|
||||
var failures = validationResults
|
||||
.SelectMany(r => r.Errors)
|
||||
.Where(f => f != null)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count != 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Validation failed for {RequestName} with {ErrorCount} errors / Validation thất bại cho {RequestName} với {ErrorCount} lỗi",
|
||||
requestName, failures.Count);
|
||||
|
||||
throw new ValidationException(failures);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to change status of a Sample.
|
||||
/// VI: Command để thay đổi trạng thái của Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="NewStatus">EN: New status (activate, complete, cancel) / VI: Trạng thái mới (activate, complete, cancel)</param>
|
||||
public record ChangeSampleStatusCommand(
|
||||
Guid SampleId,
|
||||
string NewStatus
|
||||
) : IRequest<bool>;
|
||||
@@ -0,0 +1,70 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for ChangeSampleStatusCommand.
|
||||
/// VI: Handler cho ChangeSampleStatusCommand.
|
||||
/// </summary>
|
||||
public class ChangeSampleStatusCommandHandler : IRequestHandler<ChangeSampleStatusCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<ChangeSampleStatusCommandHandler> _logger;
|
||||
|
||||
public ChangeSampleStatusCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<ChangeSampleStatusCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
ChangeSampleStatusCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Changing status of sample {SampleId} to {NewStatus} / Thay đổi trạng thái sample {SampleId} thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Change status based on action / VI: Thay đổi trạng thái dựa trên action
|
||||
switch (request.NewStatus.ToLowerInvariant())
|
||||
{
|
||||
case "activate":
|
||||
sample.Activate();
|
||||
break;
|
||||
case "complete":
|
||||
sample.Complete();
|
||||
break;
|
||||
case "cancel":
|
||||
sample.Cancel();
|
||||
break;
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Invalid status action: {NewStatus} / Action trạng thái không hợp lệ: {NewStatus}",
|
||||
request.NewStatus);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} status changed to {NewStatus} / Trạng thái sample {SampleId} đã đổi thành {NewStatus}",
|
||||
request.SampleId, request.NewStatus);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to create a new Sample.
|
||||
/// VI: Command để tạo một Sample mới.
|
||||
/// </summary>
|
||||
/// <param name="Name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="Description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public record CreateSampleCommand(
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<CreateSampleCommandResult>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Result of CreateSampleCommand.
|
||||
/// VI: Kết quả của CreateSampleCommand.
|
||||
/// </summary>
|
||||
/// <param name="Id">EN: Created sample ID / VI: ID sample đã tạo</param>
|
||||
public record CreateSampleCommandResult(Guid Id);
|
||||
@@ -0,0 +1,46 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for CreateSampleCommand.
|
||||
/// VI: Handler cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandler : IRequestHandler<CreateSampleCommand, CreateSampleCommandResult>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<CreateSampleCommandHandler> _logger;
|
||||
|
||||
public CreateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<CreateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateSampleCommandResult> Handle(
|
||||
CreateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Creating new sample with name: {Name} / Tạo sample mới với tên: {Name}",
|
||||
request.Name);
|
||||
|
||||
// EN: Create domain entity / VI: Tạo domain entity
|
||||
var sample = new Sample(request.Name, request.Description);
|
||||
|
||||
// EN: Add to repository / VI: Thêm vào repository
|
||||
_sampleRepository.Add(sample);
|
||||
|
||||
// EN: Save changes (dispatches domain events) / VI: Lưu thay đổi (dispatch domain events)
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample created successfully with ID: {SampleId} / Sample đã tạo thành công với ID: {SampleId}",
|
||||
sample.Id);
|
||||
|
||||
return new CreateSampleCommandResult(sample.Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to delete a Sample.
|
||||
/// VI: Command để xóa một Sample.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to delete / VI: ID sample cần xóa</param>
|
||||
public record DeleteSampleCommand(Guid SampleId) : IRequest<bool>;
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for DeleteSampleCommand.
|
||||
/// VI: Handler cho DeleteSampleCommand.
|
||||
/// </summary>
|
||||
public class DeleteSampleCommandHandler : IRequestHandler<DeleteSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<DeleteSampleCommandHandler> _logger;
|
||||
|
||||
public DeleteSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<DeleteSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
DeleteSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Deleting sample {SampleId} / Xóa sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Delete sample / VI: Xóa sample
|
||||
_sampleRepository.Delete(sample);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} deleted successfully / Sample {SampleId} đã xóa thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Command to update an existing Sample.
|
||||
/// VI: Command để cập nhật một Sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID to update / VI: ID sample cần cập nhật</param>
|
||||
/// <param name="Name">EN: New name / VI: Tên mới</param>
|
||||
/// <param name="Description">EN: New description / VI: Mô tả mới</param>
|
||||
public record UpdateSampleCommand(
|
||||
Guid SampleId,
|
||||
string Name,
|
||||
string? Description
|
||||
) : IRequest<bool>;
|
||||
@@ -0,0 +1,54 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.API.Application.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for UpdateSampleCommand.
|
||||
/// VI: Handler cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandHandler : IRequestHandler<UpdateSampleCommand, bool>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
private readonly ILogger<UpdateSampleCommandHandler> _logger;
|
||||
|
||||
public UpdateSampleCommandHandler(
|
||||
ISampleRepository sampleRepository,
|
||||
ILogger<UpdateSampleCommandHandler> logger)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<bool> Handle(
|
||||
UpdateSampleCommand request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Updating sample {SampleId} / Cập nhật sample {SampleId}",
|
||||
request.SampleId);
|
||||
|
||||
// EN: Get existing sample / VI: Lấy sample đã tồn tại
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Sample {SampleId} not found / Sample {SampleId} không tìm thấy",
|
||||
request.SampleId);
|
||||
return false;
|
||||
}
|
||||
|
||||
// EN: Update sample using domain method / VI: Cập nhật sample sử dụng domain method
|
||||
sample.Update(request.Name, request.Description);
|
||||
|
||||
// EN: Save changes / VI: Lưu thay đổi
|
||||
await _sampleRepository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Sample {SampleId} updated successfully / Sample {SampleId} đã cập nhật thành công",
|
||||
request.SampleId);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get a Sample by ID.
|
||||
/// VI: Query để lấy một Sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="SampleId">EN: Sample ID / VI: ID sample</param>
|
||||
public record GetSampleQuery(Guid SampleId) : IRequest<SampleViewModel?>;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample view model for API responses.
|
||||
/// VI: Sample view model cho API responses.
|
||||
/// </summary>
|
||||
public record SampleViewModel(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Description,
|
||||
string Status,
|
||||
DateTime CreatedAt,
|
||||
DateTime? UpdatedAt
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSampleQuery.
|
||||
/// VI: Handler cho GetSampleQuery.
|
||||
/// </summary>
|
||||
public class GetSampleQueryHandler : IRequestHandler<GetSampleQuery, SampleViewModel?>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSampleQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<SampleViewModel?> Handle(
|
||||
GetSampleQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sample = await _sampleRepository.GetAsync(request.SampleId);
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Query to get all Samples.
|
||||
/// VI: Query để lấy tất cả Samples.
|
||||
/// </summary>
|
||||
public record GetSamplesQuery : IRequest<IEnumerable<SampleViewModel>>;
|
||||
@@ -0,0 +1,34 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.API.Application.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Handler for GetSamplesQuery.
|
||||
/// VI: Handler cho GetSamplesQuery.
|
||||
/// </summary>
|
||||
public class GetSamplesQueryHandler : IRequestHandler<GetSamplesQuery, IEnumerable<SampleViewModel>>
|
||||
{
|
||||
private readonly ISampleRepository _sampleRepository;
|
||||
|
||||
public GetSamplesQueryHandler(ISampleRepository sampleRepository)
|
||||
{
|
||||
_sampleRepository = sampleRepository ?? throw new ArgumentNullException(nameof(sampleRepository));
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<SampleViewModel>> Handle(
|
||||
GetSamplesQuery request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var samples = await _sampleRepository.GetAllAsync();
|
||||
|
||||
return samples.Select(sample => new SampleViewModel(
|
||||
sample.Id,
|
||||
sample.Name,
|
||||
sample.Description,
|
||||
sample.Status.Name,
|
||||
sample.CreatedAt,
|
||||
sample.UpdatedAt
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using FluentValidation;
|
||||
using MyService.API.Application.Commands;
|
||||
|
||||
namespace MyService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for CreateSampleCommand.
|
||||
/// VI: Validator cho CreateSampleCommand.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandValidator : AbstractValidator<CreateSampleCommand>
|
||||
{
|
||||
public CreateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using FluentValidation;
|
||||
using MyService.API.Application.Commands;
|
||||
|
||||
namespace MyService.API.Application.Validations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Validator for UpdateSampleCommand.
|
||||
/// VI: Validator cho UpdateSampleCommand.
|
||||
/// </summary>
|
||||
public class UpdateSampleCommandValidator : AbstractValidator<UpdateSampleCommand>
|
||||
{
|
||||
public UpdateSampleCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.SampleId)
|
||||
.NotEmpty()
|
||||
.WithMessage("Sample ID is required / ID sample là bắt buộc");
|
||||
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.WithMessage("Name is required / Tên là bắt buộc")
|
||||
.MaximumLength(200)
|
||||
.WithMessage("Name must be less than 200 characters / Tên phải ít hơn 200 ký tự");
|
||||
|
||||
RuleFor(x => x.Description)
|
||||
.MaximumLength(1000)
|
||||
.WithMessage("Description must be less than 1000 characters / Mô tả phải ít hơn 1000 ký tự")
|
||||
.When(x => x.Description != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
using Asp.Versioning;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MyService.API.Application.Commands;
|
||||
using MyService.API.Application.Queries;
|
||||
|
||||
namespace MyService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Controller for Sample CRUD operations using CQRS pattern.
|
||||
/// VI: Controller cho các thao tác CRUD Sample sử dụng pattern CQRS.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[ApiVersion("1.0")]
|
||||
[Route("api/v{version:apiVersion}/[controller]")]
|
||||
[Produces("application/json")]
|
||||
public class SamplesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<SamplesController> _logger;
|
||||
|
||||
public SamplesController(IMediator mediator, ILogger<SamplesController> logger)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<SampleViewModel>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSamples()
|
||||
{
|
||||
var samples = await _mediator.Send(new GetSamplesQuery());
|
||||
return Ok(new { success = true, data = samples });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get a sample by ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Sample details / VI: Chi tiết sample</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(SampleViewModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSample(Guid id)
|
||||
{
|
||||
var sample = await _mediator.Send(new GetSampleQuery(id));
|
||||
|
||||
if (sample is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, data = sample });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new sample.
|
||||
/// VI: Tạo một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="request">EN: Create request / VI: Request tạo</param>
|
||||
/// <returns>EN: Created sample ID / VI: ID sample đã tạo</returns>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(CreateSampleCommandResult), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateSample([FromBody] CreateSampleRequest request)
|
||||
{
|
||||
var command = new CreateSampleCommand(request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
return CreatedAtAction(
|
||||
nameof(GetSample),
|
||||
new { id = result.Id },
|
||||
new { success = true, data = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Update request / VI: Request cập nhật</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPut("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateSample(Guid id, [FromBody] UpdateSampleRequest request)
|
||||
{
|
||||
var command = new UpdateSampleCommand(id, request.Name, request.Description);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample updated successfully / Sample đã cập nhật thành công" });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpDelete("{id:guid}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteSample(Guid id)
|
||||
{
|
||||
var command = new DeleteSampleCommand(id);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "SAMPLE_NOT_FOUND",
|
||||
message = $"Sample with ID {id} not found / Sample với ID {id} không tìm thấy"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Change sample status.
|
||||
/// VI: Thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Sample ID / VI: ID sample</param>
|
||||
/// <param name="request">EN: Status change request / VI: Request thay đổi trạng thái</param>
|
||||
/// <returns>EN: Success status / VI: Trạng thái thành công</returns>
|
||||
[HttpPatch("{id:guid}/status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> ChangeSampleStatus(Guid id, [FromBody] ChangeStatusRequest request)
|
||||
{
|
||||
var command = new ChangeSampleStatusCommand(id, request.Status);
|
||||
var result = await _mediator.Send(command);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
return BadRequest(new
|
||||
{
|
||||
success = false,
|
||||
error = new
|
||||
{
|
||||
code = "STATUS_CHANGE_FAILED",
|
||||
message = "Failed to change sample status / Thay đổi trạng thái sample thất bại"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { success = true, message = "Sample status changed successfully / Trạng thái sample đã thay đổi thành công" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for creating a sample.
|
||||
/// VI: Model request để tạo sample.
|
||||
/// </summary>
|
||||
public record CreateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for updating a sample.
|
||||
/// VI: Model request để cập nhật sample.
|
||||
/// </summary>
|
||||
public record UpdateSampleRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Request model for changing sample status.
|
||||
/// VI: Model request để thay đổi trạng thái sample.
|
||||
/// </summary>
|
||||
public record ChangeStatusRequest(string Status);
|
||||
@@ -0,0 +1,50 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MyService.API</AssemblyName>
|
||||
<RootNamespace>MyService.API</RootNamespace>
|
||||
<Description>Web API layer with CQRS pattern</Description>
|
||||
<UserSecretsId>myservice-api</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: MediatR for CQRS / VI: MediatR cho CQRS -->
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
|
||||
<!-- EN: FluentValidation for request validation / VI: FluentValidation cho validation request -->
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
|
||||
<!-- EN: Swagger/OpenAPI / VI: Swagger/OpenAPI -->
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.2.0" />
|
||||
|
||||
<!-- EN: API Versioning / VI: API Versioning -->
|
||||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
|
||||
<!-- EN: Health checks / VI: Health checks -->
|
||||
<PackageReference Include="AspNetCore.HealthChecks.NpgSql" Version="8.0.2" />
|
||||
<PackageReference Include="AspNetCore.HealthChecks.Redis" Version="8.0.1" />
|
||||
|
||||
<!-- EN: Problem Details (RFC 7807) / VI: Problem Details (RFC 7807) -->
|
||||
<PackageReference Include="Hellang.Middleware.ProblemDetails" Version="6.5.1" />
|
||||
|
||||
<!-- EN: Serilog for structured logging / VI: Serilog cho structured logging -->
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.3" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0" />
|
||||
|
||||
<!-- EN: OpenTelemetry Metrics + Prometheus exporter for /metrics endpoint
|
||||
VI: OpenTelemetry Metrics + Prometheus exporter cho endpoint /metrics -->
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyService.Domain\MyService.Domain.csproj" />
|
||||
<ProjectReference Include="..\MyService.Infrastructure\MyService.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,224 @@
|
||||
using Asp.Versioning;
|
||||
using FluentValidation;
|
||||
using Hellang.Middleware.ProblemDetails;
|
||||
using MyService.API.Application.Behaviors;
|
||||
using MyService.Infrastructure;
|
||||
using OpenTelemetry.Metrics;
|
||||
using Serilog;
|
||||
|
||||
// EN: Configure Serilog early / VI: Cấu hình Serilog sớm
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.Console()
|
||||
.CreateBootstrapLogger();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting MyService API / Khởi động MyService API");
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// EN: Configure Serilog / VI: Cấu hình Serilog
|
||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
||||
.ReadFrom.Configuration(context.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console());
|
||||
|
||||
// EN: Add Infrastructure services / VI: Thêm Infrastructure services
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
{
|
||||
cfg.RegisterServicesFromAssemblyContaining<Program>();
|
||||
cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(ValidatorBehavior<,>));
|
||||
cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
|
||||
});
|
||||
|
||||
// EN: Add FluentValidation / VI: Thêm FluentValidation
|
||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||
|
||||
// EN: Add API versioning / VI: Thêm API versioning
|
||||
builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
options.ReportApiVersions = true;
|
||||
options.ApiVersionReader = ApiVersionReader.Combine(
|
||||
new UrlSegmentApiVersionReader(),
|
||||
new HeaderApiVersionReader("X-Api-Version"));
|
||||
})
|
||||
.AddApiExplorer(options =>
|
||||
{
|
||||
options.GroupNameFormat = "'v'VVV";
|
||||
options.SubstituteApiVersionInUrl = true;
|
||||
});
|
||||
|
||||
// EN: Add controllers / VI: Thêm controllers
|
||||
builder.Services.AddControllers();
|
||||
|
||||
// EN: Add ProblemDetails middleware (RFC 7807) with domain exception mappings.
|
||||
// All services MUST map their domain exceptions here so ProblemDetails middleware
|
||||
// handles them before the generic 500 fallback.
|
||||
// VI: Thêm ProblemDetails middleware (RFC 7807) với domain exception mappings.
|
||||
// Mọi service PHẢI map domain exceptions ở đây để ProblemDetails middleware
|
||||
// xử lý chúng trước fallback 500 chung.
|
||||
builder.Services.AddProblemDetails(options =>
|
||||
{
|
||||
options.IncludeExceptionDetails = (ctx, ex) =>
|
||||
builder.Environment.IsDevelopment();
|
||||
|
||||
// EN: Map FluentValidation.ValidationException to 400 BadRequest with field-level errors.
|
||||
// VI: Map FluentValidation.ValidationException sang 400 BadRequest với lỗi theo field.
|
||||
options.Map<FluentValidation.ValidationException>(ex =>
|
||||
{
|
||||
var errors = ex.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(
|
||||
g => g.Key,
|
||||
g => g.Select(e => e.ErrorMessage).ToArray()
|
||||
);
|
||||
|
||||
return new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(errors)
|
||||
{
|
||||
Title = "Validation Error",
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Detail = "One or more validation errors occurred.",
|
||||
Type = "https://httpstatuses.io/400"
|
||||
};
|
||||
});
|
||||
|
||||
// EN: Map DomainException (base) to 422 Unprocessable Entity.
|
||||
// Replace with your specific domain exception types:
|
||||
// e.g. DuplicateResourceException -> 409, EntityNotFoundException -> 404
|
||||
// VI: Map DomainException (base) sang 422 Unprocessable Entity.
|
||||
// Thay bằng các domain exception cụ thể của service:
|
||||
// ví dụ DuplicateResourceException -> 409, EntityNotFoundException -> 404
|
||||
options.Map<MyService.Domain.Exceptions.DomainException>(ex =>
|
||||
new Microsoft.AspNetCore.Mvc.ProblemDetails
|
||||
{
|
||||
Title = "Business Rule Violation",
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Detail = ex.Message,
|
||||
Type = "https://httpstatuses.io/422"
|
||||
});
|
||||
|
||||
// EN: TODO — add service-specific mappings below following this pattern:
|
||||
//
|
||||
// options.Map<MyService.Domain.Exceptions.EntityNotFoundException>(ex =>
|
||||
// new ProblemDetails { Title = "Not Found", Status = 404, Detail = ex.Message,
|
||||
// Type = "https://httpstatuses.io/404" });
|
||||
//
|
||||
// options.Map<MyService.Domain.Exceptions.DuplicateResourceException>(ex =>
|
||||
// new ProblemDetails { Title = "Conflict", Status = 409, Detail = ex.Message,
|
||||
// Type = "https://httpstatuses.io/409" });
|
||||
//
|
||||
// VI: TODO — thêm mappings riêng cho từng service theo pattern này.
|
||||
});
|
||||
|
||||
// EN: Add Swagger / VI: Thêm Swagger
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(options =>
|
||||
{
|
||||
options.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "MyService API",
|
||||
Version = "v1",
|
||||
Description = "MyService microservice API / API microservice MyService"
|
||||
});
|
||||
});
|
||||
|
||||
// EN: Add health checks / VI: Thêm health checks
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(
|
||||
builder.Configuration.GetConnectionString("DefaultConnection")
|
||||
?? builder.Configuration["DATABASE_URL"]
|
||||
?? "",
|
||||
name: "postgresql",
|
||||
tags: ["db", "postgresql"]);
|
||||
|
||||
// EN: Add OpenTelemetry Metrics with Prometheus exporter.
|
||||
// Exposes /metrics endpoint for Prometheus scraping.
|
||||
// Includes ASP.NET Core request metrics and .NET runtime metrics.
|
||||
// VI: Thêm OpenTelemetry Metrics với Prometheus exporter.
|
||||
// Expose endpoint /metrics để Prometheus scrape.
|
||||
// Bao gồm metrics cho ASP.NET Core requests và .NET runtime.
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
.AddPrometheusExporter());
|
||||
|
||||
// EN: Add CORS — restrict to allowed origins. In production, set AllowedOrigins in config
|
||||
// to ["https://goodgo.vn", "https://admin.goodgo.vn"]. Dev fallback is localhost only.
|
||||
// NOTE: Do NOT use AllowAnyOrigin() in any environment — Traefik handles external traffic.
|
||||
// VI: Thêm CORS — giới hạn origins được phép. Trong production, đặt AllowedOrigins trong config
|
||||
// thành ["https://goodgo.vn", "https://admin.goodgo.vn"]. Dev fallback chỉ localhost.
|
||||
// LƯU Ý: KHÔNG dùng AllowAnyOrigin() trong bất kỳ môi trường nào — Traefik xử lý traffic ngoài.
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
{
|
||||
policy.WithOrigins(
|
||||
builder.Configuration.GetSection("AllowedOrigins").Get<string[]>()
|
||||
?? ["http://localhost:3000", "http://localhost:5173", "http://localhost:5000"])
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader();
|
||||
});
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// EN: Configure middleware pipeline / VI: Cấu hình middleware pipeline
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseProblemDetails();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/swagger/v1/swagger.json", "MyService API v1");
|
||||
c.RoutePrefix = "swagger";
|
||||
});
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
app.UseRouting();
|
||||
|
||||
// EN: Map Prometheus metrics scraping endpoint.
|
||||
// Accessible at /metrics — scraped by Prometheus every 15s.
|
||||
// NOTE: This endpoint should be blocked at the API gateway for external traffic.
|
||||
// VI: Map endpoint scraping metrics Prometheus.
|
||||
// Truy cập tại /metrics — Prometheus scrape mỗi 15 giây.
|
||||
// LƯU Ý: Endpoint này nên bị chặn ở API gateway không cho traffic ngoài.
|
||||
app.MapPrometheusScrapingEndpoint("/metrics");
|
||||
|
||||
// EN: Map health check endpoints / VI: Map health check endpoints
|
||||
app.MapHealthChecks("/health");
|
||||
app.MapHealthChecks("/health/live", new()
|
||||
{
|
||||
Predicate = _ => false // EN: Just checks app is running / VI: Chỉ kiểm tra app đang chạy
|
||||
});
|
||||
app.MapHealthChecks("/health/ready");
|
||||
|
||||
// EN: Map controllers / VI: Map controllers
|
||||
app.MapControllers();
|
||||
|
||||
// EN: Run the application / VI: Chạy ứng dụng
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application terminated unexpectedly / Ứng dụng kết thúc bất ngờ");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
|
||||
// EN: Make Program class accessible for integration tests
|
||||
// VI: Làm cho class Program có thể truy cập cho integration tests
|
||||
public partial class Program { }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||
"System": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId"
|
||||
]
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres"
|
||||
},
|
||||
"Redis": {
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "your-super-secret-key-min-32-characters",
|
||||
"Issuer": "goodgo-platform",
|
||||
"Audience": "goodgo-services",
|
||||
"AccessTokenExpiryMinutes": 15,
|
||||
"RefreshTokenExpiryDays": 7
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using MyService.Domain.SeedWork;
|
||||
|
||||
namespace MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository interface for Sample aggregate.
|
||||
/// VI: Interface repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Following repository pattern, this interface defines the contract
|
||||
/// for data access operations on Sample aggregate.
|
||||
/// VI: Theo pattern repository, interface này định nghĩa contract
|
||||
/// cho các thao tác truy cập dữ liệu trên Sample aggregate.
|
||||
/// </remarks>
|
||||
public interface ISampleRepository : IRepository<Sample>
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get a sample by its ID.
|
||||
/// VI: Lấy một sample theo ID.
|
||||
/// </summary>
|
||||
/// <param name="sampleId">EN: The sample ID / VI: ID của sample</param>
|
||||
/// <returns>EN: The sample or null if not found / VI: Sample hoặc null nếu không tìm thấy</returns>
|
||||
Task<Sample?> GetAsync(Guid sampleId);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all samples.
|
||||
/// VI: Lấy tất cả samples.
|
||||
/// </summary>
|
||||
/// <returns>EN: List of samples / VI: Danh sách samples</returns>
|
||||
Task<IEnumerable<Sample>> GetAllAsync();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a new sample.
|
||||
/// VI: Thêm một sample mới.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to add / VI: Sample cần thêm</param>
|
||||
/// <returns>EN: The added sample / VI: Sample đã thêm</returns>
|
||||
Sample Add(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update an existing sample.
|
||||
/// VI: Cập nhật một sample đã tồn tại.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to update / VI: Sample cần cập nhật</param>
|
||||
void Update(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Delete a sample.
|
||||
/// VI: Xóa một sample.
|
||||
/// </summary>
|
||||
/// <param name="sample">EN: The sample to delete / VI: Sample cần xóa</param>
|
||||
void Delete(Sample sample);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get samples by status.
|
||||
/// VI: Lấy samples theo trạng thái.
|
||||
/// </summary>
|
||||
/// <param name="statusId">EN: The status ID / VI: ID trạng thái</param>
|
||||
/// <returns>EN: List of samples with given status / VI: Danh sách samples với trạng thái cho trước</returns>
|
||||
Task<IEnumerable<Sample>> GetByStatusAsync(int statusId);
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using MyService.Domain.Events;
|
||||
using MyService.Domain.Exceptions;
|
||||
using MyService.Domain.SeedWork;
|
||||
|
||||
namespace MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample aggregate root demonstrating DDD patterns.
|
||||
/// VI: Sample aggregate root minh họa các pattern DDD.
|
||||
/// </summary>
|
||||
public class Sample : Entity, IAggregateRoot
|
||||
{
|
||||
// EN: Private fields for encapsulation
|
||||
// VI: Fields private để đóng gói
|
||||
private string _name = null!;
|
||||
private string? _description;
|
||||
private SampleStatus _status = null!;
|
||||
private DateTime _createdAt;
|
||||
private DateTime? _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample name (required).
|
||||
/// VI: Tên sample (bắt buộc).
|
||||
/// </summary>
|
||||
public string Name => _name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Optional description.
|
||||
/// VI: Mô tả tùy chọn.
|
||||
/// </summary>
|
||||
public string? Description => _description;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Current status.
|
||||
/// VI: Trạng thái hiện tại.
|
||||
/// </summary>
|
||||
public SampleStatus Status => _status;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Status ID for EF Core mapping.
|
||||
/// VI: ID trạng thái cho EF Core mapping.
|
||||
/// </summary>
|
||||
public int StatusId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Creation timestamp.
|
||||
/// VI: Thời gian tạo.
|
||||
/// </summary>
|
||||
public DateTime CreatedAt => _createdAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Last update timestamp.
|
||||
/// VI: Thời gian cập nhật cuối.
|
||||
/// </summary>
|
||||
public DateTime? UpdatedAt => _updatedAt;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Private constructor for EF Core.
|
||||
/// VI: Constructor private cho EF Core.
|
||||
/// </summary>
|
||||
protected Sample()
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new Sample with required information.
|
||||
/// VI: Tạo một Sample mới với thông tin bắt buộc.
|
||||
/// </summary>
|
||||
/// <param name="name">EN: Sample name / VI: Tên sample</param>
|
||||
/// <param name="description">EN: Optional description / VI: Mô tả tùy chọn</param>
|
||||
public Sample(string name, string? description = null) : this()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
Id = Guid.NewGuid();
|
||||
_name = name;
|
||||
_description = description;
|
||||
_status = SampleStatus.Draft;
|
||||
StatusId = SampleStatus.Draft.Id;
|
||||
_createdAt = DateTime.UtcNow;
|
||||
|
||||
// EN: Add domain event for creation
|
||||
// VI: Thêm domain event cho việc tạo
|
||||
AddDomainEvent(new SampleCreatedDomainEvent(this));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Update sample information.
|
||||
/// VI: Cập nhật thông tin sample.
|
||||
/// </summary>
|
||||
public void Update(string name, string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new SampleDomainException("Sample name cannot be empty");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Cannot update a cancelled sample");
|
||||
|
||||
_name = name;
|
||||
_description = description;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Activate the sample.
|
||||
/// VI: Kích hoạt sample.
|
||||
/// </summary>
|
||||
public void Activate()
|
||||
{
|
||||
if (_status != SampleStatus.Draft)
|
||||
throw new SampleDomainException("Only draft samples can be activated");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Active;
|
||||
StatusId = SampleStatus.Active.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Complete the sample.
|
||||
/// VI: Hoàn thành sample.
|
||||
/// </summary>
|
||||
public void Complete()
|
||||
{
|
||||
if (_status != SampleStatus.Active)
|
||||
throw new SampleDomainException("Only active samples can be completed");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Completed;
|
||||
StatusId = SampleStatus.Completed.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancel the sample.
|
||||
/// VI: Hủy sample.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
if (_status == SampleStatus.Completed)
|
||||
throw new SampleDomainException("Cannot cancel a completed sample");
|
||||
|
||||
if (_status == SampleStatus.Cancelled)
|
||||
throw new SampleDomainException("Sample is already cancelled");
|
||||
|
||||
var previousStatus = _status;
|
||||
_status = SampleStatus.Cancelled;
|
||||
StatusId = SampleStatus.Cancelled.Id;
|
||||
_updatedAt = DateTime.UtcNow;
|
||||
|
||||
AddDomainEvent(new SampleStatusChangedDomainEvent(Id, previousStatus, _status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using MyService.Domain.SeedWork;
|
||||
|
||||
namespace MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Sample status enumeration following type-safe enum pattern.
|
||||
/// VI: Enumeration trạng thái Sample theo pattern enum an toàn kiểu.
|
||||
/// </summary>
|
||||
public class SampleStatus : Enumeration
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Draft status - initial state
|
||||
/// VI: Trạng thái nháp - trạng thái ban đầu
|
||||
/// </summary>
|
||||
public static SampleStatus Draft = new(1, nameof(Draft));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Active status - ready for use
|
||||
/// VI: Trạng thái hoạt động - sẵn sàng sử dụng
|
||||
/// </summary>
|
||||
public static SampleStatus Active = new(2, nameof(Active));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Completed status - finished processing
|
||||
/// VI: Trạng thái hoàn thành - đã xử lý xong
|
||||
/// </summary>
|
||||
public static SampleStatus Completed = new(3, nameof(Completed));
|
||||
|
||||
/// <summary>
|
||||
/// EN: Cancelled status - cancelled by user
|
||||
/// VI: Trạng thái đã hủy - bị hủy bởi người dùng
|
||||
/// </summary>
|
||||
public static SampleStatus Cancelled = new(4, nameof(Cancelled));
|
||||
|
||||
public SampleStatus(int id, string name) : base(id, name)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all available statuses.
|
||||
/// VI: Lấy tất cả các trạng thái có sẵn.
|
||||
/// </summary>
|
||||
public static IEnumerable<SampleStatus> List() => GetAll<SampleStatus>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from name.
|
||||
/// VI: Parse trạng thái từ tên.
|
||||
/// </summary>
|
||||
public static SampleStatus FromName(string name)
|
||||
{
|
||||
var status = List().SingleOrDefault(s =>
|
||||
string.Equals(s.Name, name, StringComparison.CurrentCultureIgnoreCase));
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse status from ID.
|
||||
/// VI: Parse trạng thái từ ID.
|
||||
/// </summary>
|
||||
public static SampleStatus From(int id)
|
||||
{
|
||||
var status = List().SingleOrDefault(s => s.Id == id);
|
||||
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentException($"Possible values for SampleStatus: {string.Join(",", List().Select(s => s.Name))}");
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when a new Sample is created.
|
||||
/// VI: Domain event được phát ra khi một Sample mới được tạo.
|
||||
/// </summary>
|
||||
public class SampleCreatedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The newly created sample.
|
||||
/// VI: Sample mới được tạo.
|
||||
/// </summary>
|
||||
public Sample Sample { get; }
|
||||
|
||||
public SampleCreatedDomainEvent(Sample sample)
|
||||
{
|
||||
Sample = sample;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using MediatR;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.Domain.Events;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain event raised when Sample status changes.
|
||||
/// VI: Domain event được phát ra khi trạng thái Sample thay đổi.
|
||||
/// </summary>
|
||||
public class SampleStatusChangedDomainEvent : INotification
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The sample ID.
|
||||
/// VI: ID của sample.
|
||||
/// </summary>
|
||||
public Guid SampleId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Previous status before the change.
|
||||
/// VI: Trạng thái trước khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus PreviousStatus { get; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: New status after the change.
|
||||
/// VI: Trạng thái mới sau khi thay đổi.
|
||||
/// </summary>
|
||||
public SampleStatus NewStatus { get; }
|
||||
|
||||
public SampleStatusChangedDomainEvent(
|
||||
Guid sampleId,
|
||||
SampleStatus previousStatus,
|
||||
SampleStatus newStatus)
|
||||
{
|
||||
SampleId = sampleId;
|
||||
PreviousStatus = previousStatus;
|
||||
NewStatus = newStatus;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MyService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base exception for domain errors.
|
||||
/// VI: Exception cơ sở cho các lỗi domain.
|
||||
/// </summary>
|
||||
public class DomainException : Exception
|
||||
{
|
||||
public DomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public DomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MyService.Domain.Exceptions;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Exception for Sample aggregate domain errors.
|
||||
/// VI: Exception cho các lỗi domain của Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleDomainException : DomainException
|
||||
{
|
||||
public SampleDomainException()
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SampleDomainException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MyService.Domain</AssemblyName>
|
||||
<RootNamespace>MyService.Domain</RootNamespace>
|
||||
<Description>Domain layer containing core business logic and entities</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: MediatR for domain events / VI: MediatR cho domain events -->
|
||||
<PackageReference Include="MediatR.Contracts" Version="2.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using MediatR;
|
||||
|
||||
namespace MyService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for all domain entities.
|
||||
/// VI: Lớp cơ sở cho tất cả các entity trong domain.
|
||||
/// </summary>
|
||||
public abstract class Entity
|
||||
{
|
||||
private int? _requestedHashCode;
|
||||
private Guid _id;
|
||||
private List<INotification> _domainEvents = new();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unique identifier for the entity.
|
||||
/// VI: Định danh duy nhất cho entity.
|
||||
/// </summary>
|
||||
public virtual Guid Id
|
||||
{
|
||||
get => _id;
|
||||
protected set => _id = value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Domain events raised by this entity.
|
||||
/// VI: Các domain event được phát ra bởi entity này.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Add a domain event to be dispatched.
|
||||
/// VI: Thêm một domain event để dispatch.
|
||||
/// </summary>
|
||||
public void AddDomainEvent(INotification eventItem)
|
||||
{
|
||||
_domainEvents.Add(eventItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Remove a domain event.
|
||||
/// VI: Xóa một domain event.
|
||||
/// </summary>
|
||||
public void RemoveDomainEvent(INotification eventItem)
|
||||
{
|
||||
_domainEvents.Remove(eventItem);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Clear all domain events.
|
||||
/// VI: Xóa tất cả domain events.
|
||||
/// </summary>
|
||||
public void ClearDomainEvents()
|
||||
{
|
||||
_domainEvents.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if entity is transient (not persisted yet).
|
||||
/// VI: Kiểm tra xem entity có phải là transient (chưa lưu) không.
|
||||
/// </summary>
|
||||
public bool IsTransient()
|
||||
{
|
||||
return Id == default;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not Entity item)
|
||||
return false;
|
||||
|
||||
if (ReferenceEquals(this, item))
|
||||
return true;
|
||||
|
||||
if (GetType() != item.GetType())
|
||||
return false;
|
||||
|
||||
if (item.IsTransient() || IsTransient())
|
||||
return false;
|
||||
|
||||
return item.Id == Id;
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
if (IsTransient())
|
||||
return base.GetHashCode();
|
||||
|
||||
_requestedHashCode ??= Id.GetHashCode() ^ 31;
|
||||
return _requestedHashCode.Value;
|
||||
}
|
||||
|
||||
public static bool operator ==(Entity? left, Entity? right)
|
||||
{
|
||||
return left?.Equals(right) ?? right is null;
|
||||
}
|
||||
|
||||
public static bool operator !=(Entity? left, Entity? right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Reflection;
|
||||
|
||||
namespace MyService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for enumeration classes (type-safe enum pattern).
|
||||
/// VI: Lớp cơ sở cho các lớp enumeration (pattern enum an toàn kiểu).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: This provides a type-safe alternative to enums with additional functionality
|
||||
/// like validation, parsing, and rich behavior.
|
||||
/// VI: Cung cấp một thay thế an toàn kiểu cho enums với các chức năng bổ sung
|
||||
/// như validation, parsing, và hành vi phong phú.
|
||||
/// </remarks>
|
||||
public abstract class Enumeration : IComparable
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The name of the enumeration value.
|
||||
/// VI: Tên của giá trị enumeration.
|
||||
/// </summary>
|
||||
public string Name { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: The unique identifier of the enumeration value.
|
||||
/// VI: Định danh duy nhất của giá trị enumeration.
|
||||
/// </summary>
|
||||
public int Id { get; private set; }
|
||||
|
||||
protected Enumeration(int id, string name) => (Id, Name) = (id, name);
|
||||
|
||||
public override string ToString() => Name;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get all enumeration values of a given type.
|
||||
/// VI: Lấy tất cả các giá trị enumeration của một kiểu cho trước.
|
||||
/// </summary>
|
||||
public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
|
||||
typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
|
||||
.Select(f => f.GetValue(null))
|
||||
.Cast<T>();
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is not Enumeration otherValue)
|
||||
return false;
|
||||
|
||||
var typeMatches = GetType() == obj.GetType();
|
||||
var valueMatches = Id.Equals(otherValue.Id);
|
||||
|
||||
return typeMatches && valueMatches;
|
||||
}
|
||||
|
||||
public override int GetHashCode() => Id.GetHashCode();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Get absolute difference between two enumeration values.
|
||||
/// VI: Lấy sự khác biệt tuyệt đối giữa hai giá trị enumeration.
|
||||
/// </summary>
|
||||
public static int AbsoluteDifference(Enumeration firstValue, Enumeration secondValue)
|
||||
{
|
||||
return Math.Abs(firstValue.Id - secondValue.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse an integer ID to the corresponding enumeration value.
|
||||
/// VI: Parse một ID integer thành giá trị enumeration tương ứng.
|
||||
/// </summary>
|
||||
public static T FromValue<T>(int value) where T : Enumeration
|
||||
{
|
||||
var matchingItem = Parse<T, int>(value, "value", item => item.Id == value);
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Parse a display name to the corresponding enumeration value.
|
||||
/// VI: Parse một tên hiển thị thành giá trị enumeration tương ứng.
|
||||
/// </summary>
|
||||
public static T FromDisplayName<T>(string displayName) where T : Enumeration
|
||||
{
|
||||
var matchingItem = Parse<T, string>(displayName, "display name", item => item.Name == displayName);
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
private static T Parse<T, TValue>(TValue value, string description, Func<T, bool> predicate) where T : Enumeration
|
||||
{
|
||||
var matchingItem = GetAll<T>().FirstOrDefault(predicate);
|
||||
|
||||
if (matchingItem is null)
|
||||
throw new InvalidOperationException($"'{value}' is not a valid {description} in {typeof(T)}");
|
||||
|
||||
return matchingItem;
|
||||
}
|
||||
|
||||
public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MyService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Marker interface for aggregate roots.
|
||||
/// VI: Interface đánh dấu cho aggregate roots.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Aggregate roots are the entry points to aggregates and are the only objects
|
||||
/// that outside code should hold references to.
|
||||
/// VI: Aggregate roots là điểm vào của aggregates và là đối tượng duy nhất
|
||||
/// mà code bên ngoài nên giữ tham chiếu đến.
|
||||
/// </remarks>
|
||||
public interface IAggregateRoot
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace MyService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Generic repository interface for aggregate roots.
|
||||
/// VI: Interface repository generic cho aggregate roots.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">EN: The aggregate root type / VI: Kiểu aggregate root</typeparam>
|
||||
public interface IRepository<T> where T : IAggregateRoot
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: The unit of work for this repository.
|
||||
/// VI: Unit of work cho repository này.
|
||||
/// </summary>
|
||||
IUnitOfWork UnitOfWork { get; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace MyService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of Work pattern interface.
|
||||
/// VI: Interface cho Unit of Work pattern.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Maintains a list of objects affected by a business transaction
|
||||
/// and coordinates the writing out of changes.
|
||||
/// VI: Duy trì danh sách các đối tượng bị ảnh hưởng bởi một transaction nghiệp vụ
|
||||
/// và điều phối việc ghi các thay đổi.
|
||||
/// </remarks>
|
||||
public interface IUnitOfWork : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Save all changes made in this unit of work.
|
||||
/// VI: Lưu tất cả các thay đổi được thực hiện trong unit of work này.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
|
||||
/// <returns>EN: Number of entities written / VI: Số entity đã ghi</returns>
|
||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Save all changes and dispatch domain events.
|
||||
/// VI: Lưu tất cả thay đổi và dispatch domain events.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">EN: Cancellation token / VI: Token hủy</param>
|
||||
/// <returns>EN: True if successful / VI: True nếu thành công</returns>
|
||||
Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace MyService.Domain.SeedWork;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Base class for Value Objects following DDD patterns.
|
||||
/// VI: Lớp cơ sở cho Value Objects theo mẫu DDD.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// EN: Value objects are immutable and compared by their values, not identity.
|
||||
/// VI: Value objects là bất biến và được so sánh theo giá trị, không phải định danh.
|
||||
/// </remarks>
|
||||
public abstract class ValueObject
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Get the atomic values that make up this value object.
|
||||
/// VI: Lấy các giá trị nguyên tử tạo nên value object này.
|
||||
/// </summary>
|
||||
protected abstract IEnumerable<object?> GetEqualityComponents();
|
||||
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
if (obj is null || obj.GetType() != GetType())
|
||||
return false;
|
||||
|
||||
var other = (ValueObject)obj;
|
||||
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
|
||||
}
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
return GetEqualityComponents()
|
||||
.Select(x => x?.GetHashCode() ?? 0)
|
||||
.Aggregate((x, y) => x ^ y);
|
||||
}
|
||||
|
||||
public static bool operator ==(ValueObject? left, ValueObject? right)
|
||||
{
|
||||
return left?.Equals(right) ?? right is null;
|
||||
}
|
||||
|
||||
public static bool operator !=(ValueObject? left, ValueObject? right)
|
||||
{
|
||||
return !(left == right);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a copy of this value object with modifications.
|
||||
/// VI: Tạo bản sao của value object này với các thay đổi.
|
||||
/// </summary>
|
||||
protected ValueObject GetCopy()
|
||||
{
|
||||
return (ValueObject)MemberwiseClone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
using MyService.Infrastructure.Idempotency;
|
||||
using MyService.Infrastructure.Repositories;
|
||||
|
||||
namespace MyService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Dependency injection extensions for Infrastructure layer.
|
||||
/// VI: Extensions dependency injection cho lớp Infrastructure.
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Add infrastructure services to the DI container.
|
||||
/// VI: Thêm các services infrastructure vào DI container.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// EN: Add DbContext with PostgreSQL / VI: Thêm DbContext với PostgreSQL
|
||||
services.AddDbContext<MyServiceContext>(options =>
|
||||
{
|
||||
var connectionString = configuration.GetConnectionString("DefaultConnection")
|
||||
?? configuration["DATABASE_URL"]
|
||||
?? throw new InvalidOperationException("Connection string not configured");
|
||||
|
||||
options.UseNpgsql(connectionString, npgsqlOptions =>
|
||||
{
|
||||
npgsqlOptions.MigrationsAssembly(typeof(MyServiceContext).Assembly.FullName);
|
||||
npgsqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(30),
|
||||
errorCodesToAdd: null);
|
||||
});
|
||||
|
||||
// EN: Enable sensitive data logging in development only
|
||||
// VI: Chỉ bật sensitive data logging trong development
|
||||
if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
|
||||
{
|
||||
options.EnableSensitiveDataLogging();
|
||||
options.EnableDetailedErrors();
|
||||
}
|
||||
});
|
||||
|
||||
// EN: Register repositories / VI: Đăng ký repositories
|
||||
services.AddScoped<ISampleRepository, SampleRepository>();
|
||||
|
||||
// EN: Register idempotency services / VI: Đăng ký idempotency services
|
||||
services.AddScoped<IRequestManager, RequestManager>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for Sample entity.
|
||||
/// VI: Cấu hình EF Core cho entity Sample.
|
||||
/// </summary>
|
||||
public class SampleEntityTypeConfiguration : IEntityTypeConfiguration<Sample>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Sample> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("samples");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
// EN: Ignore domain events (not persisted)
|
||||
// VI: Bỏ qua domain events (không lưu)
|
||||
builder.Ignore(s => s.DomainEvents);
|
||||
|
||||
// EN: Properties / VI: Các thuộc tính
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string>("_name")
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(200)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<string?>("_description")
|
||||
.HasColumnName("description")
|
||||
.HasMaxLength(1000);
|
||||
|
||||
builder.Property<DateTime>("_createdAt")
|
||||
.HasColumnName("created_at")
|
||||
.IsRequired();
|
||||
|
||||
builder.Property<DateTime?>("_updatedAt")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
// EN: Status relationship / VI: Quan hệ với Status
|
||||
builder.Property(s => s.StatusId)
|
||||
.HasColumnName("status_id")
|
||||
.IsRequired();
|
||||
|
||||
builder.HasOne(s => s.Status)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.StatusId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// EN: Indexes / VI: Các index
|
||||
builder.HasIndex("_name");
|
||||
builder.HasIndex(s => s.StatusId);
|
||||
builder.HasIndex("_createdAt");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
|
||||
namespace MyService.Infrastructure.EntityConfigurations;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core configuration for SampleStatus enumeration.
|
||||
/// VI: Cấu hình EF Core cho enumeration SampleStatus.
|
||||
/// </summary>
|
||||
public class SampleStatusEntityTypeConfiguration : IEntityTypeConfiguration<SampleStatus>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SampleStatus> builder)
|
||||
{
|
||||
// EN: Table name / VI: Tên bảng
|
||||
builder.ToTable("sample_statuses");
|
||||
|
||||
// EN: Primary key / VI: Khóa chính
|
||||
builder.HasKey(s => s.Id);
|
||||
|
||||
builder.Property(s => s.Id)
|
||||
.HasColumnName("id")
|
||||
.ValueGeneratedNever()
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Name)
|
||||
.HasColumnName("name")
|
||||
.HasMaxLength(50)
|
||||
.IsRequired();
|
||||
|
||||
// EN: Seed initial data / VI: Seed dữ liệu ban đầu
|
||||
builder.HasData(
|
||||
SampleStatus.Draft,
|
||||
SampleStatus.Active,
|
||||
SampleStatus.Completed,
|
||||
SampleStatus.Cancelled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace MyService.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Entity for tracking client requests to ensure idempotency.
|
||||
/// VI: Entity để theo dõi các requests từ client đảm bảo idempotency.
|
||||
/// </summary>
|
||||
public class ClientRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Unique request identifier.
|
||||
/// VI: Định danh request duy nhất.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// EN: Name of the command/request type.
|
||||
/// VI: Tên của loại command/request.
|
||||
/// </summary>
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Timestamp when the request was received.
|
||||
/// VI: Thời điểm request được nhận.
|
||||
/// </summary>
|
||||
public DateTime Time { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace MyService.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Interface for managing client request idempotency.
|
||||
/// VI: Interface để quản lý idempotency của client requests.
|
||||
/// </summary>
|
||||
public interface IRequestManager
|
||||
{
|
||||
/// <summary>
|
||||
/// EN: Check if a request with the given ID exists.
|
||||
/// VI: Kiểm tra xem request với ID cho trước có tồn tại không.
|
||||
/// </summary>
|
||||
/// <param name="id">EN: Request ID / VI: ID của request</param>
|
||||
/// <returns>EN: True if exists / VI: True nếu tồn tại</returns>
|
||||
Task<bool> ExistAsync(Guid id);
|
||||
|
||||
/// <summary>
|
||||
/// EN: Create a new request record for tracking.
|
||||
/// VI: Tạo bản ghi request mới để theo dõi.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">EN: Command type / VI: Loại command</typeparam>
|
||||
/// <param name="id">EN: Request ID / VI: ID của request</param>
|
||||
Task CreateRequestForCommandAsync<T>(Guid id);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MyService.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Implementation of request manager for idempotency.
|
||||
/// VI: Triển khai request manager cho idempotency.
|
||||
/// </summary>
|
||||
public class RequestManager : IRequestManager
|
||||
{
|
||||
private readonly MyServiceContext _context;
|
||||
|
||||
public RequestManager(MyServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<bool> ExistAsync(Guid id)
|
||||
{
|
||||
var request = await _context
|
||||
.FindAsync<ClientRequest>(id);
|
||||
|
||||
return request != null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task CreateRequestForCommandAsync<T>(Guid id)
|
||||
{
|
||||
var exists = await ExistAsync(id);
|
||||
|
||||
var request = exists
|
||||
? throw new InvalidOperationException($"Request with {id} already exists")
|
||||
: new ClientRequest
|
||||
{
|
||||
Id = id,
|
||||
Name = typeof(T).Name,
|
||||
Time = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_context.Add(request);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MyService.Infrastructure</AssemblyName>
|
||||
<RootNamespace>MyService.Infrastructure</RootNamespace>
|
||||
<Description>Infrastructure layer for data access and external services</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: Entity Framework Core with PostgreSQL / VI: Entity Framework Core với PostgreSQL -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: MediatR for dispatching domain events / VI: MediatR để dispatch domain events -->
|
||||
<PackageReference Include="MediatR" Version="12.4.1" />
|
||||
|
||||
<!-- EN: Dapper for read-optimized queries / VI: Dapper cho queries tối ưu đọc -->
|
||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||
|
||||
<!-- EN: Resilience with Polly / VI: Resilience với Polly -->
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
|
||||
<PackageReference Include="Polly" Version="8.5.0" />
|
||||
|
||||
<!-- EN: Redis cache / VI: Redis cache -->
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.8.16" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MyService.Domain\MyService.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,160 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
using MyService.Domain.SeedWork;
|
||||
using MyService.Infrastructure.EntityConfigurations;
|
||||
|
||||
namespace MyService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// EN: EF Core DbContext for MyService.
|
||||
/// VI: EF Core DbContext cho MyService.
|
||||
/// </summary>
|
||||
public class MyServiceContext : DbContext, IUnitOfWork
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private IDbContextTransaction? _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Samples table.
|
||||
/// VI: Bảng Samples.
|
||||
/// </summary>
|
||||
public DbSet<Sample> Samples => Set<Sample>();
|
||||
|
||||
/// <summary>
|
||||
/// EN: Read-only access to current transaction.
|
||||
/// VI: Truy cập chỉ đọc đến transaction hiện tại.
|
||||
/// </summary>
|
||||
public IDbContextTransaction? CurrentTransaction => _currentTransaction;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Check if there is an active transaction.
|
||||
/// VI: Kiểm tra xem có transaction đang hoạt động không.
|
||||
/// </summary>
|
||||
public bool HasActiveTransaction => _currentTransaction != null;
|
||||
|
||||
public MyServiceContext(DbContextOptions<MyServiceContext> options) : base(options)
|
||||
{
|
||||
_mediator = null!;
|
||||
}
|
||||
|
||||
public MyServiceContext(DbContextOptions<MyServiceContext> options, IMediator mediator) : base(options)
|
||||
{
|
||||
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
|
||||
|
||||
System.Diagnostics.Debug.WriteLine("MyServiceContext::ctor - " + GetHashCode());
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
// EN: Apply entity configurations
|
||||
// VI: Áp dụng các cấu hình entity
|
||||
modelBuilder.ApplyConfiguration(new SampleEntityTypeConfiguration());
|
||||
modelBuilder.ApplyConfiguration(new SampleStatusEntityTypeConfiguration());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Save entities and dispatch domain events.
|
||||
/// VI: Lưu entities và dispatch domain events.
|
||||
/// </summary>
|
||||
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// EN: Dispatch domain events before saving (side effects)
|
||||
// VI: Dispatch domain events trước khi lưu (side effects)
|
||||
await DispatchDomainEventsAsync();
|
||||
|
||||
// EN: Save changes to database
|
||||
// VI: Lưu thay đổi vào database
|
||||
await base.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Begin a new transaction if none is active.
|
||||
/// VI: Bắt đầu một transaction mới nếu không có transaction nào đang hoạt động.
|
||||
/// </summary>
|
||||
public async Task<IDbContextTransaction?> BeginTransactionAsync()
|
||||
{
|
||||
if (_currentTransaction != null) return null;
|
||||
|
||||
_currentTransaction = await Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted);
|
||||
|
||||
return _currentTransaction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Commit the current transaction.
|
||||
/// VI: Commit transaction hiện tại.
|
||||
/// </summary>
|
||||
public async Task CommitTransactionAsync(IDbContextTransaction transaction)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(transaction);
|
||||
|
||||
if (transaction != _currentTransaction)
|
||||
throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current");
|
||||
|
||||
try
|
||||
{
|
||||
await SaveChangesAsync();
|
||||
await transaction.CommitAsync();
|
||||
}
|
||||
catch
|
||||
{
|
||||
RollbackTransaction();
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_currentTransaction != null)
|
||||
{
|
||||
_currentTransaction.Dispose();
|
||||
_currentTransaction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Rollback the current transaction.
|
||||
/// VI: Rollback transaction hiện tại.
|
||||
/// </summary>
|
||||
public void RollbackTransaction()
|
||||
{
|
||||
try
|
||||
{
|
||||
_currentTransaction?.Rollback();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (_currentTransaction != null)
|
||||
{
|
||||
_currentTransaction.Dispose();
|
||||
_currentTransaction = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Dispatch all domain events from tracked entities.
|
||||
/// VI: Dispatch tất cả domain events từ các entities đang được track.
|
||||
/// </summary>
|
||||
private async Task DispatchDomainEventsAsync()
|
||||
{
|
||||
var domainEntities = ChangeTracker
|
||||
.Entries<Entity>()
|
||||
.Where(x => x.Entity.DomainEvents.Any())
|
||||
.ToList();
|
||||
|
||||
var domainEvents = domainEntities
|
||||
.SelectMany(x => x.Entity.DomainEvents)
|
||||
.ToList();
|
||||
|
||||
domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents());
|
||||
|
||||
foreach (var domainEvent in domainEvents)
|
||||
{
|
||||
await _mediator.Publish(domainEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
using MyService.Domain.SeedWork;
|
||||
|
||||
namespace MyService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Repository implementation for Sample aggregate.
|
||||
/// VI: Triển khai repository cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleRepository : ISampleRepository
|
||||
{
|
||||
private readonly MyServiceContext _context;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit of work for transaction management.
|
||||
/// VI: Unit of work cho quản lý transaction.
|
||||
/// </summary>
|
||||
public IUnitOfWork UnitOfWork => _context;
|
||||
|
||||
public SampleRepository(MyServiceContext context)
|
||||
{
|
||||
_context = context ?? throw new ArgumentNullException(nameof(context));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Sample?> GetAsync(Guid sampleId)
|
||||
{
|
||||
var sample = await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.FirstOrDefaultAsync(s => s.Id == sampleId);
|
||||
|
||||
return sample;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetAllAsync()
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Sample Add(Sample sample)
|
||||
{
|
||||
return _context.Samples.Add(sample).Entity;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Update(Sample sample)
|
||||
{
|
||||
_context.Entry(sample).State = EntityState.Modified;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Delete(Sample sample)
|
||||
{
|
||||
_context.Samples.Remove(sample);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IEnumerable<Sample>> GetByStatusAsync(int statusId)
|
||||
{
|
||||
return await _context.Samples
|
||||
.Include(s => s.Status)
|
||||
.Where(s => s.StatusId == statusId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Xunit;
|
||||
|
||||
namespace MyService.FunctionalTests.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Functional tests for Samples API endpoints.
|
||||
/// VI: Functional tests cho các endpoints API Samples.
|
||||
/// </summary>
|
||||
public class SamplesControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
{
|
||||
private readonly HttpClient _client;
|
||||
|
||||
public SamplesControllerTests(CustomWebApplicationFactory factory)
|
||||
{
|
||||
_client = factory.CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSamples_ShouldReturnOkWithEmptyList()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/api/v1/samples");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var content = await response.Content.ReadFromJsonAsync<ApiResponse<List<object>>>();
|
||||
content?.Success.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateSample_WithValidData_ShouldReturnCreated()
|
||||
{
|
||||
// Arrange
|
||||
var request = new { Name = "Test Sample", Description = "Test Description" };
|
||||
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/samples", request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var content = await response.Content.ReadFromJsonAsync<ApiResponse<CreateSampleResult>>();
|
||||
content?.Success.Should().BeTrue();
|
||||
content?.Data?.Id.Should().NotBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSample_WithInvalidId_ShouldReturnNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var invalidId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var response = await _client.GetAsync($"/api/v1/samples/{invalidId}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthCheck_ShouldReturnHealthy()
|
||||
{
|
||||
// Act
|
||||
var response = await _client.GetAsync("/health/live");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
// EN: Helper DTOs for deserialization
|
||||
// VI: Helper DTOs để deserialize
|
||||
private record ApiResponse<T>(bool Success, T? Data);
|
||||
private record CreateSampleResult(Guid Id);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MyService.Infrastructure;
|
||||
|
||||
namespace MyService.FunctionalTests;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Custom WebApplicationFactory for functional tests.
|
||||
/// VI: WebApplicationFactory tùy chỉnh cho functional tests.
|
||||
/// </summary>
|
||||
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
{
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseEnvironment("Testing");
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// EN: Remove the existing DbContext registration
|
||||
// VI: Xóa đăng ký DbContext hiện tại
|
||||
var descriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(DbContextOptions<MyServiceContext>));
|
||||
|
||||
if (descriptor != null)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
// EN: Remove DbContext service
|
||||
// VI: Xóa DbContext service
|
||||
var dbContextDescriptor = services.SingleOrDefault(
|
||||
d => d.ServiceType == typeof(MyServiceContext));
|
||||
|
||||
if (dbContextDescriptor != null)
|
||||
{
|
||||
services.Remove(dbContextDescriptor);
|
||||
}
|
||||
|
||||
// EN: Add in-memory database for testing
|
||||
// VI: Thêm in-memory database để test
|
||||
services.AddDbContext<MyServiceContext>(options =>
|
||||
{
|
||||
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
|
||||
});
|
||||
|
||||
// EN: Ensure database is created with seed data
|
||||
// VI: Đảm bảo database được tạo với seed data
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MyServiceContext>();
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MyService.FunctionalTests</AssemblyName>
|
||||
<RootNamespace>MyService.FunctionalTests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: Test framework / VI: Test framework -->
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: Integration testing / VI: Integration testing -->
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
|
||||
<!-- EN: Test containers for database / VI: Test containers cho database -->
|
||||
<PackageReference Include="Testcontainers.PostgreSql" Version="4.1.0" />
|
||||
|
||||
<!-- EN: Coverage / VI: Coverage -->
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MyService.API\MyService.API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,65 @@
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using MyService.API.Application.Commands;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
using MyService.Domain.SeedWork;
|
||||
using Xunit;
|
||||
|
||||
namespace MyService.UnitTests.Application;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for CreateSampleCommandHandler.
|
||||
/// VI: Unit tests cho CreateSampleCommandHandler.
|
||||
/// </summary>
|
||||
public class CreateSampleCommandHandlerTests
|
||||
{
|
||||
private readonly Mock<ISampleRepository> _mockRepository;
|
||||
private readonly Mock<ILogger<CreateSampleCommandHandler>> _mockLogger;
|
||||
private readonly CreateSampleCommandHandler _handler;
|
||||
|
||||
public CreateSampleCommandHandlerTests()
|
||||
{
|
||||
_mockRepository = new Mock<ISampleRepository>();
|
||||
_mockLogger = new Mock<ILogger<CreateSampleCommandHandler>>();
|
||||
|
||||
var mockUnitOfWork = new Mock<IUnitOfWork>();
|
||||
mockUnitOfWork.Setup(u => u.SaveEntitiesAsync(It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(true);
|
||||
|
||||
_mockRepository.SetupGet(r => r.UnitOfWork).Returns(mockUnitOfWork.Object);
|
||||
|
||||
_handler = new CreateSampleCommandHandler(_mockRepository.Object, _mockLogger.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCreateSampleAndReturnId()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", "Test Description");
|
||||
|
||||
_mockRepository.Setup(r => r.Add(It.IsAny<Sample>()))
|
||||
.Returns((Sample s) => s);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Id.Should().NotBeEmpty();
|
||||
_mockRepository.Verify(r => r.Add(It.IsAny<Sample>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithValidCommand_ShouldCallSaveEntities()
|
||||
{
|
||||
// Arrange
|
||||
var command = new CreateSampleCommand("Test Sample", null);
|
||||
|
||||
// Act
|
||||
await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
_mockRepository.Verify(r => r.UnitOfWork.SaveEntitiesAsync(It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using FluentAssertions;
|
||||
using MyService.Domain.AggregatesModel.SampleAggregate;
|
||||
using MyService.Domain.Exceptions;
|
||||
using Xunit;
|
||||
|
||||
namespace MyService.UnitTests.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// EN: Unit tests for Sample aggregate.
|
||||
/// VI: Unit tests cho Sample aggregate.
|
||||
/// </summary>
|
||||
public class SampleAggregateTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateSample_WithValidName_ShouldCreateWithDraftStatus()
|
||||
{
|
||||
// Arrange
|
||||
var name = "Test Sample";
|
||||
var description = "Test Description";
|
||||
|
||||
// Act
|
||||
var sample = new Sample(name, description);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(name);
|
||||
sample.Description.Should().Be(description);
|
||||
sample.Status.Should().Be(SampleStatus.Draft);
|
||||
sample.Id.Should().NotBeEmpty();
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleCreatedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateSample_WithEmptyName_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var name = "";
|
||||
|
||||
// Act
|
||||
var act = () => new Sample(name);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Sample name cannot be empty");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenDraft_ShouldChangeToActive()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Activate();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Active);
|
||||
sample.DomainEvents.Should().ContainSingle(); // SampleStatusChangedDomainEvent
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Activate_WhenNotDraft_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Activate();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Only draft samples can be activated");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Complete_WhenActive_ShouldChangeToCompleted()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.ClearDomainEvents();
|
||||
|
||||
// Act
|
||||
sample.Complete();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenDraftOrActive_ShouldChangeToCancelled()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
|
||||
// Act
|
||||
sample.Cancel();
|
||||
|
||||
// Assert
|
||||
sample.Status.Should().Be(SampleStatus.Cancelled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cancel_WhenCompleted_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Activate();
|
||||
sample.Complete();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Cancel();
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot cancel a completed sample");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenNotCancelled_ShouldUpdateNameAndDescription()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Original Name", "Original Description");
|
||||
var newName = "Updated Name";
|
||||
var newDescription = "Updated Description";
|
||||
|
||||
// Act
|
||||
sample.Update(newName, newDescription);
|
||||
|
||||
// Assert
|
||||
sample.Name.Should().Be(newName);
|
||||
sample.Description.Should().Be(newDescription);
|
||||
sample.UpdatedAt.Should().NotBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Update_WhenCancelled_ShouldThrowException()
|
||||
{
|
||||
// Arrange
|
||||
var sample = new Sample("Test Sample");
|
||||
sample.Cancel();
|
||||
|
||||
// Act
|
||||
var act = () => sample.Update("New Name", null);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<SampleDomainException>()
|
||||
.WithMessage("Cannot update a cancelled sample");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<AssemblyName>MyService.UnitTests</AssemblyName>
|
||||
<RootNamespace>MyService.UnitTests</RootNamespace>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EN: Test framework / VI: Test framework -->
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
|
||||
<!-- EN: Assertions and mocking / VI: Assertions và mocking -->
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.2" />
|
||||
<PackageReference Include="Moq" Version="4.20.72" />
|
||||
|
||||
<!-- EN: Coverage / VI: Coverage -->
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\MyService.Domain\MyService.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\src\MyService.API\MyService.API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
552
microservices/services/_template_nodejs/ARCHITECTURE.en.md
Normal file
552
microservices/services/_template_nodejs/ARCHITECTURE.en.md
Normal 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
|
||||
552
microservices/services/_template_nodejs/ARCHITECTURE.vi.md
Normal file
552
microservices/services/_template_nodejs/ARCHITECTURE.vi.md
Normal 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
|
||||
114
microservices/services/_template_nodejs/Dockerfile
Normal file
114
microservices/services/_template_nodejs/Dockerfile
Normal 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"]
|
||||
962
microservices/services/_template_nodejs/README.md
Normal file
962
microservices/services/_template_nodejs/README.md
Normal 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
|
||||
37
microservices/services/_template_nodejs/eslint.config.js
Normal file
37
microservices/services/_template_nodejs/eslint.config.js
Normal 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
|
||||
},
|
||||
},
|
||||
];
|
||||
40
microservices/services/_template_nodejs/jest.config.ts
Normal file
40
microservices/services/_template_nodejs/jest.config.ts
Normal 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;
|
||||
68
microservices/services/_template_nodejs/package.json
Normal file
68
microservices/services/_template_nodejs/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"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": "pnpm prisma:generate && jest --testPathIgnorePatterns='src/__tests__/feature.e2e.ts|src/modules/feature/__tests__/feature.repository.test.ts|src/modules/health/__tests__/health.controller.test.ts'",
|
||||
"test:all": "pnpm prisma:generate && 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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
48
microservices/services/_template_nodejs/prisma/schema.prisma
Normal file
48
microservices/services/_template_nodejs/prisma/schema.prisma
Normal 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")
|
||||
}
|
||||
111
microservices/services/_template_nodejs/prisma/seed.ts
Normal file
111
microservices/services/_template_nodejs/prisma/seed.ts
Normal 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();
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
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(),
|
||||
},
|
||||
}), { virtual: true });
|
||||
|
||||
// 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(),
|
||||
}), { virtual: true });
|
||||
|
||||
// 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(),
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
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(mockApp.use).toHaveBeenCalled();
|
||||
expect(mockApp.get).toHaveBeenCalledWith('/docs.json', expect.any(Function));
|
||||
expect(mockApp.get).toHaveBeenCalledWith('/docs.yaml', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
364
microservices/services/_template_nodejs/src/docs/swagger.ts
Normal file
364
microservices/services/_template_nodejs/src/docs/swagger.ts
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
161
microservices/services/_template_nodejs/src/errors/http-error.ts
Normal file
161
microservices/services/_template_nodejs/src/errors/http-error.ts
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
26
microservices/services/_template_nodejs/src/errors/index.ts
Normal file
26
microservices/services/_template_nodejs/src/errors/index.ts
Normal 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';
|
||||
134
microservices/services/_template_nodejs/src/main.ts
Normal file
134
microservices/services/_template_nodejs/src/main.ts
Normal 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();
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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-42d3-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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,276 @@
|
||||
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';
|
||||
|
||||
const getHeaderValue = (
|
||||
headers: Request['headers'],
|
||||
headerName: string
|
||||
): string | undefined => {
|
||||
const normalized = headerName.toLowerCase();
|
||||
const directValue = headers[normalized];
|
||||
if (typeof directValue === 'string') {
|
||||
return directValue;
|
||||
}
|
||||
if (Array.isArray(directValue) && directValue.length > 0) {
|
||||
return directValue[0];
|
||||
}
|
||||
|
||||
const rawHeaderKey = Object.keys(headers).find(key => key.toLowerCase() === normalized);
|
||||
if (!rawHeaderKey) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawValue = headers[rawHeaderKey];
|
||||
if (typeof rawValue === 'string') {
|
||||
return rawValue;
|
||||
}
|
||||
if (Array.isArray(rawValue) && rawValue.length > 0) {
|
||||
return rawValue[0];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = getHeaderValue(req.headers, headerName) || 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 = getHeaderValue(req.headers, headerName);
|
||||
|
||||
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();
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user