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:
40
services/iam-service-net/.env.example
Normal file
40
services/iam-service-net/.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
# Environment / Môi Trường
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
|
||||
# Database / Cơ Sở Dữ Liệu
|
||||
# PostgreSQL connection string (Neon or local)
|
||||
DATABASE_URL=Host=localhost;Port=5432;Database=myservice_db;Username=postgres;Password=postgres
|
||||
|
||||
# Redis Cache
|
||||
REDIS_URL=localhost:6379
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# JWT Authentication / Xác Thực JWT
|
||||
JWT_SECRET=your-secret-key-min-32-characters-long-here
|
||||
JWT_ISSUER=goodgo-platform
|
||||
JWT_AUDIENCE=goodgo-services
|
||||
JWT_ACCESS_TOKEN_EXPIRY_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_EXPIRY_DAYS=7
|
||||
|
||||
# API Configuration / Cấu Hình API
|
||||
API_PORT=5000
|
||||
API_BASE_PATH=/api/v1/myservice
|
||||
|
||||
# Observability / Quan Sát
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
|
||||
OTEL_SERVICE_NAME=myservice
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=Information
|
||||
SEQ_URL=http://localhost:5341
|
||||
|
||||
# Feature Flags
|
||||
FEATURE_SWAGGER_ENABLED=true
|
||||
FEATURE_DETAILED_ERRORS=true
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_PERMITS_PER_MINUTE=100
|
||||
RATE_LIMIT_QUEUE_LIMIT=10
|
||||
|
||||
# Health Checks
|
||||
HEALTHCHECK_TIMEOUT_SECONDS=5
|
||||
75
services/iam-service-net/.gitignore
vendored
Normal file
75
services/iam-service-net/.gitignore
vendored
Normal file
@@ -0,0 +1,75 @@
|
||||
# Build results
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio
|
||||
.vs/
|
||||
*.user
|
||||
*.userosscache
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.sln.docstates
|
||||
|
||||
# Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
packages/
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# Coverage
|
||||
TestResults/
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
out/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
|
||||
# Secrets
|
||||
appsettings.*.json
|
||||
!appsettings.json
|
||||
!appsettings.Development.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
|
||||
# JetBrains
|
||||
*.resharper
|
||||
|
||||
# dotnet tools
|
||||
.config/dotnet-tools.json
|
||||
|
||||
# Migration scripts (only keep structure)
|
||||
Migrations/
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
~$*
|
||||
22
services/iam-service-net/Directory.Build.props
Normal file
22
services/iam-service-net/Directory.Build.props
Normal 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>
|
||||
66
services/iam-service-net/Dockerfile
Normal file
66
services/iam-service-net/Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# Build stage / Giai đoạn build
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# EN: Copy project files for layer caching
|
||||
# VI: Sao chép các file project để tận dụng layer caching
|
||||
COPY ["src/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"]
|
||||
11
services/iam-service-net/IamService.slnx
Normal file
11
services/iam-service-net/IamService.slnx
Normal 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>
|
||||
166
services/iam-service-net/README.md
Normal file
166
services/iam-service-net/README.md
Normal 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
|
||||
72
services/iam-service-net/docker-compose.yml
Normal file
72
services/iam-service-net/docker-compose.yml
Normal file
@@ -0,0 +1,72 @@
|
||||
version: '3.8'
|
||||
|
||||
# EN: Docker Compose for local development
|
||||
# VI: Docker Compose cho phát triển local
|
||||
|
||||
services:
|
||||
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
|
||||
271
services/iam-service-net/docs/en/ARCHITECTURE.md
Normal file
271
services/iam-service-net/docs/en/ARCHITECTURE.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Architecture Documentation
|
||||
|
||||
> Detailed architecture documentation for the .NET 10 Microservice Template.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "API Layer"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
end
|
||||
|
||||
subgraph "Domain Layer"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
end
|
||||
|
||||
subgraph "Infrastructure Layer"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
## Layer Responsibilities
|
||||
|
||||
### 1. Domain Layer (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)
|
||||
265
services/iam-service-net/docs/en/README.md
Normal file
265
services/iam-service-net/docs/en/README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# .NET 10 Microservice Template
|
||||
|
||||
> Enterprise-grade .NET 10 microservice template following DDD, CQRS, and Clean Architecture patterns.
|
||||
|
||||
## Overview
|
||||
|
||||
This template provides a production-ready structure for .NET microservices based on the eShopOnContainers reference architecture with:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Separate Commands (write) and Queries (read) with MediatR
|
||||
- **Clean Architecture** - Domain, Infrastructure, API layered separation
|
||||
- **EF Core 10** - PostgreSQL with connection resilience
|
||||
- **FluentValidation** - Request validation
|
||||
- **API Versioning** - URL segment versioning
|
||||
- **Health Checks** - Kubernetes-ready probes
|
||||
- **Structured Logging** - Serilog with console and Seq
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Version |
|
||||
|-------------|---------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (or use Docker) |
|
||||
|
||||
```bash
|
||||
# Check .NET version
|
||||
dotnet --version
|
||||
# Should output: 10.0.xxx
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create New Service
|
||||
|
||||
```bash
|
||||
# Copy template to new service
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Navigate to service directory
|
||||
cd services/your-service-name
|
||||
|
||||
# Rename all occurrences of "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
|
||||
271
services/iam-service-net/docs/vi/ARCHITECTURE.md
Normal file
271
services/iam-service-net/docs/vi/ARCHITECTURE.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# Tài Liệu Kiến Trúc
|
||||
|
||||
> Tài liệu kiến trúc chi tiết cho Template Microservice .NET 10.
|
||||
|
||||
## Tổng Quan Kiến Trúc
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Lớp API"
|
||||
C[Controllers]
|
||||
CMD[Commands]
|
||||
Q[Queries]
|
||||
B[Behaviors]
|
||||
V[Validations]
|
||||
end
|
||||
|
||||
subgraph "Lớp Domain"
|
||||
AR[Aggregate Roots]
|
||||
E[Entities]
|
||||
VO[Value Objects]
|
||||
DE[Domain Events]
|
||||
DX[Domain Exceptions]
|
||||
end
|
||||
|
||||
subgraph "Lớp Infrastructure"
|
||||
DB[(PostgreSQL)]
|
||||
R[Repositories]
|
||||
CTX[DbContext]
|
||||
ID[Idempotency]
|
||||
end
|
||||
|
||||
C --> CMD
|
||||
C --> Q
|
||||
CMD --> B --> V
|
||||
CMD --> AR
|
||||
Q --> R
|
||||
R --> CTX --> DB
|
||||
AR --> DE
|
||||
R --> AR
|
||||
|
||||
style C fill:#4a90d9,stroke:#2d5986,color:#fff
|
||||
style AR fill:#50c878,stroke:#2d8659,color:#fff
|
||||
style DB fill:#ff6b6b,stroke:#c0392b,color:#fff
|
||||
```
|
||||
|
||||
## Trách Nhiệm Các Lớp
|
||||
|
||||
### 1. Lớp Domain (IamService.Domain)
|
||||
|
||||
Trái tim của ứng dụng chứa business logic thuần túy. Lớp này:
|
||||
- Có **ZERO** phụ thuộc bên ngoài (ngoại trừ MediatR.Contracts cho events)
|
||||
- Chỉ chứa các class POCO
|
||||
- Triển khai các tactical patterns của DDD
|
||||
|
||||
#### Thành Phần
|
||||
|
||||
| Thành phần | Mục Đích |
|
||||
|------------|----------|
|
||||
| **SeedWork** | Base classes: Entity, ValueObject, Enumeration, IAggregateRoot |
|
||||
| **AggregatesModel** | Aggregate roots với entities và value objects |
|
||||
| **Events** | Domain events cho giao tiếp cross-aggregate |
|
||||
| **Exceptions** | Domain exceptions cho vi phạm business rules |
|
||||
|
||||
### 2. Lớp Infrastructure (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)
|
||||
265
services/iam-service-net/docs/vi/README.md
Normal file
265
services/iam-service-net/docs/vi/README.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Template Microservice .NET 10
|
||||
|
||||
> Template microservice .NET 10 cấp doanh nghiệp theo các pattern DDD, CQRS và Clean Architecture.
|
||||
|
||||
## Tổng Quan
|
||||
|
||||
Template này cung cấp cấu trúc sẵn sàng production cho microservices .NET dựa trên kiến trúc tham chiếu eShopOnContainers với:
|
||||
|
||||
- **Domain-Driven Design (DDD)** - Aggregates, Entities, Value Objects, Domain Events
|
||||
- **CQRS Pattern** - Tách biệt Commands (ghi) và Queries (đọc) với MediatR
|
||||
- **Clean Architecture** - Phân tầng Domain, Infrastructure, API
|
||||
- **EF Core 10** - PostgreSQL với connection resilience
|
||||
- **FluentValidation** - Validation request
|
||||
- **API Versioning** - Versioning theo URL segment
|
||||
- **Health Checks** - Probes sẵn sàng cho Kubernetes
|
||||
- **Structured Logging** - Serilog với console và Seq
|
||||
|
||||
## Yêu Cầu
|
||||
|
||||
| Yêu cầu | Phiên bản |
|
||||
|---------|-----------|
|
||||
| .NET SDK | 10.0.101+ |
|
||||
| Docker | 24.0+ |
|
||||
| PostgreSQL | 15+ (hoặc dùng Docker) |
|
||||
|
||||
```bash
|
||||
# Kiểm tra phiên bản .NET
|
||||
dotnet --version
|
||||
# Kết quả nên là: 10.0.xxx
|
||||
```
|
||||
|
||||
## Bắt Đầu Nhanh
|
||||
|
||||
### 1. Tạo Service Mới
|
||||
|
||||
```bash
|
||||
# Sao chép template sang service mới
|
||||
cp -r services/_template_dot_net services/your-service-name
|
||||
|
||||
# Di chuyển đến thư mục service
|
||||
cd services/your-service-name
|
||||
|
||||
# Đổi tên tất cả "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
|
||||
7
services/iam-service-net/global.json
Normal file
7
services/iam-service-net/global.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.101",
|
||||
"rollForward": "latestMinor",
|
||||
"allowPrerelease": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Diagnostics;
|
||||
using MediatR;
|
||||
|
||||
namespace 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
180
services/iam-service-net/src/IamService.API/Program.cs
Normal file
180
services/iam-service-net/src/IamService.API/Program.cs
Normal 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 { }
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "http://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"launchUrl": "swagger",
|
||||
"applicationUrl": "http://localhost:5000",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Information",
|
||||
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
|
||||
"System": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
46
services/iam-service-net/src/IamService.API/appsettings.json
Normal file
46
services/iam-service-net/src/IamService.API/appsettings.json
Normal 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": "*"
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user