feat(docs): Enhance Vietnamese documentation with new sections and updates

- Added new sections on API Design, Caching Patterns, and Testing Patterns to the Vietnamese documentation.
- Updated sidebar configurations for improved navigation and accessibility.
- Removed outdated onboarding guides to streamline content and focus on relevant resources.
This commit is contained in:
Ho Ngoc Hai
2026-01-12 13:36:53 +07:00
parent c046ed0a06
commit 07f96a8eb2
58 changed files with 4291 additions and 0 deletions

View 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
services/iam-service-net/.gitignore vendored Normal file
View 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
~$*

View File

@@ -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>

View 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/IamService.API/IamService.API.csproj", "src/IamService.API/"]
COPY ["src/IamService.Domain/IamService.Domain.csproj", "src/IamService.Domain/"]
COPY ["src/IamService.Infrastructure/IamService.Infrastructure.csproj", "src/IamService.Infrastructure/"]
COPY ["Directory.Build.props", "./"]
# EN: Restore dependencies
# VI: Khôi phục dependencies
RUN dotnet restore "src/IamService.API/IamService.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/IamService.API"
RUN dotnet build "IamService.API.csproj" -c Release -o /app/build --no-restore
# Publish stage / Giai đoạn publish
FROM build AS publish
RUN dotnet publish "IamService.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", "IamService.API.dll"]

View File

@@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/IamService.API/IamService.API.csproj" />
<Project Path="src/IamService.Domain/IamService.Domain.csproj" />
<Project Path="src/IamService.Infrastructure/IamService.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/IamService.FunctionalTests/IamService.FunctionalTests.csproj" />
<Project Path="tests/IamService.UnitTests/IamService.UnitTests.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,166 @@
# IAM Service .NET
> Identity and Access Management Service built with .NET 10, ASP.NET Core Identity, and OpenIddict.
## Overview
IAM Service provides OAuth2/OpenID Connect authentication and authorization capabilities:
- **User Management**: Registration, profile management, account locking
- **Role-Based Access Control (RBAC)**: Role assignment, permission management
- **OAuth2 Token Endpoints**: Password, Refresh Token, Client Credentials grants
- **JWT Tokens**: Access tokens (15 min), Refresh tokens (7 days)
## Tech Stack
| Technology | Purpose |
|------------|---------|
| .NET 10 | Runtime |
| ASP.NET Core Identity | User/Role management |
| OpenIddict | OAuth2/OIDC server |
| EF Core + PostgreSQL | Data persistence |
| MediatR | CQRS pattern |
| FluentValidation | Request validation |
| Serilog | Structured logging |
## Quick Start
### 1. Prerequisites
- .NET SDK 10.0.101+
- Docker (for PostgreSQL)
### 2. Setup Environment
```bash
cp .env.example .env
# Edit DATABASE_URL in .env
```
### 3. Run with Docker Compose
```bash
docker-compose up -d
```
Service available at: `http://localhost:5001`
### 4. Run Locally
```bash
dotnet restore
dotnet build
dotnet run --project src/IamService.API
```
## API Endpoints
### Authentication
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/auth/register` | Register new user |
| POST | `/connect/token` | OAuth2 token endpoint |
### Token Request (Password Grant)
```bash
curl -X POST http://localhost:5001/connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&username=user@example.com&password=Password123!"
```
### Users (Protected)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/users` | List users (paginated) |
| GET | `/api/v1/users/me` | Get current user |
### Health Checks
| Endpoint | Purpose |
|----------|---------|
| `/health` | Full health status |
| `/health/live` | Liveness probe |
| `/health/ready` | Readiness probe |
## Project Structure
```
iam-service-net/
├── src/
│ ├── IamService.API/ # Controllers, CQRS
│ │ ├── Controllers/ # AuthController, UsersController
│ │ └── Application/ # Commands, Queries, Validations
│ ├── IamService.Domain/ # Domain entities
│ │ ├── AggregatesModel/ # UserAggregate, RoleAggregate
│ │ ├── Events/ # Domain events
│ │ └── Exceptions/ # Domain exceptions
│ └── IamService.Infrastructure/ # Data access
│ ├── IamServiceContext.cs # DbContext with Identity
│ └── Repositories/ # Repository implementations
├── tests/
│ ├── IamService.UnitTests/
│ └── IamService.FunctionalTests/
├── Dockerfile
└── docker-compose.yml
```
## Configuration
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `ASPNETCORE_ENVIRONMENT` | Environment | Development |
| `DATABASE_URL` | PostgreSQL connection | - |
| `REDIS_URL` | Redis connection | - |
### Password Policy
- Minimum 8 characters
- Requires uppercase, lowercase, digit, special character
### Token Lifetimes
| Token | Lifetime |
|-------|----------|
| Access Token | 15 minutes |
| Refresh Token | 7 days |
## Development
```bash
# Restore dependencies
dotnet restore
# Build
dotnet build
# Run tests
dotnet test
# Run API
dotnet run --project src/IamService.API
```
## Docker
```bash
# Build image
docker build -t iam-service:latest .
# Run container
docker run -p 5001:8080 --env-file .env iam-service:latest
```
## Resources
- [OpenIddict Documentation](https://documentation.openiddict.com/)
- [ASP.NET Core Identity](https://docs.microsoft.com/en-us/aspnet/core/security/authentication/identity)
- [eShopOnContainers](https://github.com/dotnet-architecture/eShopOnContainers)
## License
Proprietary - GoodGo Platform

View File

@@ -0,0 +1,72 @@
version: '3.8'
# EN: Docker Compose for local development
# VI: Docker Compose cho phát triển local
services:
iamservice-api:
build:
context: .
dockerfile: Dockerfile
container_name: iamservice-api
ports:
- "5001:8080"
environment:
- ASPNETCORE_ENVIRONMENT=Development
- DATABASE_URL=Host=postgres;Port=5432;Database=iamservice_db;Username=postgres;Password=postgres
- REDIS_URL=redis:6379
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- iamservice-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: iamservice-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: iamservice_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- iamservice-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: iamservice-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
networks:
- iamservice-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
redis_data:
networks:
iamservice-network:
driver: bridge

View 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 (IamService.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 (IamService.Infrastructure)
Technical implementations and external concerns:
- Database access (EF Core)
- Repository implementations
- External service integrations
### 3. API Layer (IamService.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:
iamservice-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: iamservice-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: iamservice: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)

View 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 "IamService" to "YourService"
find . -type f -name "*.cs" -exec sed -i '' 's/IamService/YourService/g' {} +
find . -type f -name "*.csproj" -exec sed -i '' 's/IamService/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 iamservice-api
```
### 4. Run Locally
```bash
# Restore dependencies
dotnet restore
# Build all projects
dotnet build
# Run the API
dotnet run --project src/IamService.API
```
## Project Structure
```
_template_dot_net/
├── src/
│ ├── IamService.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
│ │
│ ├── IamService.Domain/ # Domain Layer (Pure business logic)
│ │ ├── AggregatesModel/ # Aggregate roots and entities
│ │ ├── Events/ # Domain events
│ │ ├── Exceptions/ # Domain exceptions
│ │ └── SeedWork/ # Base classes (Entity, ValueObject, etc.)
│ │
│ └── IamService.Infrastructure/ # Infrastructure Layer (Data access)
│ ├── EntityConfigurations/ # EF Core Fluent API configurations
│ ├── Repositories/ # Repository implementations
│ ├── Idempotency/ # Request idempotency handling
│ └── IamServiceContext.cs # DbContext with Unit of Work
├── tests/
│ ├── IamService.UnitTests/ # Unit tests (Domain, Application)
│ └── IamService.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/IamService.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=iamservice;Username=postgres;Password=postgres"
},
"Serilog": {
"MinimumLevel": "Information"
}
}
```
## Deployment
### Docker Build
```bash
# Build Docker image
docker build -t iamservice:latest .
# Run container
docker run -p 5000:8080 --env-file .env iamservice: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

View 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 (IamService.Domain)
Trái tim của ứng dụng chứa business logic thuần túy. Lớp này:
-**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 (IamService.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 (IamService.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:
iamservice-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: iamservice-api
spec:
replicas: 3
template:
spec:
containers:
- name: api
image: iamservice: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)

View 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ả "IamService" thành "YourService"
find . -type f -name "*.cs" -exec sed -i '' 's/IamService/YourService/g' {} +
find . -type f -name "*.csproj" -exec sed -i '' 's/IamService/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 iamservice-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/IamService.API
```
## Cấu Trúc Dự Án
```
_template_dot_net/
├── src/
│ ├── IamService.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
│ │
│ ├── IamService.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.)
│ │
│ └── IamService.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
│ └── IamServiceContext.cs # DbContext với Unit of Work
├── tests/
│ ├── IamService.UnitTests/ # Unit tests (Domain, Application)
│ └── IamService.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/IamService.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=iamservice;Username=postgres;Password=postgres"
},
"Serilog": {
"MinimumLevel": "Information"
}
}
```
## Triển Khai
### Docker Build
```bash
# Build Docker image
docker build -t iamservice:latest .
# Chạy container
docker run -p 5000:8080 --env-file .env iamservice: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

View File

@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.101",
"rollForward": "latestMinor",
"allowPrerelease": false
}
}

View File

@@ -0,0 +1,58 @@
using System.Diagnostics;
using MediatR;
namespace IamService.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;
}
}
}

View File

@@ -0,0 +1,84 @@
using MediatR;
using Microsoft.EntityFrameworkCore;
using IamService.Infrastructure;
namespace IamService.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 IamServiceContext _dbContext;
private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;
public TransactionBehavior(
IamServiceContext 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);
await _dbContext.RollbackTransactionAsync();
throw;
}
});
}
}

View File

@@ -0,0 +1,63 @@
using FluentValidation;
using MediatR;
namespace IamService.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();
}
}

View File

@@ -0,0 +1,24 @@
using MediatR;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Command to register a new user.
/// VI: Command để đăng ký user mới.
/// </summary>
public record RegisterUserCommand(
string Email,
string Password,
string FirstName,
string LastName
) : IRequest<RegisterUserCommandResult>;
/// <summary>
/// EN: Result of user registration.
/// VI: Kết quả đăng ký user.
/// </summary>
public record RegisterUserCommandResult(
Guid UserId,
string Email,
string FullName
);

View File

@@ -0,0 +1,71 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.Events;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Handler for RegisterUserCommand.
/// VI: Handler cho RegisterUserCommand.
/// </summary>
public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand, RegisterUserCommandResult>
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<RegisterUserCommandHandler> _logger;
public RegisterUserCommandHandler(
UserManager<ApplicationUser> userManager,
ILogger<RegisterUserCommandHandler> logger)
{
_userManager = userManager ?? throw new ArgumentNullException(nameof(userManager));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<RegisterUserCommandResult> Handle(
RegisterUserCommand request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Registering new user with email {Email}",
request.Email);
// EN: Check if user already exists
// VI: Kiểm tra xem user đã tồn tại chưa
var existingUser = await _userManager.FindByEmailAsync(request.Email);
if (existingUser != null)
{
_logger.LogWarning("User with email {Email} already exists", request.Email);
throw new InvalidOperationException($"User with email {request.Email} already exists");
}
// EN: Create new user
// VI: Tạo user mới
var user = new ApplicationUser(request.Email, request.FirstName, request.LastName);
// EN: Add domain event
// VI: Thêm domain event
user.AddDomainEvent(new UserRegisteredDomainEvent(user));
// EN: Create user with password
// VI: Tạo user với password
var result = await _userManager.CreateAsync(user, request.Password);
if (!result.Succeeded)
{
var errors = string.Join(", ", result.Errors.Select(e => e.Description));
_logger.LogWarning("Failed to create user: {Errors}", errors);
throw new InvalidOperationException($"Failed to create user: {errors}");
}
_logger.LogInformation(
"Successfully registered user {UserId} with email {Email}",
user.Id, user.Email);
return new RegisterUserCommandResult(
user.Id,
user.Email!,
user.FullName);
}
}

View File

@@ -0,0 +1,38 @@
using MediatR;
namespace IamService.API.Application.Queries.Users;
/// <summary>
/// EN: Query to get users with pagination.
/// VI: Query để lấy danh sách users với phân trang.
/// </summary>
public record GetUsersQuery(
int PageNumber = 1,
int PageSize = 10
) : IRequest<GetUsersQueryResult>;
/// <summary>
/// EN: Result of get users query.
/// VI: Kết quả query lấy users.
/// </summary>
public record GetUsersQueryResult(
IEnumerable<UserViewModel> Users,
int TotalCount,
int PageNumber,
int PageSize
);
/// <summary>
/// EN: User view model for queries.
/// VI: User view model cho queries.
/// </summary>
public record UserViewModel(
Guid Id,
string Email,
string FirstName,
string LastName,
string FullName,
string Status,
DateTime CreatedAt,
DateTime? LastLoginAt
);

View File

@@ -0,0 +1,53 @@
using MediatR;
using Microsoft.Extensions.Logging;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.API.Application.Queries.Users;
/// <summary>
/// EN: Handler for GetUsersQuery.
/// VI: Handler cho GetUsersQuery.
/// </summary>
public class GetUsersQueryHandler : IRequestHandler<GetUsersQuery, GetUsersQueryResult>
{
private readonly IUserRepository _userRepository;
private readonly ILogger<GetUsersQueryHandler> _logger;
public GetUsersQueryHandler(
IUserRepository userRepository,
ILogger<GetUsersQueryHandler> logger)
{
_userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<GetUsersQueryResult> Handle(
GetUsersQuery request,
CancellationToken cancellationToken)
{
_logger.LogInformation(
"Getting users page {PageNumber} with size {PageSize}",
request.PageNumber, request.PageSize);
var (users, totalCount) = await _userRepository.GetAllAsync(
request.PageNumber,
request.PageSize,
cancellationToken);
var userViewModels = users.Select(u => new UserViewModel(
u.Id,
u.Email!,
u.FirstName,
u.LastName,
u.FullName,
u.Status.Name,
u.CreatedAt,
u.LastLoginAt));
return new GetUsersQueryResult(
userViewModels,
totalCount,
request.PageNumber,
request.PageSize);
}
}

View File

@@ -0,0 +1,33 @@
using FluentValidation;
namespace IamService.API.Application.Commands.Auth;
/// <summary>
/// EN: Validator for RegisterUserCommand.
/// VI: Validator cho RegisterUserCommand.
/// </summary>
public class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
{
public RegisterUserCommandValidator()
{
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(x => x.Password)
.NotEmpty().WithMessage("Password is required")
.MinimumLength(8).WithMessage("Password must be at least 8 characters")
.Matches("[A-Z]").WithMessage("Password must contain at least one uppercase letter")
.Matches("[a-z]").WithMessage("Password must contain at least one lowercase letter")
.Matches("[0-9]").WithMessage("Password must contain at least one digit")
.Matches("[^a-zA-Z0-9]").WithMessage("Password must contain at least one special character");
RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("First name is required")
.MaximumLength(100).WithMessage("First name cannot exceed 100 characters");
RuleFor(x => x.LastName)
.NotEmpty().WithMessage("Last name is required")
.MaximumLength(100).WithMessage("Last name cannot exceed 100 characters");
}
}

View File

@@ -0,0 +1,253 @@
using System.Security.Claims;
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Abstractions;
using OpenIddict.Server.AspNetCore;
using IamService.API.Application.Commands.Auth;
using IamService.Domain.AggregatesModel.UserAggregate;
using static OpenIddict.Abstractions.OpenIddictConstants;
namespace IamService.API.Controllers;
/// <summary>
/// EN: Authentication controller with OpenIddict OAuth2/OIDC endpoints.
/// VI: Controller xác thực với OpenIddict OAuth2/OIDC endpoints.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/auth")]
public class AuthController : ControllerBase
{
private readonly IMediator _mediator;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly ILogger<AuthController> _logger;
public AuthController(
IMediator mediator,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager,
ILogger<AuthController> logger)
{
_mediator = mediator;
_userManager = userManager;
_signInManager = signInManager;
_logger = logger;
}
/// <summary>
/// EN: Register a new user.
/// VI: Đăng ký user mới.
/// </summary>
[HttpPost("register")]
[ProducesResponseType(typeof(RegisterUserCommandResult), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Register(
[FromBody] RegisterUserCommand command,
CancellationToken cancellationToken)
{
var result = await _mediator.Send(command, cancellationToken);
return CreatedAtAction(nameof(Register), new { id = result.UserId }, result);
}
/// <summary>
/// EN: OAuth2 Token endpoint (handled by OpenIddict).
/// VI: OAuth2 Token endpoint (được xử lý bởi OpenIddict).
/// </summary>
[HttpPost("~/connect/token")]
[Consumes("application/x-www-form-urlencoded")]
[Produces("application/json")]
public async Task<IActionResult> Exchange()
{
var request = HttpContext.GetOpenIddictServerRequest()
?? throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");
if (request.IsPasswordGrantType())
{
return await HandlePasswordGrantAsync(request);
}
if (request.IsRefreshTokenGrantType())
{
return await HandleRefreshTokenGrantAsync();
}
if (request.IsClientCredentialsGrantType())
{
return await HandleClientCredentialsGrantAsync(request);
}
_logger.LogWarning("Unsupported grant type: {GrantType}", request.GrantType);
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.UnsupportedGrantType,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The specified grant type is not supported."
}));
}
private async Task<IActionResult> HandlePasswordGrantAsync(OpenIddictRequest request)
{
var user = await _userManager.FindByEmailAsync(request.Username!);
if (user == null)
{
_logger.LogWarning("Login failed: user not found for {Email}", request.Username);
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid email or password."
}));
}
// EN: Check password
// VI: Kiểm tra password
var result = await _signInManager.CheckPasswordSignInAsync(user, request.Password!, lockoutOnFailure: true);
if (result.IsLockedOut)
{
_logger.LogWarning("Login failed: user {UserId} is locked out", user.Id);
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Account is locked. Please try again later."
}));
}
if (!result.Succeeded)
{
_logger.LogWarning("Login failed: invalid password for user {UserId}", user.Id);
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "Invalid email or password."
}));
}
// EN: Record login and create claims
// VI: Ghi nhận login và tạo claims
user.RecordLogin();
var identity = new ClaimsIdentity(
authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
nameType: Claims.Name,
roleType: Claims.Role);
// EN: Add claims
// VI: Thêm claims
identity.SetClaim(Claims.Subject, user.Id.ToString())
.SetClaim(Claims.Email, user.Email)
.SetClaim(Claims.Name, user.FullName)
.SetClaim("first_name", user.FirstName)
.SetClaim("last_name", user.LastName);
// EN: Add roles to claims
// VI: Thêm roles vào claims
var roles = await _userManager.GetRolesAsync(user);
identity.SetClaims(Claims.Role, [.. roles]);
// EN: Set destinations for claims
// VI: Set destinations cho claims
identity.SetDestinations(GetDestinations);
var principal = new ClaimsPrincipal(identity);
// EN: Set scopes
// VI: Set scopes
principal.SetScopes(request.GetScopes());
_logger.LogInformation("User {UserId} logged in successfully", user.Id);
return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
private async Task<IActionResult> HandleRefreshTokenGrantAsync()
{
var result = await HttpContext.AuthenticateAsync(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
var userId = result.Principal?.GetClaim(Claims.Subject);
if (string.IsNullOrEmpty(userId))
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The refresh token is no longer valid."
}));
}
var user = await _userManager.FindByIdAsync(userId);
if (user == null)
{
return Forbid(
authenticationSchemes: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
properties: new AuthenticationProperties(new Dictionary<string, string?>
{
[OpenIddictServerAspNetCoreConstants.Properties.Error] = Errors.InvalidGrant,
[OpenIddictServerAspNetCoreConstants.Properties.ErrorDescription] = "The user no longer exists."
}));
}
// EN: Recreate principal with updated claims
// VI: Tạo lại principal với claims đã cập nhật
var identity = new ClaimsIdentity(result.Principal!.Claims,
authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
nameType: Claims.Name,
roleType: Claims.Role);
identity.SetDestinations(GetDestinations);
return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
}
private Task<IActionResult> HandleClientCredentialsGrantAsync(OpenIddictRequest request)
{
var identity = new ClaimsIdentity(
authenticationType: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme,
nameType: Claims.Name,
roleType: Claims.Role);
identity.SetClaim(Claims.Subject, request.ClientId);
identity.SetDestinations(GetDestinations);
var principal = new ClaimsPrincipal(identity);
principal.SetScopes(request.GetScopes());
return Task.FromResult<IActionResult>(
SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme));
}
private static IEnumerable<string> GetDestinations(Claim claim)
{
switch (claim.Type)
{
case Claims.Name or Claims.Email:
yield return Destinations.AccessToken;
if (claim.Subject?.HasScope(Scopes.Profile) == true)
yield return Destinations.IdentityToken;
yield break;
case Claims.Role:
yield return Destinations.AccessToken;
if (claim.Subject?.HasScope(Scopes.Roles) == true)
yield return Destinations.IdentityToken;
yield break;
default:
yield return Destinations.AccessToken;
yield break;
}
}
}

View File

@@ -0,0 +1,84 @@
using Asp.Versioning;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using OpenIddict.Validation.AspNetCore;
using IamService.API.Application.Queries.Users;
namespace IamService.API.Controllers;
/// <summary>
/// EN: Users management controller.
/// VI: Controller quản lý users.
/// </summary>
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/users")]
[Authorize(AuthenticationSchemes = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme)]
public class UsersController : ControllerBase
{
private readonly IMediator _mediator;
private readonly ILogger<UsersController> _logger;
public UsersController(
IMediator mediator,
ILogger<UsersController> logger)
{
_mediator = mediator;
_logger = logger;
}
/// <summary>
/// EN: Get all users with pagination.
/// VI: Lấy tất cả users với phân trang.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(GetUsersQueryResult), StatusCodes.Status200OK)]
public async Task<IActionResult> GetUsers(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10,
CancellationToken cancellationToken = default)
{
var query = new GetUsersQuery(pageNumber, pageSize);
var result = await _mediator.Send(query, cancellationToken);
return Ok(new
{
success = true,
data = result.Users,
pagination = new
{
pageNumber = result.PageNumber,
pageSize = result.PageSize,
totalCount = result.TotalCount,
totalPages = (int)Math.Ceiling(result.TotalCount / (double)result.PageSize)
}
});
}
/// <summary>
/// EN: Get current user info.
/// VI: Lấy thông tin user hiện tại.
/// </summary>
[HttpGet("me")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetCurrentUser()
{
var userId = User.FindFirst("sub")?.Value;
var email = User.FindFirst("email")?.Value;
var name = User.FindFirst("name")?.Value;
var roles = User.FindAll("role").Select(c => c.Value);
return Ok(new
{
success = true,
data = new
{
id = userId,
email,
name,
roles
}
});
}
}

View File

@@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<AssemblyName>IamService.API</AssemblyName>
<RootNamespace>IamService.API</RootNamespace>
<Description>Web API layer with CQRS pattern</Description>
<UserSecretsId>iamservice-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: ASP.NET Core Identity for user management / VI: ASP.NET Core Identity cho quản lý user -->
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<!-- EN: OpenIddict for OAuth2/OIDC / VI: OpenIddict cho OAuth2/OIDC -->
<PackageReference Include="OpenIddict.AspNetCore" Version="5.8.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.8.0" />
<!-- EN: JWT Bearer Authentication / VI: JWT Bearer Authentication -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IamService.Domain\IamService.Domain.csproj" />
<ProjectReference Include="..\IamService.Infrastructure\IamService.Infrastructure.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,180 @@
using Asp.Versioning;
using FluentValidation;
using Hellang.Middleware.ProblemDetails;
using IamService.API.Application.Behaviors;
using IamService.Infrastructure;
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 IAM Service API / Khởi động IAM Service 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 (Identity, OpenIddict, Repositories)
// VI: Thêm Infrastructure services (Identity, OpenIddict, Repositories)
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) / VI: Thêm ProblemDetails middleware
builder.Services.AddProblemDetails(options =>
{
options.IncludeExceptionDetails = (ctx, ex) =>
builder.Environment.IsDevelopment();
});
// EN: Add Swagger / VI: Thêm Swagger
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new()
{
Title = "IAM Service API",
Version = "v1",
Description = "Identity and Access Management Service - OAuth2/OIDC API"
});
// EN: Add OAuth2 security definition / VI: Thêm OAuth2 security definition
options.AddSecurityDefinition("oauth2", new()
{
Type = Microsoft.OpenApi.Models.SecuritySchemeType.OAuth2,
Flows = new()
{
Password = new()
{
TokenUrl = new Uri("/connect/token", UriKind.Relative),
Scopes = new Dictionary<string, string>
{
["openid"] = "OpenID",
["profile"] = "Profile",
["email"] = "Email",
["roles"] = "Roles",
["api"] = "API access"
}
}
}
});
options.AddSecurityRequirement(new()
{
{
new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "oauth2" } },
["api"]
}
});
});
// 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 CORS / VI: Thêm CORS
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.AllowAnyOrigin()
.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", "IAM Service API v1");
c.RoutePrefix = "swagger";
c.OAuthClientId("swagger-ui");
c.OAuthUsePkce();
});
}
app.UseCors();
app.UseRouting();
// EN: Authentication and Authorization / VI: Xác thực và phân quyền
app.UseAuthentication();
app.UseAuthorization();
// 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 { }

View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}
}
}

View File

@@ -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=iamservice_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": "*"
}

View File

@@ -0,0 +1,100 @@
using Microsoft.AspNetCore.Identity;
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.RoleAggregate;
/// <summary>
/// EN: Application role entity extending ASP.NET Core Identity.
/// VI: Entity role mở rộng từ ASP.NET Core Identity.
/// </summary>
public class ApplicationRole : IdentityRole<Guid>, IAggregateRoot
{
// EN: Private fields for encapsulation
// VI: Fields private để đóng gói
private string? _description;
private DateTime _createdAt;
private bool _isSystemRole;
private readonly List<IDomainEvent> _domainEvents = [];
/// <summary>
/// EN: Role description.
/// VI: Mô tả role.
/// </summary>
public string? Description => _description;
/// <summary>
/// EN: Creation timestamp.
/// VI: Thời gian tạo.
/// </summary>
public DateTime CreatedAt => _createdAt;
/// <summary>
/// EN: Whether this is a system-defined role.
/// VI: Role có phải là system role không.
/// </summary>
public bool IsSystemRole => _isSystemRole;
/// <summary>
/// EN: Domain events.
/// VI: Domain events.
/// </summary>
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected ApplicationRole()
{
}
/// <summary>
/// EN: Create a new role.
/// VI: Tạo role mới.
/// </summary>
public ApplicationRole(string name, string? description = null, bool isSystemRole = false) : this()
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Role name cannot be empty", nameof(name));
Id = Guid.NewGuid();
Name = name;
NormalizedName = name.ToUpperInvariant();
_description = description;
_isSystemRole = isSystemRole;
_createdAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update role information.
/// VI: Cập nhật thông tin role.
/// </summary>
public void Update(string name, string? description)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Role name cannot be empty", nameof(name));
Name = name;
NormalizedName = name.ToUpperInvariant();
_description = description;
}
/// <summary>
/// EN: Add domain event.
/// VI: Thêm domain event.
/// </summary>
public void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
/// <summary>
/// EN: Clear domain events.
/// VI: Xóa domain events.
/// </summary>
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}

View File

@@ -0,0 +1,46 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.RoleAggregate;
/// <summary>
/// EN: Repository interface for ApplicationRole aggregate.
/// VI: Interface repository cho ApplicationRole aggregate.
/// </summary>
public interface IRoleRepository : IRepository<ApplicationRole>
{
/// <summary>
/// EN: Find role by name.
/// VI: Tìm role theo tên.
/// </summary>
Task<ApplicationRole?> FindByNameAsync(string name, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Find role by ID.
/// VI: Tìm role theo ID.
/// </summary>
Task<ApplicationRole?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all roles.
/// VI: Lấy tất cả roles.
/// </summary>
Task<IEnumerable<ApplicationRole>> GetAllAsync(CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add new role.
/// VI: Thêm role mới.
/// </summary>
ApplicationRole Add(ApplicationRole role);
/// <summary>
/// EN: Update existing role.
/// VI: Cập nhật role hiện có.
/// </summary>
void Update(ApplicationRole role);
/// <summary>
/// EN: Delete role.
/// VI: Xóa role.
/// </summary>
void Delete(ApplicationRole role);
}

View File

@@ -0,0 +1,178 @@
using Microsoft.AspNetCore.Identity;
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.UserAggregate;
/// <summary>
/// EN: Application user entity extending ASP.NET Core Identity.
/// VI: Entity user mở rộng từ ASP.NET Core Identity.
/// </summary>
public class ApplicationUser : IdentityUser<Guid>, IAggregateRoot
{
// EN: Private fields for encapsulation
// VI: Fields private để đóng gói
private string _firstName = null!;
private string _lastName = null!;
private UserStatus _status = null!;
private DateTime _createdAt;
private DateTime? _lastLoginAt;
private readonly List<IDomainEvent> _domainEvents = [];
/// <summary>
/// EN: User's first name.
/// VI: Tên người dùng.
/// </summary>
public string FirstName => _firstName;
/// <summary>
/// EN: User's last name.
/// VI: Họ người dùng.
/// </summary>
public string LastName => _lastName;
/// <summary>
/// EN: User's full name.
/// VI: Tên đầy đủ người dùng.
/// </summary>
public string FullName => $"{_firstName} {_lastName}";
/// <summary>
/// EN: Current status.
/// VI: Trạng thái hiện tại.
/// </summary>
public UserStatus 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 login timestamp.
/// VI: Thời gian đăng nhập cuối.
/// </summary>
public DateTime? LastLoginAt => _lastLoginAt;
/// <summary>
/// EN: Domain events.
/// VI: Domain events.
/// </summary>
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
/// <summary>
/// EN: Private constructor for EF Core.
/// VI: Constructor private cho EF Core.
/// </summary>
protected ApplicationUser()
{
}
/// <summary>
/// EN: Create a new user with required information.
/// VI: Tạo user mới với thông tin bắt buộc.
/// </summary>
public ApplicationUser(string email, string firstName, string lastName) : this()
{
if (string.IsNullOrWhiteSpace(email))
throw new ArgumentException("Email cannot be empty", nameof(email));
if (string.IsNullOrWhiteSpace(firstName))
throw new ArgumentException("First name cannot be empty", nameof(firstName));
if (string.IsNullOrWhiteSpace(lastName))
throw new ArgumentException("Last name cannot be empty", nameof(lastName));
Id = Guid.NewGuid();
Email = email;
UserName = email;
NormalizedEmail = email.ToUpperInvariant();
NormalizedUserName = email.ToUpperInvariant();
_firstName = firstName;
_lastName = lastName;
_status = UserStatus.Active;
StatusId = UserStatus.Active.Id;
_createdAt = DateTime.UtcNow;
}
/// <summary>
/// EN: Update user profile.
/// VI: Cập nhật thông tin user.
/// </summary>
public void UpdateProfile(string firstName, string lastName)
{
if (string.IsNullOrWhiteSpace(firstName))
throw new ArgumentException("First name cannot be empty", nameof(firstName));
if (string.IsNullOrWhiteSpace(lastName))
throw new ArgumentException("Last name cannot be empty", nameof(lastName));
_firstName = firstName;
_lastName = lastName;
}
/// <summary>
/// EN: Record successful login.
/// VI: Ghi nhận đăng nhập thành công.
/// </summary>
public void RecordLogin()
{
_lastLoginAt = DateTime.UtcNow;
AccessFailedCount = 0;
}
/// <summary>
/// EN: Lock the user account.
/// VI: Khóa tài khoản user.
/// </summary>
public void Lock(DateTimeOffset? until = null)
{
_status = UserStatus.Locked;
StatusId = UserStatus.Locked.Id;
LockoutEnd = until ?? DateTimeOffset.MaxValue;
}
/// <summary>
/// EN: Unlock the user account.
/// VI: Mở khóa tài khoản user.
/// </summary>
public void Unlock()
{
_status = UserStatus.Active;
StatusId = UserStatus.Active.Id;
LockoutEnd = null;
AccessFailedCount = 0;
}
/// <summary>
/// EN: Disable the user account.
/// VI: Vô hiệu hóa tài khoản user.
/// </summary>
public void Disable()
{
_status = UserStatus.Disabled;
StatusId = UserStatus.Disabled.Id;
}
/// <summary>
/// EN: Add domain event.
/// VI: Thêm domain event.
/// </summary>
public void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
/// <summary>
/// EN: Clear domain events.
/// VI: Xóa domain events.
/// </summary>
public void ClearDomainEvents()
{
_domainEvents.Clear();
}
}

View File

@@ -0,0 +1,43 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.UserAggregate;
/// <summary>
/// EN: Repository interface for ApplicationUser aggregate.
/// VI: Interface repository cho ApplicationUser aggregate.
/// </summary>
public interface IUserRepository : IRepository<ApplicationUser>
{
/// <summary>
/// EN: Find user by email.
/// VI: Tìm user theo email.
/// </summary>
Task<ApplicationUser?> FindByEmailAsync(string email, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Find user by ID.
/// VI: Tìm user theo ID.
/// </summary>
Task<ApplicationUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
/// <summary>
/// EN: Get all users with pagination.
/// VI: Lấy tất cả users với phân trang.
/// </summary>
Task<(IEnumerable<ApplicationUser> Users, int TotalCount)> GetAllAsync(
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default);
/// <summary>
/// EN: Add new user.
/// VI: Thêm user mới.
/// </summary>
ApplicationUser Add(ApplicationUser user);
/// <summary>
/// EN: Update existing user.
/// VI: Cập nhật user hiện có.
/// </summary>
void Update(ApplicationUser user);
}

View File

@@ -0,0 +1,31 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.AggregatesModel.UserAggregate;
/// <summary>
/// EN: User status enumeration.
/// VI: Enumeration trạng thái user.
/// </summary>
public class UserStatus : Enumeration
{
public static readonly UserStatus Active = new(1, nameof(Active));
public static readonly UserStatus Locked = new(2, nameof(Locked));
public static readonly UserStatus Disabled = new(3, nameof(Disabled));
public static readonly UserStatus PendingVerification = new(4, nameof(PendingVerification));
public UserStatus(int id, string name) : base(id, name)
{
}
/// <summary>
/// EN: Get all user statuses.
/// VI: Lấy tất cả trạng thái user.
/// </summary>
public static IEnumerable<UserStatus> GetAll() =>
[
Active,
Locked,
Disabled,
PendingVerification
];
}

View File

@@ -0,0 +1,23 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.Events;
/// <summary>
/// EN: Domain event raised when a role is assigned to a user.
/// VI: Domain event được raise khi role được gán cho user.
/// </summary>
public class RoleAssignedDomainEvent : IDomainEvent
{
public Guid UserId { get; }
public Guid RoleId { get; }
public string RoleName { get; }
public DateTime OccurredOn { get; }
public RoleAssignedDomainEvent(Guid userId, Guid roleId, string roleName)
{
UserId = userId;
RoleId = roleId;
RoleName = roleName;
OccurredOn = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,21 @@
using IamService.Domain.SeedWork;
namespace IamService.Domain.Events;
/// <summary>
/// EN: Domain event raised when user successfully logs in.
/// VI: Domain event được raise khi user đăng nhập thành công.
/// </summary>
public class UserLoggedInDomainEvent : IDomainEvent
{
public Guid UserId { get; }
public string Email { get; }
public DateTime OccurredOn { get; }
public UserLoggedInDomainEvent(Guid userId, string email)
{
UserId = userId;
Email = email;
OccurredOn = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,20 @@
using IamService.Domain.SeedWork;
using IamService.Domain.AggregatesModel.UserAggregate;
namespace IamService.Domain.Events;
/// <summary>
/// EN: Domain event raised when a new user is registered.
/// VI: Domain event được raise khi user mới được đăng ký.
/// </summary>
public class UserRegisteredDomainEvent : IDomainEvent
{
public ApplicationUser User { get; }
public DateTime OccurredOn { get; }
public UserRegisteredDomainEvent(ApplicationUser user)
{
User = user;
OccurredOn = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,21 @@
namespace IamService.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)
{
}
}

View File

@@ -0,0 +1,18 @@
namespace IamService.Domain.Exceptions;
/// <summary>
/// EN: Exception thrown for invalid credentials.
/// VI: Exception khi thông tin đăng nhập không hợp lệ.
/// </summary>
public class InvalidCredentialsException : DomainException
{
public InvalidCredentialsException()
: base("Invalid email or password")
{
}
public InvalidCredentialsException(string message)
: base(message)
{
}
}

View File

@@ -0,0 +1,18 @@
namespace IamService.Domain.Exceptions;
/// <summary>
/// EN: Exception thrown when user account is locked.
/// VI: Exception khi tài khoản user bị khóa.
/// </summary>
public class UserLockedException : DomainException
{
public UserLockedException()
: base("User account is locked")
{
}
public UserLockedException(string message)
: base(message)
{
}
}

View File

@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>IamService.Domain</AssemblyName>
<RootNamespace>IamService.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" />
<!-- EN: ASP.NET Core Identity abstractions / VI: ASP.NET Core Identity abstractions -->
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="9.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,102 @@
using MediatR;
namespace IamService.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);
}
}

View File

@@ -0,0 +1,95 @@
using System.Reflection;
namespace IamService.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);
}

View File

@@ -0,0 +1,15 @@
namespace IamService.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
{
}

View File

@@ -0,0 +1,12 @@
using MediatR;
namespace IamService.Domain.SeedWork;
/// <summary>
/// EN: Marker interface for domain events.
/// VI: Interface đánh dấu cho domain events.
/// </summary>
public interface IDomainEvent : INotification
{
DateTime OccurredOn { get; }
}

View File

@@ -0,0 +1,15 @@
namespace IamService.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; }
}

View File

@@ -0,0 +1,30 @@
namespace IamService.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);
}

View File

@@ -0,0 +1,53 @@
namespace IamService.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();
}
}

View File

@@ -0,0 +1,143 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.AggregatesModel.RoleAggregate;
using IamService.Domain.SeedWork;
using IamService.Infrastructure.Repositories;
namespace IamService.Infrastructure;
/// <summary>
/// EN: Dependency injection extensions for Infrastructure layer.
/// VI: Dependency injection extensions cho Infrastructure layer.
/// </summary>
public static class DependencyInjection
{
/// <summary>
/// EN: Add Infrastructure services to DI container.
/// VI: Thêm Infrastructure services vào DI container.
/// </summary>
public static IServiceCollection AddInfrastructure(
this IServiceCollection services,
IConfiguration configuration)
{
// EN: Get database connection string
// VI: Lấy database connection string
var connectionString = configuration.GetConnectionString("DefaultConnection")
?? configuration["DATABASE_URL"]
?? throw new InvalidOperationException("Database connection string not configured");
// EN: Add DbContext with PostgreSQL
// VI: Thêm DbContext với PostgreSQL
services.AddDbContext<IamServiceContext>(options =>
{
options.UseNpgsql(connectionString, npgsqlOptions =>
{
npgsqlOptions.MigrationsAssembly(typeof(IamServiceContext).Assembly.FullName);
npgsqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorCodesToAdd: null);
});
// EN: Use OpenIddict EF Core stores
// VI: Sử dụng OpenIddict EF Core stores
options.UseOpenIddict();
});
// EN: Add ASP.NET Core Identity
// VI: Thêm ASP.NET Core Identity
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
{
// EN: Password settings
// VI: Cài đặt mật khẩu
options.Password.RequireDigit = true;
options.Password.RequireLowercase = true;
options.Password.RequireUppercase = true;
options.Password.RequireNonAlphanumeric = true;
options.Password.RequiredLength = 8;
// EN: Lockout settings
// VI: Cài đặt khóa tài khoản
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.AllowedForNewUsers = true;
// EN: User settings
// VI: Cài đặt user
options.User.RequireUniqueEmail = true;
options.SignIn.RequireConfirmedEmail = false;
})
.AddEntityFrameworkStores<IamServiceContext>()
.AddDefaultTokenProviders();
// EN: Configure OpenIddict
// VI: Cấu hình OpenIddict
services.AddOpenIddict()
// EN: Register the OpenIddict core components
// VI: Đăng ký OpenIddict core components
.AddCore(options =>
{
options.UseEntityFrameworkCore()
.UseDbContext<IamServiceContext>();
})
// EN: Register the OpenIddict server components
// VI: Đăng ký OpenIddict server components
.AddServer(options =>
{
// EN: Enable token endpoints
// VI: Bật token endpoints
options.SetTokenEndpointUris("/connect/token")
.SetUserinfoEndpointUris("/connect/userinfo")
.SetIntrospectionEndpointUris("/connect/introspect")
.SetRevocationEndpointUris("/connect/revoke");
// EN: Enable flows
// VI: Bật flows
options.AllowPasswordFlow()
.AllowRefreshTokenFlow()
.AllowClientCredentialsFlow();
// EN: Register scopes
// VI: Đăng ký scopes
options.RegisterScopes("openid", "profile", "email", "roles", "api");
// EN: Token lifetimes
// VI: Thời hạn token
options.SetAccessTokenLifetime(TimeSpan.FromMinutes(15))
.SetRefreshTokenLifetime(TimeSpan.FromDays(7));
// EN: Development settings - Disable HTTPS requirement for local dev
// VI: Cài đặt development - Tắt yêu cầu HTTPS cho dev local
options.AddDevelopmentEncryptionCertificate()
.AddDevelopmentSigningCertificate();
// EN: Accept anonymous clients (for password flow)
// VI: Chấp nhận anonymous clients (cho password flow)
options.AcceptAnonymousClients();
// EN: Disable client authentication
// VI: Tắt client authentication (for development)
options.UseAspNetCore()
.EnableTokenEndpointPassthrough()
.EnableUserinfoEndpointPassthrough();
})
// EN: Register the OpenIddict validation components
// VI: Đăng ký OpenIddict validation components
.AddValidation(options =>
{
options.UseLocalServer();
options.UseAspNetCore();
});
// EN: Register repositories
// VI: Đăng ký repositories
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IRoleRepository, RoleRepository>();
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<IamServiceContext>());
return services;
}
}

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>IamService.Infrastructure</AssemblyName>
<RootNamespace>IamService.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" />
<!-- EN: ASP.NET Core Identity / VI: ASP.NET Core Identity -->
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.0" />
<!-- EN: OpenIddict for OAuth2/OIDC / VI: OpenIddict cho OAuth2/OIDC -->
<PackageReference Include="OpenIddict.AspNetCore" Version="5.8.0" />
<PackageReference Include="OpenIddict.EntityFrameworkCore" Version="5.8.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\IamService.Domain\IamService.Domain.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,178 @@
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.AggregatesModel.RoleAggregate;
using IamService.Domain.SeedWork;
namespace IamService.Infrastructure;
/// <summary>
/// EN: Database context for IAM Service with Identity and OpenIddict support.
/// VI: Database context cho IAM Service với Identity và OpenIddict support.
/// </summary>
public class IamServiceContext : IdentityDbContext<ApplicationUser, ApplicationRole, Guid>, IUnitOfWork
{
private readonly IMediator _mediator;
private IDbContextTransaction? _currentTransaction;
public IamServiceContext(DbContextOptions<IamServiceContext> options)
: base(options)
{
_mediator = null!;
}
public IamServiceContext(DbContextOptions<IamServiceContext> options, IMediator mediator)
: base(options)
{
_mediator = mediator ?? throw new ArgumentNullException(nameof(mediator));
}
/// <summary>
/// EN: User statuses table.
/// VI: Bảng trạng thái user.
/// </summary>
public DbSet<UserStatus> UserStatuses { get; set; } = null!;
/// <summary>
/// EN: Check if there's an active transaction.
/// VI: Kiểm tra xem có transaction đang hoạt động không.
/// </summary>
public bool HasActiveTransaction => _currentTransaction != null;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// EN: Apply entity configurations
// VI: Áp dụng entity configurations
modelBuilder.ApplyConfigurationsFromAssembly(typeof(IamServiceContext).Assembly);
// EN: Configure Identity tables with custom names
// VI: Cấu hình bảng Identity với tên tùy chỉnh
modelBuilder.Entity<ApplicationUser>().ToTable("users");
modelBuilder.Entity<ApplicationRole>().ToTable("roles");
modelBuilder.Entity<IdentityUserRole<Guid>>().ToTable("user_roles");
modelBuilder.Entity<IdentityUserClaim<Guid>>().ToTable("user_claims");
modelBuilder.Entity<IdentityUserLogin<Guid>>().ToTable("user_logins");
modelBuilder.Entity<IdentityUserToken<Guid>>().ToTable("user_tokens");
modelBuilder.Entity<IdentityRoleClaim<Guid>>().ToTable("role_claims");
// EN: Seed UserStatus enumeration
// VI: Seed UserStatus enumeration
modelBuilder.Entity<UserStatus>().ToTable("user_statuses");
modelBuilder.Entity<UserStatus>().HasData(
UserStatus.Active,
UserStatus.Locked,
UserStatus.Disabled,
UserStatus.PendingVerification
);
// EN: Configure OpenIddict entities
// VI: Cấu hình OpenIddict entities
modelBuilder.UseOpenIddict();
}
/// <summary>
/// EN: Save changes and dispatch domain events.
/// VI: Lưu thay đổi và dispatch domain events.
/// </summary>
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
{
// EN: Dispatch domain events before saving
// VI: Dispatch domain events trước khi lưu
await DispatchDomainEventsAsync(cancellationToken);
await base.SaveChangesAsync(cancellationToken);
return true;
}
/// <summary>
/// EN: Begin a new transaction.
/// VI: Bắt đầu transaction mới.
/// </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)
{
if (transaction == null) throw new ArgumentNullException(nameof(transaction));
if (transaction != _currentTransaction) throw new InvalidOperationException($"Transaction {transaction.TransactionId} is not current");
try
{
await SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await RollbackTransactionAsync();
throw;
}
finally
{
if (_currentTransaction != null)
{
_currentTransaction.Dispose();
_currentTransaction = null;
}
}
}
/// <summary>
/// EN: Rollback the current transaction.
/// VI: Rollback transaction hiện tại.
/// </summary>
public async Task RollbackTransactionAsync()
{
try
{
if (_currentTransaction != null)
{
await _currentTransaction.RollbackAsync();
}
}
finally
{
if (_currentTransaction != null)
{
_currentTransaction.Dispose();
_currentTransaction = null;
}
}
}
private async Task DispatchDomainEventsAsync(CancellationToken cancellationToken)
{
if (_mediator == null) return;
var domainEntities = ChangeTracker
.Entries<ApplicationUser>()
.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, cancellationToken);
}
}
}

View File

@@ -0,0 +1,26 @@
namespace IamService.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; }
}

View File

@@ -0,0 +1,24 @@
namespace IamService.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);
}

View File

@@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore;
namespace IamService.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 IamServiceContext _context;
public RequestManager(IamServiceContext 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();
}
}

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore;
using IamService.Domain.AggregatesModel.RoleAggregate;
using IamService.Domain.SeedWork;
namespace IamService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for ApplicationRole aggregate.
/// VI: Repository implementation cho ApplicationRole aggregate.
/// </summary>
public class RoleRepository : IRoleRepository
{
private readonly IamServiceContext _context;
public RoleRepository(IamServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public IUnitOfWork UnitOfWork => _context;
/// <summary>
/// EN: Find role by name.
/// VI: Tìm role theo tên.
/// </summary>
public async Task<ApplicationRole?> FindByNameAsync(string name, CancellationToken cancellationToken = default)
{
return await _context.Roles
.FirstOrDefaultAsync(r => r.NormalizedName == name.ToUpperInvariant(), cancellationToken);
}
/// <summary>
/// EN: Find role by ID.
/// VI: Tìm role theo ID.
/// </summary>
public async Task<ApplicationRole?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Roles
.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
}
/// <summary>
/// EN: Get all roles.
/// VI: Lấy tất cả roles.
/// </summary>
public async Task<IEnumerable<ApplicationRole>> GetAllAsync(CancellationToken cancellationToken = default)
{
return await _context.Roles
.OrderBy(r => r.Name)
.ToListAsync(cancellationToken);
}
/// <summary>
/// EN: Add new role.
/// VI: Thêm role mới.
/// </summary>
public ApplicationRole Add(ApplicationRole role)
{
return _context.Roles.Add(role).Entity;
}
/// <summary>
/// EN: Update existing role.
/// VI: Cập nhật role hiện có.
/// </summary>
public void Update(ApplicationRole role)
{
_context.Entry(role).State = EntityState.Modified;
}
/// <summary>
/// EN: Delete role.
/// VI: Xóa role.
/// </summary>
public void Delete(ApplicationRole role)
{
_context.Roles.Remove(role);
}
}

View File

@@ -0,0 +1,81 @@
using Microsoft.EntityFrameworkCore;
using IamService.Domain.AggregatesModel.UserAggregate;
using IamService.Domain.SeedWork;
namespace IamService.Infrastructure.Repositories;
/// <summary>
/// EN: Repository implementation for ApplicationUser aggregate.
/// VI: Repository implementation cho ApplicationUser aggregate.
/// </summary>
public class UserRepository : IUserRepository
{
private readonly IamServiceContext _context;
public UserRepository(IamServiceContext context)
{
_context = context ?? throw new ArgumentNullException(nameof(context));
}
public IUnitOfWork UnitOfWork => _context;
/// <summary>
/// EN: Find user by email.
/// VI: Tìm user theo email.
/// </summary>
public async Task<ApplicationUser?> FindByEmailAsync(string email, CancellationToken cancellationToken = default)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.NormalizedEmail == email.ToUpperInvariant(), cancellationToken);
}
/// <summary>
/// EN: Find user by ID.
/// VI: Tìm user theo ID.
/// </summary>
public async Task<ApplicationUser?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
{
return await _context.Users
.FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
}
/// <summary>
/// EN: Get all users with pagination.
/// VI: Lấy tất cả users với phân trang.
/// </summary>
public async Task<(IEnumerable<ApplicationUser> Users, int TotalCount)> GetAllAsync(
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default)
{
var query = _context.Users.AsQueryable();
var totalCount = await query.CountAsync(cancellationToken);
var users = await query
.OrderByDescending(u => u.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
return (users, totalCount);
}
/// <summary>
/// EN: Add new user.
/// VI: Thêm user mới.
/// </summary>
public ApplicationUser Add(ApplicationUser user)
{
return _context.Users.Add(user).Entity;
}
/// <summary>
/// EN: Update existing user.
/// VI: Cập nhật user hiện có.
/// </summary>
public void Update(ApplicationUser user)
{
_context.Entry(user).State = EntityState.Modified;
}
}

View File

@@ -0,0 +1,80 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;
namespace IamService.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);
}

View File

@@ -0,0 +1,56 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using IamService.Infrastructure;
namespace IamService.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<IamServiceContext>));
if (descriptor != null)
{
services.Remove(descriptor);
}
// EN: Remove DbContext service
// VI: Xóa DbContext service
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IamServiceContext));
if (dbContextDescriptor != null)
{
services.Remove(dbContextDescriptor);
}
// EN: Add in-memory database for testing
// VI: Thêm in-memory database để test
services.AddDbContext<IamServiceContext>(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<IamServiceContext>();
db.Database.EnsureCreated();
});
}
}

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>IamService.FunctionalTests</AssemblyName>
<RootNamespace>IamService.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\IamService.API\IamService.API.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyName>IamService.UnitTests</AssemblyName>
<RootNamespace>IamService.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\IamService.Domain\IamService.Domain.csproj" />
<ProjectReference Include="..\..\src\IamService.API\IamService.API.csproj" />
</ItemGroup>
</Project>