diff --git a/.github/workflows/github-cicd.yml b/.github/workflows/github-cicd.yml
index 733b2a18..e50e2157 100644
--- a/.github/workflows/github-cicd.yml
+++ b/.github/workflows/github-cicd.yml
@@ -8,9 +8,9 @@ on:
branches:
- main
-env:
- DOTNET_INSTALL_DIR: "./.dotnet"
- NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
+#env:
+#DOTNET_INSTALL_DIR: "./.dotnet"
+#NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
jobs:
build-and-deploy:
@@ -27,9 +27,17 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
- dotnet-version: "8.0.406"
- cache: true
- cache-dependency-path: "**/packages.lock.json"
+ dotnet-version: "8.0.408"
+ #cache: true
+ #cache-dependency-path: "src/**/packages.lock.json"
+ - name: Cache NuGet packages
+ id: cache-nuget
+ uses: actions/cache@v4
+ with:
+ path: ~/.nuget/packages
+ key: ${{ runner.os }}-nuget-${{ hashFiles('src/**/packages.lock.json','tests/**/packages.lock.json') }}
+ restore-keys: |
+ ${{ runner.os }}-nuget-
- name: Install dependencies
run: dotnet restore --locked-mode
- name: Build
@@ -40,7 +48,7 @@ jobs:
run: dotnet publish -c Release -o app/publish
- uses: actions/upload-artifact@v4
with:
- name: my-artifact
+ name: dotnet-app
path: |
app/publish
app/output/test-results
diff --git a/.gitignore b/.gitignore
index ddbb8df2..411d7643 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,7 +5,7 @@
# dotenv files
.env
-
+.vscode/
# User-specific files
*.rsuser
*.suo
diff --git a/.vscode/launch.json b/.vscode/launch.json
deleted file mode 100644
index bfe7a9ba..00000000
--- a/.vscode/launch.json
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "version": "0.2.0",
- "configurations": [
-
- {
- // Use IntelliSense to find out which attributes exist for C# debugging
- // Use hover for the description of the existing attributes
- // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
- "name": ".NET Core Launch (web)",
- "type": "coreclr",
- "request": "launch",
- "preLaunchTask": "build",
- // If you have changed target frameworks, make sure to update the program path.
- "program": "${workspaceFolder}/src/Api/bin/Debug/net8.0/Api.dll",
- "args": [],
- "cwd": "${workspaceFolder}/src/Api",
- "stopAtEntry": false,
- // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
- "serverReadyAction": {
- "action": "openExternally",
- "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
- },
- "env": {
- "ASPNETCORE_ENVIRONMENT": "Development"
- },
- "sourceFileMap": {
- "/Views": "${workspaceFolder}/Views"
- }
- },
- {
- "name": ".NET Core Attach",
- "type": "coreclr",
- "request": "attach"
- }
- ]
-}
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
deleted file mode 100644
index d880b8e0..00000000
--- a/.vscode/tasks.json
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "version": "2.0.0",
- "tasks": [
- {
- "label": "build",
- "command": "dotnet",
- "type": "process",
- "args": [
- "build",
- "${workspaceFolder}/template.sln",
- "/property:GenerateFullPaths=true",
- "/consoleloggerparameters:NoSummary;ForceNoAlign"
- ],
- "problemMatcher": "$msCompile"
- },
- {
- "label": "publish",
- "command": "dotnet",
- "type": "process",
- "args": [
- "publish",
- "${workspaceFolder}/template.sln",
- "/property:GenerateFullPaths=true",
- "/consoleloggerparameters:NoSummary;ForceNoAlign"
- ],
- "problemMatcher": "$msCompile"
- },
- {
- "label": "watch",
- "command": "dotnet",
- "type": "process",
- "args": [
- "watch",
- "run",
- "--project",
- "${workspaceFolder}/template.sln"
- ],
- "problemMatcher": "$msCompile"
- }
- ]
-}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
index ecf72062..2998e6f1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,10 +1,4 @@
-# Use the official .NET runtime image as a base for production
-FROM mcr.microsoft.com/dotnet/aspnet:8.0.12 AS base
-WORKDIR /app
-EXPOSE 8080
-
-# Copy the published application from the workflow
-FROM base AS final
+FROM mcr.microsoft.com/dotnet/aspnet:8.0.15
WORKDIR /app
COPY app/publish .
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
diff --git a/README-VIETNAMESE.md b/README-VIETNAMESE.md
index 1f1be263..5e6a0ba4 100644
--- a/README-VIETNAMESE.md
+++ b/README-VIETNAMESE.md
@@ -4,10 +4,7 @@
#
-
-[](LICENSE) 
-
-
+ [](LICENSE)      
# Bảng nội dung
@@ -21,9 +18,8 @@
- [Nhược điểm](#nhược-điểm)
- [Tính năng :rocket:](#tinh-nang)
- [Nhá hàng cho các tính năng :fire:](#nha-hang-cho-cac-tinh-nang)
- - [Authentication](#authentication)
- - [Dynamic search and sort](#dynamic-search-and-sort)
- - [Cross-cutting concerns](#cross-cutting-concerns)
+ - [Api](#api)
+ - [Truy vết](#truy-vết)
- [Lưu trử file media bằng Minio](#lưu-trử-file-media-bằng-minio)
- [Tự động dịch message](#tự-động-dịch-message)
- [Sơ lượt về Cấu trúc :mag_right:](#so-luot-ve-cau-truc)
@@ -43,7 +39,7 @@
Template này được thiết kế dành cho các bạn backend làm việc với ASP.NET Core. Nó cung cấp một cách hiệu quả để xây dựng các ứng dụng enterprise một cách đơn giản bằng cách tận dụng lợi thế từ kiến trúc Clean Architecture và .NET Core framework.
-Với template này, bạn sẽ có được zero configuration, không cần quan tâm đến cấu trúc, cài đặt, môi trường hoặc các thông lệ tốt nhất cho web API, vì tất cả đã được thiết lập :smiley:.
+Với template này, tất cả đã được thiết lập sẵn :smiley:.
@@ -55,22 +51,18 @@ Sự hỗ trợ của bạn là động lực giúp mình mang đến những t
# Định Nghĩa
-Clean Architecture là một triết lý thiết kế phần mềm được giới thiệu bởi Robert C. Martin (Uncle Bob). Nó nhấn mạnh việc tách biệt các mối quan tâm và khuyến khích việc tổ chức mã thành các lớp, mỗi lớp có trách nhiệm riêng biệt. Mục tiêu chính của kiến trúc là tạo ra các hệ thống không phụ thuộc vào framework, giao diện người dùng, cơ sở dữ liệu và các cơ quan bên ngoài, đảm bảo tính linh hoạt, khả năng mở rộng và dễ dàng kiểm thử.
-
-Tại phần trung tâm, Clean Architecture tổ chức mã thành các vòng tròn đồng tâm, với mỗi lớp đều có mục đích cụ thể.
+Kiến trúc Sạch (Clean Architecture) là một phương pháp thiết kế phần mềm do Robert C. Martin (Uncle Bob) giới thiệu, nhấn mạnh vào thuật ngữ "Tách biệt các thành phần",các tầng ngoài cùng sẽ phụ thuộc vào các tầng ở trong như hình. Tầng core sẽ không phụ thuộc vào các framework bên ngoài, cơ sở dữ liệu hay giao diện người dùng, từ đó giúp hệ thống dễ bảo trì, kiểm thử và phát triển theo thời gian.

-Quy tắc phụ thuộc nói rằng các thành phần phụ thuộc hướng từ ngoài vào trong, đảm bảo rằng các tầng bên trong vẫn tách biệt với các tầng bên ngoài.
-
### Lợi ích
-- **_Các thành phần tách biệt_**: Mỗi một tầng chịu trách nhiệm cho một khía cạnh của ứng dụng, giúp mã dễ hiểu và bảo trì.
-- **_Dễ dàng kiểm thử_**: Các business logic được tách biệt khỏi framework và UI, việc kiểm thử đơn vị trở nên đơn giản và đáng tin cậy hơn.
-- **_Linh hoạt và Thích nghi_**: Khi thay đổi framework, cơ sở dữ liệu hoặc các hệ thống bên ngoài ít ảnh hưởng đến logic của phần core.
-- **_Tái sử dụng_**: Các Business rules có thể được tái sử dụng trong các ứng dụng hoặc hệ thống khác mà không phải thay đổi quá nhiều code.
-- **_Khả năng mở rộng_**: Cấu trúc rõ ràng hỗ trợ việc phát triển và thêm tính năng mới mà không cần tái cơ cấu lại.
-- **_Không phụ thuộc vào framework_**: Không bị phụ thuộc nhiều vào framework, rất dễ dàng để thanh đổi công nghệ mới.
+- **Các thành phần tách biệt**: Mỗi một tầng chịu trách nhiệm cho một khía cạnh của ứng dụng, giúp mã dễ hiểu và bảo trì.
+- **Dễ dàng kiểm thử**: Các business logic được tách biệt khỏi framework và UI, việc kiểm thử đơn vị trở nên đơn giản và đáng tin cậy hơn.
+- **Linh hoạt và Thích nghi**: Khi thay đổi framework, cơ sở dữ liệu hoặc các hệ thống bên ngoài ít ảnh hưởng đến logic của phần core.
+- **Tái sử dụng**: Các Business rules có thể được tái sử dụng trong các ứng dụng hoặc hệ thống khác mà không phải thay đổi quá nhiều code.
+- **Khả năng mở rộng**: Cấu trúc rõ ràng hỗ trợ việc phát triển và thêm tính năng mới mà không cần tái cơ cấu lại.
+- **Không phụ thuộc vào framework**: Không bị phụ thuộc nhiều vào framework, rất dễ dàng để thanh đổi công nghệ mới.
### Nhược điểm
@@ -86,78 +78,42 @@ Quy tắc phụ thuộc nói rằng các thành phần phụ thuộc hướng t
Có gì đặc biệt khiến cho template này trở nên khác biệt so với những template khác có trên Github?
-Nó không chỉ có một cấu trúc rất hiện đại dễ dàng mở rộng và duy trì, mà còn có một loại các tính năng, design pattern vô cùng cực hữa ích đặc biệt là cho .NET Core Web API,
-
-Giúp cho bạn làm project của mình mà ít tốn công sức nhất.
-
-Nào hãy cùng mình khám phá nha :
-
-1. [Authentication với JWT](src/Infrastructure/Services/Identity/)
-1. [Authorization bằng Vai trò và quyền](#authorize)
-1. [Dynamic Search](src/Contracts/Extensions/QueryExtensions/SearchExtensions.cs), [Dynamic Sort](src/Contracts/Extensions/QueryExtensions/SortExtension.cs), [Dynamic Filter](#filtering),[Offset and Cursor Pagination](#pagination)
-1. [Lưu trữ media bằng AWS S3](src/Infrastructure/Services/Aws/)
-1. [Tích hợp sẳn Elastic Search](src/Infrastructure/Services/Elastics/)
-1. [Tích Hợp Domain Event](src/Application//Common/DomainEventHandlers/)
-1. [Cross-cutting Concerns](src/Application/Common/Behaviors/)
-1. [Distributed cache by Redis](src/Infrastructure/Services/DistributedCache/RedisCacheService.cs)
-1. [Xử lý bất đồng bộ nhiều Request cùng lúc bằng hàng đợi (ví dụ ở nhánh feature/TicketSale)](src/Infrastructure/Services/DistributedCache/)
-1. [Gửi Email](src/Infrastructure/Services/Mail/)
-1. [Tích hợp Schedule jobs bằng Hangfire](src/Infrastructure/Services/Hangfires/)
-1. [Specification Pattern](src/Domain/Common/Specs/), [Uit of work and Repository pattern](src/Infrastructure/UnitOfWorks/), [Cached repository with decorator design pattern](src/Infrastructure/UnitOfWorks/CachedRepositories/)
-1. [Subcutaneous Test](tests/Application.SubcutaneousTests/)
-1. [Tự động dịch message](src/Contracts/Common/Messages/)
-1. [Mã nguồn mở và Cấp phép MIT](#license)
+### Tính năng cần thiết cho mọi dự án:
+
+- Đăng nhập :closed_lock_with_key:
+- Refresh token :arrows_counterclockwise:
+- Đổi mật khẩu :repeat:
+- Quên mật khẩu :unlock:
+- Xem và cập nhật profile người dùng :man_with_gua_pi_mao:
+- User CRUD :family:
+- Role CRUD 🛡️
+
+### Một số tính năng hữu ích khác:
+
+1. [DDD (Domain Driven Design)](/src/Domain/Aggregates/) :brain:
+1. [CQRS & Mediator](/src/Application/Features/) :twisted_rightwards_arrows:
+1. [Cross-cutting concern](/src/Application/Common/Behaviors/) :scissors:
+1. [Mail Sender](/src/Infrastructure/Services/Mail/) :mailbox:
+1. [Cached Repository](/src/Infrastructure/UnitOfWorks/CachedRepositories/) :computer:
+1. [Queue](/src/Infrastructure/Services/Queue/) :walking:
+1. [Logging](/src/Api/Extensions/SerialogExtension.cs) :pencil:
+1. [Tracing](/src/Api/Extensions/OpenTelemetryExtensions.cs) :chart_with_upwards_trend:
+1. [Automatical translatable messages](https://github.com/minhsangdotcom/the-template_shared-kernel) :globe_with_meridians:
+1. [S3 AWS](/src/Infrastructure/Services/Aws/) :cloud:
# Nhá hàng cho các tính năng :fire:
-### Authentication
-
-```json
-{
- "results": {
- "user": {
- "firstName": "Chloe",
- "lastName": "Kim",
- "username": "chloe.kim",
- "email": "chloe.kim@gmail.com",
- "phoneNumber": "0925123123",
- "dayOfBirth": "1990-09-30T17:00:00Z",
- "gender": 2,
- "province": null,
- "district": null,
- "commune": null,
- "street": "132 Ham Nghi",
- "avatar": null,
- "status": 1,
- "createdBy": "SYSTEM",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JD936AXSDNMQ713P5XMVRQDV",
- "createdAt": "2024-12-31T08:15:50Z"
- },
- "tokenType": "Bearer",
- "accessTokenExpiredIn": 3600,
- "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMUpEOTM2QVhTRE5NUTcxM1A1WE1WUlFEViIsImV4cCI6MTczNzYxMjk4NH0.GMrQKpoaHcCHoKgV4WDeDPAZy_IEj7kUjh7PQRwTNG8",
- "refresh": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmYW1pbHlfaWQiOiJaNmI2M3hQSFUxRUsyVkl5R0YyOGJpWUdNTlh1REFrdiIsInN1YiI6IjAxSkQ5MzZBWFNETk1RNzEzUDVYTVZSUURWIiwiZXhwIjoxNzM3Njk1Nzg0fQ.jZgUpT7hQ0icP7FIp3TUzXfl2I4-O5MWEZ78RlBdCiI"
- },
- "statusCode": 200,
- "message": "SUCCESS"
-}
-```
-
-### Dynamic search and sort
+### API
-```
-http://localhost:8080/api/Users?PageSize=2&Search.Keyword=N%E1%BA%B5ng&Search.Targets=province.name&Sort=dayOfBirth
-```
+
-
+
-### Cross-cutting concerns
+### Truy Vết
-
+
### Lưu trử file media bằng Minio
@@ -167,24 +123,18 @@ http://localhost:8080/api/Users?PageSize=2&Search.Keyword=N%E1%BA%B5ng&Search.Ta
```json
{
- "type": "BadRequestException",
- "trace": {
- "traceId": "a8ad0670028620121f51850ce5b6cab5",
- "spanId": "fbf21a1849fdadac"
+ "type": "BadRequestError",
+ "title": "Error has occured with password",
+ "status": 400,
+ "instance": "POST /api/v1/Users/Login",
+ "ErrorDetail": {
+ "message": "user_password_incorrect",
+ "en": "Password of user is incorrect",
+ "vi": "Mật khẩu của Người dùng không đúng"
},
- "errors": [
- {
- "reasons": [
- {
- "message": "user_password_incorrect",
- "en": "Password of user is incorrect",
- "vi": "Mật khẩu của Người dùng không đúng"
- }
- ]
- }
- ],
- "statusCode": 400,
- "message": "One or several errors have occured"
+ "requestId": "0HNC1ERHD53E2:00000001",
+ "traceId": "fa7b365b49f1b554a9cfabd978d858c8",
+ "spanId": "8623dbe038a6dede"
}
```
@@ -192,117 +142,104 @@ http://localhost:8080/api/Users?PageSize=2&Search.Keyword=N%E1%BA%B5ng&Search.Ta
# Sơ lượt về Cấu trúc :mag_right:
-**_Domain_**: Tầng Domain đóng vai trò như phần trung tâm trong Clean Architecture, các thành phần bao gồm:
-
-- Aggregates : Là nhóm các entity có mối liên quan với nhau, các value object, enum, interface và Specification pattern (tùy chọn) các bạn có thể đọc thêm ở [https://github.com/ardalis/Specification](https://github.com/ardalis/Specification). Nó có một số nguyên tắc bắt buộc trong quá trình tương tác giữa các root và các thành phần quan hệ của nó và còn nhiều thức khác.
-- Exceptions : Tạo ra custom exception cho tầng Domain
-
- 📁 Domain\
- ├── 📁 Aggregates\
- ├── 📁 AuditLogs\
- ├── 📁 Regions\
- ├── 📁 Roles\
- ├── 📁 Users\
- ├── 📁 Common\
- ├── 📁 ElasticConfigurations\
- ├── 📁 Specs\
- ├── 📁 Exceptions
-
-_Nó không hề phụ thuộc vào bất kể layer nào_
-
-**_Application_**: Tầng ứng dụng đóng vai trò quang trọng trong clean architecture, Nó chứa các business logic, business rule cho ứng dụng và có các thành phần cấu thành như sau:
-
-- Thư mục Common:
- - Behaviors : Nơi chứa các cross-cutting concern có thể kể đến như : error logging, validation, performance logging...
- - DomainEventHandler: Nơi implemnet các logic cho gửi event nội bộ.
- - Exceptions: Chứa các exception cho tầng Application.
- - Interfaces: Tạo ra các interfaces cho repositories và các services bên ngoài.
- - Mapping: Chứa các mapping object.
-- Thư mục Features: Gom nhóm các modules với command/queries sử dụng CQRS pattern và MediaR
-
- - Common : Đây là nơi mà mình đặt những thứ chung của tất cả các module lại với nhau để dễ dàng cho việc tái sử dụng như là Mapping với Automapper, Request, Response
-
- 📁 Application\
- ├── 📁 Common\
- ├── 📁 Auth\
- ├── 📁 Behaviors\
- ├── 📁 DomainEventHandler\
- ├── 📁 Exceptions\
- ├── 📁 Interface\
- ├── 📁 Registers\
- ├── 📁 Services\
- ├── 📁 UnitofWorks\
- ├── 📁 Mapping\
- ├── 📁 QueryStringProcessing\
- ├── 📁 Security\
- ├── 📁 Features\
- ├── 📁 AuditLogs\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Common\
- ├── 📁 Mapping\
- ├── 📁 Projections\
- ├── 📁 Validators\
- ├── 📁 Permissions\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Regions\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Roles\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Users\
- ├── 📁 Commands\
- ├── 📁 Queries\
-
-_Chỉ phụ thuộc vào tầng Domain_
-
-**_Infrastucture_** : Tầng Infrastucture là nơi chứa các kết nối với database và các server bên thứ 3, nó có chứa một số thành phần sau đây:
-
-- Thư mục Data:
- - Configurations : Chứa các tùy chỉnh cho các entity ở tầng Domain.
- - Interceptors : Nơi chứa các hành động trước và sau khi EF Core thực hiện lưu các thay đổi
- - Migrations: Chứa các file migration cho các tiếp cận bằng code first trong EF.
-- Services : Nơi implement các interface ở tầng Application.
-- UnitOfWorks: Nơi implement các repository interface ở tầng Application.
-
- 📁 Infrastructure\
- ├── 📁 Constants\
- ├── 📁 Data\
- ├── 📁 Configurations\
- ├── 📁 Identity\
- ├── 📁 Regions\
- ├── :page_facing_up: AuditLogConfiguration.cs\
- ├── :page_facing_up: DeadLetterQueueConfiguration.cs\
- ├── 📁 Interceptors\
- ├── 📁 Migrations\
- ├── 📁 Seeds\
- ├── :page_facing_up: DatabaseSettings.cs\
- ├── :page_facing_up: DbInitializer.cs\
- ├── :page_facing_up: DesignTimeDbContextFactory.cs\
- ├── :page_facing_up: RegionDataSeeding.cs\
- ├── :page_facing_up: TheDbContext.cs\
- ├── :page_facing_up: ValidateDatabaseSetting.cs\
- ├── 📁 Services\
- ├── 📁 UnitofWork\
-
-_Phụ thuộc vào tầng Application và Domain_
-
-**_Api_**: Chứa các Api endpoint.
-
- 📁 Api\
- ├── 📁 Converters\
- ├── 📁 Endpoints\
- ├── 📁 Extensions\
- ├── 📁 Middlewares\
- ├── 📁 Resources\
- ├── 📁 Settings\
- ├── 📁 wwwroot\
-
-_Phụ thuộc vào tầng Application and Infrastructure_
-
-**_Contract_** : Chứa shared components cho các tầng Application, Infrastructure and API.
+```
+/Domain
+ ├── /Aggregates/ # Domain aggregates (entities with business rules)
+ └── /Common/ # Shared domain logic and base types
+ ├── AggregateRoot.cs # Base class for aggregate roots
+ ├── BaseEntity.cs # Base class for entities
+ └── UlidToStringConverter.cs # Value converter for ULIDs
+```
+
+```
+/Application
+ ├── /Common
+ │ ├── /Auth/ # custom authorization & policies in .NET Core
+ │ ├── /Behaviors/ # MediatR pipeline behaviors (CQRS cross‑cutting)
+ │ ├── /DomainEventHandlers/ # handlers for raising/domain events
+ │ ├── /Errors/ # error types for Result‑pattern responses
+ │ ├── /Exceptions/ # domain/application exception definitions
+ │ ├── /Extensions/ # helper methods (pagination, LHS parsing, etc.)
+ │ ├── /Interfaces/ # application‑level contracts & abstractions
+ │ ├── /QueryStringProcessing/ # validation logic for query‑string params
+ │ └── /Security/ # security attributes (e.g. [Authorize], roles)
+ ├── /Features/ # CQRS + MediatR pattern modules
+ │ ├── AuditLogs/ # commands & queries for audit‑trail
+ │ ├── Common/ # shared feature utilities
+ │ ├── Permissions/ # manage app permissions
+ │ ├── QueueLogs/ # logging for background/queued jobs
+ │ ├── Regions/ # region‑related commands & queries
+ │ ├── Roles/ # role management (CRUD, assignments)
+ │ └── Users/ # user‑centric commands & queries
+ └── DependencyInjection.cs # Registration of all Application services into DI
+
+```
+
+```
+/Infrastructure
+ ├── /Constants/ # application-wide constants & credential definitions
+ │ └── Credential.cs # strongly-typed credentials (keys, secrets, etc.)
+ │
+ ├── /Data/ # EF Core data layer: context, migrations, seeding, configs
+ │ ├── /Configurations/ # IEntityTypeConfiguration<> implementations
+ │ ├── /Interceptors/ # DbCommand/SaveChanges interceptors (logging, auditing)
+ │ ├── /Migrations/ # EF Core migration files
+ │ ├── /Seeds/ # seed-data providers for initial data
+ │ ├── DatabaseSettings.cs # POCO for database connection/settings
+ │ ├── DbInitializer.cs # ensures DB is created & seeded on startup
+ │ ├── DesignTimeDbContextFactory.cs # design-time factory for `dotnet ef` commands
+ │ ├── RegionDataSeeding.cs # specific seed logic for Regions table
+ │ ├── TheDbContext.cs # your `DbContext` implementation
+ │ └── ValidateDatabaseSetting.cs # runtime validation of DB settings
+ │
+ ├── /Services/ # external/infrastructure services & integrations
+ │ ├── /Aws/ # AWS SDK wrappers (S3, SNS, etc.)
+ │ ├── /Cache/ # caching implementations (Redis, MemoryCache)
+ │ ├── /ElasticSearch/ # Elasticsearch client & indexing/search logic
+ │ ├── /Hangfire/ # background-job scheduler configuration
+ │ ├── /Identity/ # identity provider integrations (JWT, OAuth)
+ │ ├── /Mail/ # SMTP, SendGrid, or other mail-sending services
+ │ ├── /Queue/ # Request queueing with Redis
+ │ ├── /Token/ # token-related services and helpers
+ │ ├── ActionAccessorService.cs # grabs current `HttpContext` action info
+ │ └── CurrentUserService.cs # resolves authenticated user details
+ │
+ ├── /UnitOfWorks/ # Unit-of-Work & repository abstractions
+ │ ├── /CachedRepositories/ # repositories with built-in caching layers
+ │ ├── /Repositories/ # concrete repository implementations
+ │ ├── RepositoryExtension.cs # extension methods for IRepository
+ │ └── UnitOfWork.cs # coordinates multiple repository commits
+ │
+ └── DependencyInjection.cs # registration of all Infrastructure services into DI
+```
+
+```
+/Api
+ ├── /common/ # shared helpers, configurations for API layer
+ │
+ ├── /Converters/ # JSON/string converters for date types
+ │ ├── DateTimeConverter.cs # custom converter for System.DateTime
+ │ └── DateTimeOffsetConverter.cs # custom converter for System.DateTimeOffset
+ │
+ ├── /Endpoints/ # minimal‑API endpoint definitions
+ │
+ ├── /Extensions/ # extension methods (IServiceCollection, HttpContext, etc.)
+ │
+ ├── /Middlewares/ # custom middleware (error handling, logging, auth, etc.)
+ │
+ ├── /Resources/ # static resource files
+ │ └── /Translations/ # localization .resx files
+ │ ├── Message.en.resx # English resource strings
+ │ └── Message.vi.resx # Vietnamese resource strings
+ │
+ ├── /Settings/ # POCOs bound to appsettings.json sections
+ │ ├── OpenApiSettings.cs # swagger/OpenAPI configuration
+ │ ├── OpenTelemetrySettings.cs # OTEL exporter/tracing settings
+ │ └── SerilogSettings.cs # Serilog sink & logging configuration
+ │
+ └── /wwwroot/ # publicly served static content
+ └── /Templates/ # email/html templates, static assets
+```
# Bắt đầu thôi nào
@@ -325,33 +262,6 @@ Chỉnh sửa connection string của PostgreSQL (Bởi vì template này đang
},
```
-Nếu các bạn muốn sử dụng các database khác thì chỉ cần chỉnh lại một số đoạn code nhỏ ở DependencyInjection.cs trong Infrastructure.
-
-```csharp
- services.AddDbContextPool(
- (sp, options) =>
- {
- NpgsqlDataSource npgsqlDataSource = sp.GetRequiredService();
- options
- .UseNpgsql(npgsqlDataSource)
- .AddInterceptors(
- sp.GetRequiredService(),
- sp.GetRequiredService()
- );
- }
- );
-```
-
-Chỉ cần thay thế UseNpgsql với bất kể database nào mà bạn muốn :smile:.
-
-Sau đó đi tới Data, vào file DesignTimeDbContextFactory
-
-```
-builder.UseNpgsql(connectionString);
-```
-
-Thay thế như file DependencyInjection.cs ở trên :point_up_2:.
-
Bước tiếp theo nha :point_right::
```
@@ -363,7 +273,7 @@ cd Dockers/MinioS3
```
MINIO_ROOT_USER=the_template_storage
-MINIO_ROOT_PASSWORD=storage@the_template1`
+MINIO_ROOT_PASSWORD=storage@the_template1
```
@@ -374,9 +284,13 @@ docker-compose up -d
```
-Đây là một cách khá hay để sử dụng AWS miễn phí với máy tính của bạn :dollar: Tui đã học được cách này lúc còn ở công ty cũ :pray:
+Truy cập http://localhost:9001 và đăng nhập
-_Mà nè nếu mấy fen đã có sẳn con AWS rồi thì khỏi cần làm mấy cái này nha_
+
+
+Tạo ra cặp key
+
+
Chỉnh lại setting ở your appsettings.json
@@ -392,8 +306,6 @@ Chỉnh lại setting ở your appsettings.json
},
```
-Các bạn có thể tạo ra cặp access và Secret key bằng giao diện ở [http://localhost:9001](http://localhost:9001)
-
Bước cuối nha
```
@@ -402,7 +314,7 @@ dotnet run
```
-vào swagger ui ở "localhost:8080/docs"
+vào swagger ui ở http://localhost:8080/docs
Xong rồi đó :tada: :tada: :tada: :clap:
@@ -410,20 +322,22 @@ Xong rồi đó :tada: :tada: :tada: :clap:
### Authorize
-Để phân quyền cho nó sử dụng AuthorizeBy nha gắn nó vô trên đầu Endpoint (Controller)
+Để phân quyền cho nó sử dụng RequireAuth vào minimal api,
+tham số permissions là kiểu string, các quyền được phân tách bởi dấu phẩy.
```csharp
- [HttpPost(Router.UserRoute.Users)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "create User")]
- [AuthorizeBy(permissions: $"{ActionPermission.create}:{ObjectPermission.user}")]
- public override async Task> HandleAsync(
- [FromForm] CreateUserCommand request,
- CancellationToken cancellationToken = default
- )
+app.MapPost(Router.UserRoute.Users, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
{
- CreateUserResponse user = await sender.Send(request, cancellationToken);
- return this.Created201(Router.UserRoute.GetRouteName, user.Id, user);
- }
+ Summary = "Create user 🧑",
+ Description = "Creates a new user and returns the created user details.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .WithRequestValidation()
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Create, PermissionResource.User)
+ )
+ .DisableAntiforgery();
```
**_Tạo ra role kèm theo permission_**
@@ -447,37 +361,17 @@ Xong rồi đó :tada: :tada: :tada: :clap:
### Thêm một quyền mới vào ứng dụng
-Vào thư mục Constants trong Infrastructure mở file Credential.cs và chú ý tới PermissionGroups
+Vào thư mục Constants trong Infrastructure mở file Credential.cs và chú ý tới permissions
```csharp
- public static readonly Dictionary PermissionGroups =
- new()
- {
- {
- nameof(User) + "s",
-
- [
- CreatePermission(ActionPermission.create, ObjectPermission.user),
- CreatePermission(ActionPermission.update, ObjectPermission.user),
- CreatePermission(ActionPermission.delete, ObjectPermission.user),
- CreatePermission(ActionPermission.list, ObjectPermission.user),
- CreatePermission(ActionPermission.detail, ObjectPermission.user),
- ]
- },
- {
- nameof(Role) + "s",
-
- [
- CreatePermission(ActionPermission.create, ObjectPermission.role),
- CreatePermission(ActionPermission.update, ObjectPermission.role),
- CreatePermission(ActionPermission.delete, ObjectPermission.role),
- CreatePermission(ActionPermission.list, ObjectPermission.role),
- CreatePermission(ActionPermission.detail, ObjectPermission.role),
- ]
- },
-```
-
-Chú ý rằng, key là tên của entity cộng thêm "s" và value là danh sách các permission cho entity đó.
+public static readonly List>> permissions =
+ [
+ Permission.CreatebasicPermissions(PermissionResource.User),
+ Permission.CreatebasicPermissions(PermissionResource.Role),
+ ];
+```
+
+Chú ý rằng, key là quyền chính còn value là danh sách quyền liên quan của nó
Permission được gộp từ hành động và tên entity.
VD:
@@ -486,27 +380,28 @@ VD:
create:user
```
-Đây là nơi để tạo ra các permission từ lớp ActionPermission và ObjectPermission.
+Đây là nơi để tạo ra các PermissionAction từ lớp ActionPermission và PermissionResource.
```csharp
-public static class ActionPermission
+public class PermissionAction
{
- public const string create = nameof(create);
- public const string update = nameof(update);
- public const string delete = nameof(delete);
- public const string detail = nameof(detail);
- public const string list = nameof(list);
- public const string testa = nameof(testa);
+ public const string Create = nameof(Create);
+ public const string Update = nameof(Update);
+ public const string Delete = nameof(Delete);
+ public const string Detail = nameof(Detail);
+ public const string List = nameof(List);
+ public const string Test = nameof(Test);
+ public const string Testing = nameof(Testing);
}
-public static class ObjectPermission
+public class PermissionResource
{
- public const string user = nameof(user);
- public const string role = nameof(role);
+ public const string User = nameof(User);
+ public const string Role = nameof(Role);
}
```
-Tạo ra permission mới sau đó thêm nó vào PermissionGroups dictionary và chạy lại ứng dụng.
+Tạo ra permission mới sau đó thêm nó vào permission, tắt và chạy lại ứng dụng.
@@ -599,15 +494,10 @@ Các bạn có thể tìm hiểu thêm ỏ một số link sau đây
Mình thiết kế input đầu vào dựa trên [Strapi filter](https://docs.strapi.io/dev-docs/api/rest/filters-locale-publication)
-Mình đã nhúng sẳn filter tự động vào tất cả các hàm lấy danh sách ở lớp Repository
+Mình đã nhúng sẳn filter tự động vào tất cả các hàm lấy danh sách chỉ cần gọi
```csharp
- await unitOfWork
- .Repository()
- .CursorPagedListAsync(
- new ListUserSpecification(),
- query.ValidateQuery().ValidateFilter(typeof(ListUserResponse))
- );
+unitOfWork.DynamicReadOnlyRepository()
```
@@ -619,24 +509,27 @@ Offset and cursor pagination được tích hợp sẳn trong template.
Để sử dựng offset pagination thêm dòng sau vào code
```csharp
- await unitOfWork
- .Repository()
- .PagedListAsync(
- new ListUserSpecification(),
- query.ValidateQuery().ValidateFilter(typeof(ListUserResponse)),
- cancellationToken
- );
+var response = await unitOfWork
+ .DynamicReadOnlyRepository(true)
+ .PagedListAsync(
+ new ListUserSpecification(),
+ query,
+ ListUserMapping.Selector(),
+ cancellationToken: cancellationToken
+ );
```
Để sử dụng cursor pagination thêm dòng sau vào code
```csharp
- await unitOfWork
- .Repository()
- .CursorPagedListAsync(
- new ListUserSpecification(),
- query.ValidateQuery().ValidateFilter(typeof(ListUserResponse))
- );
+var response = await unitOfWork
+ .DynamicReadOnlyRepository(true)
+ .CursorPagedListAsync(
+ new ListUserSpecification(),
+ query,
+ ListUserMapping.Selector(),
+ cancellationToken: cancellationToken
+ );
```
```json
@@ -644,75 +537,34 @@ Offset and cursor pagination được tích hợp sẳn trong template.
"results": {
"data": [
{
- "firstName": "Sang",
- "lastName": "Tran",
- "username": "sang.tran",
- "email": "sang.tran@gmail.com",
- "phoneNumber": "0925123123",
- "dayOfBirth": "2024-12-31T17:00:00Z",
- "gender": 1,
- "province": {
- "code": "79",
- "name": "Hồ Chí Minh",
- "nameEn": "Ho Chi Minh",
- "fullName": "Thành phố Hồ Chí Minh",
- "fullNameEn": "Ho Chi Minh City",
- "customName": "Thành phố Hồ Chí Minh",
- "createdBy": "SYSTEM",
- "updatedBy": "01JD936AXSDNMQ713P5XMVRQDV",
- "updatedAt": "2024-11-24T05:50:26Z",
- "id": "01JAZDXCWY3Z9K3XS0AYZ733NF",
- "createdAt": "2024-11-09T13:13:27Z"
- },
- "district": {
- "code": "783",
- "name": "Củ Chi",
- "nameEn": "Cu Chi",
- "fullName": "Huyện Củ Chi",
- "fullNameEn": "Cu Chi District",
- "customName": null,
- "createdBy": "SYSTEM",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JAZDXDGSP0J0XF10836TR3QY",
- "createdAt": "2024-11-09T13:13:27Z"
- },
- "commune": {
- "code": "27505",
- "name": "Trung Lập Thượng",
- "nameEn": "Trung Lap Thuong",
- "fullName": "Xã Trung Lập Thượng",
- "fullNameEn": "Trung Lap Thuong Commune",
- "customName": null,
- "createdBy": "SYSTEM",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JAZDXEAS1A1RJ4FSTWKW7RJA",
- "createdAt": "2024-11-09T13:13:27Z"
- },
- "street": "abc",
- "avatar": "http:localhost:9000/the-template-project/Users/avatarcute2.1737642177170.jpg?AWSAccessKeyId=bAWMwoigEBePW8tyS4et&Expires=1737896145&Signature=X9c8uoe%2FiGmYZkixo4MdEsXaeog%3D",
+ "firstName": "sang",
+ "lastName": "minh",
+ "username": "sang.minh123",
+ "email": "sang.minh123@gmail.com",
+ "phoneNumber": "0925123320",
+ "dayOfBirth": "1990-01-09T17:00:00Z",
+ "gender": 2,
+ "address": "abcdef,Xã Phước Vĩnh An,Huyện Củ Chi,Thành phố Hồ Chí Minh",
+ "avatar": null,
"status": 1,
"createdBy": "01JD936AXSDNMQ713P5XMVRQDV",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JJ9RPW9B0FJV39JSNNT5ZKSB",
- "createdAt": "2025-01-23T14:22:56Z"
+ "updatedBy": "01JD936AXSDNMQ713P5XMVRQDV",
+ "updatedAt": "2025-04-16T14:26:01Z",
+ "id": "01JRZFDA1F7ZV4P7CFS5WSHW8A",
+ "createdAt": "2025-04-16T14:17:54Z"
}
],
"paging": {
"pageSize": 1,
- "totalPage": 21,
+ "totalPage": 3,
"hasNextPage": true,
"hasPreviousPage": false,
- "cursor": {
- "before": null,
- "after": "9x1HiQ0V+K5Dadbuh3QjfggTc3Ap7o9dgd5FbAJlEsWEyBO33wPBu66g+D0sb26sUncnOcmKHAWlQD5RHDiE44qCV+K11jKIjwOVSFY8XD3OsiA8biRl6dKTNvWNaoYhNh30nNwhHzWTAXQVYunsg0k9gykJWKbSzrI="
- }
+ "before": null,
+ "after": "q+blUlBQci5KTSxJTXEsUbJSUDIyMDLVNTDRNTQLMTK0MjS3MjXRMzG3tDAx1DYwtzIwUNIB6/FMASk2MPQKinJzcTR0M48KMwkwd3YLNg0P9gi3cFTi5aoFAA=="
}
},
- "statusCode": 200,
- "message": "SUCCESS"
+ "status": 200,
+ "message": "Success"
}
```
diff --git a/README.md b/README.md
index a681c6ac..5ff4d85d 100644
--- a/README.md
+++ b/README.md
@@ -3,28 +3,26 @@
[English](README.md) | [Vietnamese](README-VIETNAMESE.md)
#
-
-[](LICENSE) 
-
-
+
+ [](LICENSE)      
# Table of Contents
+
- [1. Language](#1-languages)
- [2. Badges](#2-badge)
- [3. Table of Contents](#3-table-of-contents)
- [2. .NET Core Clean Architecture The Template Introduction](#2-net-core-clean-architecture-the-template-introduction)
-- [3. Give a Star! ⭐](#3-give-a-star-)
+- [3. Give a Star! ⭐](#3-give-a-star)
- [4. What is Clean Architecture?](#4-what-is-clean-architecture)
- - [4.0.1 Advandtage](#401-advandtage)
- - [4.0.2. Disadvandtage](402-disadvandtage)
-- [5. Features :rocket:](#5-features-rocket)
-- [6. Demo :fire:](#6-demo-fire)
- - [6.0.1. Authentication](#601-authentication)
- - [6.0.2. Dynamic search and sort](#602-dynamic-search-and-sort)
- - [6.0.3. Cross-cutting concerns](#603-cross-cutting-concerns)
- - [6.0.4. AWS S3 by Minio](#604-aws-s3-by-minio)
- - [6.0.5. Automatic Translatable Message](#605-automatic-translatable-message)
-- [7. Structure Overview :mag\_right:](#7-structure-overview-mag_right)
+ - [4.0.1. Pros](#401-pros)
+ - [4.0.2. Cons](#402-cons)
+- [5. Features :rocket:](#5-features)
+- [6. Demo :fire:](#6-demo)
+ - [6.0.1. Apis](#601-api)
+ - [6.0.2. Tracing](#602-tracing)
+ - [6.0.3. AWS S3 Cloud](#603-aws-s3-by-minio)
+ - [6.0.4. Message](#604-automatic-translatable-message)
+- [7. Structure Overview :mag_right:](#7-structure-overview)
- [8. Getting started](#8-getting-started)
- [8.1. Run .NET Core Clean Architecture Project](#81-run-net-core-clean-architecture-project)
- [8.2. Basic Usage](#82-basic-usage)
@@ -38,263 +36,201 @@
- [11. Credits](#11-credits)
- [12. Licence](#12-licence)
-
# 2. .NET Core Clean Architecture The Template Introduction
This template is designed for backend developer working with ASP.NET Core. It provides you an efficient way to build enterprise applications effortlessly by leveraging advantages of clean architecture structre and .NET Core framework.
-With this template, You'll benefit from zero configuration, and don't need to worry about struture, settings, environments or best practices for web APIs, because everything is already set up :smiley:.
+With this template, everything is already set up :smiley:.
-# 3. Give a Star! ⭐
+# 3. Give a Star
If you find this template helpful and learn something from it, please consider giving it a :star:.
-Your support motivates me to deliver even better features and improvements in future versions.
-
# 4. What is Clean Architecture?
-Clean Architecture is a software design philosophy introduced by Robert C. Martin (Uncle Bob). It emphasizes the separation of concerns and promotes the organization of code into layers, each with distinct responsibilities. The architecture's primary goal is to create systems that are independent of frameworks, UI, databases, and external agencies, allowing flexibility, scalability, and testability.
-
-At its core, Clean Architecture organizes code into concentric circles, with each layer having a specific purpose.
+Clean Architecture is a software design approach introduced by Robert C. Martin (Uncle Bob) that emphasizes the separation of concerns by organizing code into concentric layers. The core idea is to keep business logic independent from external frameworks, databases, and user interfaces, promoting a system that's easier to maintain, test, and evolve over time.

-The dependency rule states that code dependencies should only point inward, ensuring that inner layers remain isolated from external layers.
-
-### 4.0.1 Advandtage
+### 4.0.1 Pros
-- **_Seperation of Concerns_**: Each layer is responsible for a specific aspect of the application, making the code easier to understand and maintain.
-- **_Testability_**: Since business logic is decoupled from frameworks and UI, unit testing becomes simpler and more reliable.
-- **_Flexibility and Adaptability_**: Changes to the framework, database, or external systems have minimal impact on the core logic.
-- **_Reusability_**: Business rules can be reused across different applications or systems with minimal changes.
-- **_Scalability_**: The clear structure supports growth and the addition of new features without significant refactoring.
-- **_Framework Independence_**: Avoids being locked into a specific framework, making it easier to migrate to newer technologies.
+- **Separation of Concerns**: Each layer is responsible for a specific aspect of the application, making the code easier to understand and maintain.
+- **Testability**: Since business logic is decoupled from frameworks and UI, unit testing becomes simpler and more reliable.
+- **Flexibility and Adaptability**: Changes to the framework, database, or external systems have minimal impact on the core logic.
+- **Reusability**: Business rules can be reused across different applications or systems with minimal changes.
+- **Scalability**: The clear structure supports growth and the addition of new features without significant refactoring.
+- **Framework Independence**: Avoids being locked into a specific framework, making it easier to migrate to newer technologies.
-### 4.0.2. Disadvandtage
+### 4.0.2 Cons
-- **_Complexity_**: The layered structure can add complexity, especially for smaller projects where simpler architectures might suffice.
-- **_Initial Overhead_**: Setting up Clean Architecture requires additional effort to organize layers and follow strict design principles.
-- **_Learning Curve_**: Developers unfamiliar with the principles may take time to grasp the structure and its benefits.
-- **_Over-Engineering Risk_**: For small-scale applications, the additional layers might be unnecessary and lead to over-complication.
-- **_Performance Overhead_**: The abstraction and indirection between layers can introduce slight performance trade-offs, though typically negligible.
+- **Complexity**: The layered structure can add complexity, especially for smaller projects where simpler architectures might suffice.
+- **Initial Overhead**: Setting up Clean Architecture requires additional effort to organize layers and follow strict design principles.
+- **Learning Curve**: Developers unfamiliar with the principles may take time to grasp the structure and its benefits.
+- **Over-Engineering Risk**: For small-scale applications, the additional layers might be unnecessary and lead to over-complication.
+- **Performance Overhead**: The abstraction and indirection between layers can introduce slight performance trade-offs, though typically negligible.
-# 5. Features :rocket:
+# 5. Features
What makes this Clean Architecture template stand out from the rest?
-It not only features a scalable and maintainable structure but also includes a wide range of useful features, design patterns specifically designed for .NET Core Web API.
+### Most common features:
-It helps you to do everything effortlessly.
+- Login :closed_lock_with_key:
+- Refresh token :arrows_counterclockwise:
+- Changing user password :repeat:
+- Password reset :unlock:
+- Retrieving and Updating user profile :man_with_gua_pi_mao:
+- User CRUD :family:
+- Role CRUD 🛡️
-Let's explore the features:
+### Other awesome features:
-1. [Authentication with JWT for .NET Core](src/Infrastructure/Services/Identity/)
-1. [Authorization by Roles and Permissions](#authorize)
-1. [Dynamic Search](src/Contracts/Extensions/QueryExtensions/SearchExtensions.cs), [Dynamic Sort](src/Contracts/Extensions/QueryExtensions/SortExtension.cs) , [Dynamic Filter](#filtering),[Offset and Cursor Pagination](#pagination)
-1. [AWS S3 Storage](src/Infrastructure/Services/Aws/)
-1. [Elastic Search](src/Infrastructure/Services/Elastics/)
-1. [Domain Event](src/Application//Common/DomainEventHandlers/)
-1. [Cross-cutting Concerns](src/Application/Common/Behaviors/)
-1. [Distributed cache by Redis](src/Infrastructure/Services/DistributedCache/RedisCacheService.cs)
-1. [Handling concurrent requests with Queue (example at feature/TicketSale)](src/Infrastructure/Services/DistributedCache/)
-1. [Sending Email](src/Infrastructure/Services/Mail/)
-1. [Schedule jobs by Hangfire](src/Infrastructure/Services/Hangfires/)
-1. [Specification Pattern](src/Domain/Common/Specs/), [Uit of work and Repository pattern](src/Infrastructure/UnitOfWorks/), [Cached repository with decorator design pattern](src/Infrastructure/UnitOfWorks/CachedRepositories/)
-1. [Subcutaneous Test](tests/Application.SubcutaneousTests/)
-1. [Automactic translatable message](src/Contracts/Common/Messages/)
-1. [Open source and MIT license](#licence)
+1. [DDD (Domain Driven Design)](/src/Domain/Aggregates/) :brain:
+1. [CQRS & Mediator](/src/Application/Features/) :twisted_rightwards_arrows:
+1. [Cross-cutting concern](/src/Application/Common/Behaviors/) :scissors:
+1. [Mail Sender](/src/Infrastructure/Services/Mail/) :mailbox:
+1. [Cached Repository](/src/Infrastructure/UnitOfWorks/CachedRepositories/) :computer:
+1. [Queue](/src/Infrastructure/Services/Queue/) :walking:
+1. [Logging](/src/Api/Extensions/SerialogExtension.cs) :pencil:
+1. [Tracing](/src/Api/Extensions/OpenTelemetryExtensions.cs) :chart_with_upwards_trend:
+1. [Automatical translatable messages](https://github.com/minhsangdotcom/the-template_shared-kernel) :globe_with_meridians:
+1. [S3 AWS](/src/Infrastructure/Services/Aws/) :cloud:
-# 6. Demo :fire:
+# 6. Demo
-### 6.0.1. Authentication
+### 6.0.1. API
-```json
-{
- "results": {
- "user": {
- "firstName": "Chloe",
- "lastName": "Kim",
- "username": "chloe.kim",
- "email": "chloe.kim@gmail.com",
- "phoneNumber": "0925123123",
- "dayOfBirth": "1990-09-30T17:00:00Z",
- "gender": 2,
- "province": null,
- "district": null,
- "commune": null,
- "street": "132 Ham Nghi",
- "avatar": null,
- "status": 1,
- "createdBy": "SYSTEM",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JD936AXSDNMQ713P5XMVRQDV",
- "createdAt": "2024-12-31T08:15:50Z"
- },
- "tokenType": "Bearer",
- "accessTokenExpiredIn": 3600,
- "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIwMUpEOTM2QVhTRE5NUTcxM1A1WE1WUlFEViIsImV4cCI6MTczNzYxMjk4NH0.GMrQKpoaHcCHoKgV4WDeDPAZy_IEj7kUjh7PQRwTNG8",
- "refresh": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJmYW1pbHlfaWQiOiJaNmI2M3hQSFUxRUsyVkl5R0YyOGJpWUdNTlh1REFrdiIsInN1YiI6IjAxSkQ5MzZBWFNETk1RNzEzUDVYTVZSUURWIiwiZXhwIjoxNzM3Njk1Nzg0fQ.jZgUpT7hQ0icP7FIp3TUzXfl2I4-O5MWEZ78RlBdCiI"
- },
- "statusCode": 200,
- "message": "SUCCESS"
-}
-```
+
-### 6.0.2. Dynamic search and sort
+
-```
-http://localhost:8080/api/Users?PageSize=2&Search.Keyword=N%E1%BA%B5ng&Search.Targets=province.name&Sort=dayOfBirth
-```
-
-
+### 6.0.2. Tracing
-### 6.0.3. Cross-cutting concerns
+
-
-
-### 6.0.4. AWS S3 by Minio
+### 6.0.3. AWS S3 by Minio

-### 6.0.5. Automatic Translatable Message
+### 6.0.4. Automatic Translatable Message
```json
{
- "type": "BadRequestException",
- "trace": {
- "traceId": "a8ad0670028620121f51850ce5b6cab5",
- "spanId": "fbf21a1849fdadac"
+ "type": "BadRequestError",
+ "title": "Error has occured with password",
+ "status": 400,
+ "instance": "POST /api/v1/Users/Login",
+ "ErrorDetail": {
+ "message": "user_password_incorrect",
+ "en": "Password of user is incorrect",
+ "vi": "Mật khẩu của Người dùng không đúng"
},
- "errors": [
- {
- "reasons": [
- {
- "message": "user_password_incorrect",
- "en": "Password of user is incorrect",
- "vi": "Mật khẩu của Người dùng không đúng"
- }
- ]
- }
- ],
- "statusCode": 400,
- "message": "One or several errors have occured"
+ "requestId": "0HNC1ERHD53E2:00000001",
+ "traceId": "fa7b365b49f1b554a9cfabd978d858c8",
+ "spanId": "8623dbe038a6dede"
}
```
-# 7. Structure Overview :mag_right:
-
-**_Domain_**: Domain layer serves as the core of clean architecture application and contains key elements such as:
-
-- Aggregates : It's a way to group together related entities, value objects, enums, repository interfaces and Specfication (optional) you can learn about it at [https://github.com/ardalis/Specification](https://github.com/ardalis/Specification). With principles are established to govern the interactions between the aggregate root and its relationship and more.
-- Exceptions : Create custom exceptions for Domain layer.
-
- 📁 Domain\
- ├── 📁 Aggregates\
- ├── 📁 AuditLogs\
- ├── 📁 Regions\
- ├── 📁 Roles\
- ├── 📁 Users\
- ├── 📁 Common\
- ├── 📁 ElasticConfigurations\
- ├── 📁 Specs\
- ├── 📁 Exceptions
-
-_it is independent of any external dependencies_
-
-**_Application_**: Application layer play a important role in clean architecture, it contains business logics and rules for your application and consist of key elements such as:
-
-- Common folder:
- - Behaviors : Create cross-cutting concerns such as : error logging, validation, performance logging...
- - DomainEventHandler: the implementations of sending domain events.
- - Exceptions: Contain exceptions for use case.
- - Interfaces: Define interfaces for repositories and external services.
- - Mapping: Create mapping objects.
-- Features folder: where I group command and query handlers together for using CQRS pattern and MediaR.
-
- - Common : It's my own style, I place common things of those modules such as Mapping, validations, requests and responses and reuse it across modules.
-
- 📁 Application\
- ├── 📁 Common\
- ├── 📁 Auth\
- ├── 📁 Behaviors\
- ├── 📁 DomainEventHandler\
- ├── 📁 Exceptions\
- ├── 📁 Interface\
- ├── 📁 Registers\
- ├── 📁 Services\
- ├── 📁 UnitofWorks\
- ├── 📁 Mapping\
- ├── 📁 QueryStringProcessing\
- ├── 📁 Security\
- ├── 📁 Features\
- ├── 📁 AuditLogs\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Common\
- ├── 📁 Mapping\
- ├── 📁 Projections\
- ├── 📁 Validators\
- ├── 📁 Permissions\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Regions\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Roles\
- ├── 📁 Commands\
- ├── 📁 Queries\
- ├── 📁 Users\
- ├── 📁 Commands\
- ├── 📁 Queries\
-
-_It only depends on Domain leyer_
-
-**_Infrastucture_** : The Infrastucture layer is responsible for handling data from external sources, such as databases and web services and Consists of some key elements such as:
-
-- Data folder:
- - Configurations : contain configurations for entity at Domain layer.
- - Interceptors : Where I do logic before and after entity framework apply changes, it's an awesome feature that EF Core bring to us.
- - Migrations: contain migration files for code first approach in EF.
-- Services : Implement external services
-- UnitOfWorks: Do implementations for unit of work and repository at Application layer.
-
- 📁 Infrastructure\
- ├── 📁 Constants\
- ├── 📁 Data\
- ├── 📁 Configurations\
- ├── 📁 Identity\
- ├── 📁 Regions\
- ├── :page_facing_up: AuditLogConfiguration.cs\
- ├── :page_facing_up: DeadLetterQueueConfiguration.cs\
- ├── 📁 Interceptors\
- ├── 📁 Migrations\
- ├── 📁 Seeds\
- ├── :page_facing_up: DatabaseSettings.cs\
- ├── :page_facing_up: DbInitializer.cs\
- ├── :page_facing_up: DesignTimeDbContextFactory.cs\
- ├── :page_facing_up: RegionDataSeeding.cs\
- ├── :page_facing_up: TheDbContext.cs\
- ├── :page_facing_up: ValidateDatabaseSetting.cs\
- ├── 📁 Services\
- ├── 📁 UnitofWork
-
-_It depends on Application and Domain layer_
-
-**_Api_**: contains api endpoints and represents for main running project in application.
-
- 📁 Api
- ├── 📁 Converters
- ├── 📁 Endpoints
- ├── 📁 Extensions
- ├── 📁 Middlewares
- ├── 📁 Resources
- ├── 📁 Settings
- ├── 📁 wwwroot
-
-_It depends on Application and Infrastructure layer_
-
-**_Contract_** : Contains shared components for Application, Infrastructure and API layer.
+# 7. Structure Overview
+
+```
+/Domain
+ ├── /Aggregates/ # Domain aggregates (entities with business rules)
+ └── /Common/ # Shared domain logic and base types
+ ├── AggregateRoot.cs # Base class for aggregate roots
+ ├── BaseEntity.cs # Base class for entities
+ └── UlidToStringConverter.cs # Value converter for ULIDs
+```
+
+```
+/Application
+ ├── /Common
+ │ ├── /Auth/ # custom authorization & policies in .NET Core
+ │ ├── /Behaviors/ # MediatR pipeline behaviors (CQRS cross‑cutting)
+ │ ├── /DomainEventHandlers/ # handlers for raising/domain events
+ │ ├── /Errors/ # error types for Result‑pattern responses
+ │ ├── /Exceptions/ # domain/application exception definitions
+ │ ├── /Extensions/ # helper methods (pagination, LHS parsing, etc.)
+ │ ├── /Interfaces/ # application‑level contracts & abstractions
+ │ ├── /QueryStringProcessing/ # validation logic for query‑string params
+ │ └── /Security/ # security attributes (e.g. [Authorize], roles)
+ ├── /Features/ # CQRS + MediatR pattern modules
+ │ ├── AuditLogs/ # commands & queries for audit‑trail
+ │ ├── Common/ # shared feature utilities
+ │ ├── Permissions/ # manage app permissions
+ │ ├── QueueLogs/ # logging for background/queued jobs
+ │ ├── Regions/ # region‑related commands & queries
+ │ ├── Roles/ # role management (CRUD, assignments)
+ │ └── Users/ # user‑centric commands & queries
+ └── DependencyInjection.cs # Registration of all Application services into DI
+
+```
+
+```
+/Infrastructure
+ ├── /Constants/ # application-wide constants & credential definitions
+ │ └── Credential.cs # strongly-typed credentials (keys, secrets, etc.)
+ │
+ ├── /Data/ # EF Core data layer: context, migrations, seeding, configs
+ │ ├── /Configurations/ # IEntityTypeConfiguration<> implementations
+ │ ├── /Interceptors/ # DbCommand/SaveChanges interceptors (logging, auditing)
+ │ ├── /Migrations/ # EF Core migration files
+ │ ├── /Seeds/ # seed-data providers for initial data
+ │ ├── DatabaseSettings.cs # POCO for database connection/settings
+ │ ├── DbInitializer.cs # ensures DB is created & seeded on startup
+ │ ├── DesignTimeDbContextFactory.cs # design-time factory for `dotnet ef` commands
+ │ ├── RegionDataSeeding.cs # specific seed logic for Regions table
+ │ ├── TheDbContext.cs # your `DbContext` implementation
+ │ └── ValidateDatabaseSetting.cs # runtime validation of DB settings
+ │
+ ├── /Services/ # external/infrastructure services & integrations
+ │ ├── /Aws/ # AWS SDK wrappers (S3, SNS, etc.)
+ │ ├── /Cache/ # caching implementations (Redis, MemoryCache)
+ │ ├── /ElasticSearch/ # Elasticsearch client & indexing/search logic
+ │ ├── /Hangfire/ # background-job scheduler configuration
+ │ ├── /Identity/ # identity provider integrations (JWT, OAuth)
+ │ ├── /Mail/ # SMTP, SendGrid, or other mail-sending services
+ │ ├── /Queue/ # Request queueing with Redis
+ │ ├── /Token/ # token-related services and helpers
+ │ ├── ActionAccessorService.cs # grabs current `HttpContext` action info
+ │ └── CurrentUserService.cs # resolves authenticated user details
+ │
+ ├── /UnitOfWorks/ # Unit-of-Work & repository abstractions
+ │ ├── /CachedRepositories/ # repositories with built-in caching layers
+ │ ├── /Repositories/ # concrete repository implementations
+ │ ├── RepositoryExtension.cs # extension methods for IRepository
+ │ └── UnitOfWork.cs # coordinates multiple repository commits
+ │
+ └── DependencyInjection.cs # registration of all Infrastructure services into DI
+```
+
+```
+/Api
+ ├── /common/ # shared helpers, configurations for API layer
+ │
+ ├── /Converters/ # JSON/string converters for date types
+ │ ├── DateTimeConverter.cs # custom converter for System.DateTime
+ │ └── DateTimeOffsetConverter.cs # custom converter for System.DateTimeOffset
+ │
+ ├── /Endpoints/ # minimal‑API endpoint definitions
+ │
+ ├── /Extensions/ # extension methods (IServiceCollection, HttpContext, etc.)
+ │
+ ├── /Middlewares/ # custom middleware (error handling, logging, auth, etc.)
+ │
+ ├── /Resources/ # static resource files
+ │ └── /Translations/ # localization .resx files
+ │ ├── Message.en.resx # English resource strings
+ │ └── Message.vi.resx # Vietnamese resource strings
+ │
+ ├── /Settings/ # POCOs bound to appsettings.json sections
+ │ ├── OpenApiSettings.cs # swagger/OpenAPI configuration
+ │ ├── OpenTelemetrySettings.cs # OTEL exporter/tracing settings
+ │ └── SerilogSettings.cs # Serilog sink & logging configuration
+ │
+ └── /wwwroot/ # publicly served static content
+ └── /Templates/ # email/html templates, static assets
+```
# 8. Getting started
@@ -317,33 +253,6 @@ Modify PostgreSQL connection string (this template is using PostgreSQL currently
},
```
-If you want to use difference database then just customize a few things at DependencyInjection.cs in Infrastructure layer
-
-```csharp
- services.AddDbContextPool(
- (sp, options) =>
- {
- NpgsqlDataSource npgsqlDataSource = sp.GetRequiredService();
- options
- .UseNpgsql(npgsqlDataSource)
- .AddInterceptors(
- sp.GetRequiredService(),
- sp.GetRequiredService()
- );
- }
- );
-```
-
-Simply Replace UseNpgsql with whatever database you want :smile:.
-
-Navigate to Data folder, and then open DesignTimeDbContextFactory file
-
-```
-builder.UseNpgsql(connectionString);
-```
-
-Replace it as you did above :point_up_2:.
-
The next step :point_right::
```
@@ -355,7 +264,7 @@ change mino username and password at .env if needed and you're gonna use it for
```
MINIO_ROOT_USER=the_template_storage
-MINIO_ROOT_PASSWORD=storage@the_template1`
+MINIO_ROOT_PASSWORD=storage@the_template1
```
@@ -366,11 +275,15 @@ docker-compose up -d
```
-This is a really good trick for using AWS for free :dollar: that I learned from my previous company :pray:
+Access Minio S3 Web UI at http://localhost:9001 and login
+
+
-_Note that If you already have similar one You can skip this step._
+Create a pairs of key like
-Modify this json setting at your appsettings.json
+
+
+input the keys at your appsettings.json
```json
"S3AwsSettings": {
@@ -384,8 +297,6 @@ Modify this json setting at your appsettings.json
},
```
-You can create access and secret key pair with Web UI manager at [http://localhost:9001](http://localhost:9001)
-
The final step
```
@@ -393,7 +304,7 @@ cd src/Api
dotnet run
```
-"localhost:8080/docs" is swagger UI path
+http://localhost:8080/docs is swagger UI path
Congrat! you are all set up :tada: :tada: :tada: :clap:
@@ -401,20 +312,21 @@ Congrat! you are all set up :tada: :tada: :tada: :clap:
### 8.2.1. Authorize
-To Achieve this, let's add AuthorizeBy attribute on controller
+To Achieve this, let's add RequireAuth on minimal api, permissions parameter is string and seperate each permission by comma "create:user,update:use".
```csharp
- [HttpPost(Router.UserRoute.Users)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "create User")]
- [AuthorizeBy(permissions: $"{ActionPermission.create}:{ObjectPermission.user}")]
- public override async Task> HandleAsync(
- [FromForm] CreateUserCommand request,
- CancellationToken cancellationToken = default
- )
+app.MapPost(Router.UserRoute.Users, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
{
- CreateUserResponse user = await sender.Send(request, cancellationToken);
- return this.Created201(Router.UserRoute.GetRouteName, user.Id, user);
- }
+ Summary = "Create user 🧑",
+ Description = "Creates a new user and returns the created user details.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .WithRequestValidation()
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Create, PermissionResource.User)
+ )
+ .DisableAntiforgery();
```
### 8.2.2. Create role with permissions:
@@ -440,38 +352,17 @@ Json payload is like
### 8.2.3. How to add new permissions in your app
-To get this, let's navigate to constants folder in Infrastructure layer, then open Credential.cs file and pay your attention on PermissionGroups Dictionary
+To get this, let's navigate to constants folder in Infrastructure layer, then open Credential.cs file and pay your attention on permissions dictionary
```csharp
- public static readonly Dictionary PermissionGroups =
- new()
- {
- {
- nameof(User) + "s",
-
- [
- CreatePermission(ActionPermission.create, ObjectPermission.user),
- CreatePermission(ActionPermission.update, ObjectPermission.user),
- CreatePermission(ActionPermission.delete, ObjectPermission.user),
- CreatePermission(ActionPermission.list, ObjectPermission.user),
- CreatePermission(ActionPermission.detail, ObjectPermission.user),
- ]
- },
- {
- nameof(Role) + "s",
-
- [
- CreatePermission(ActionPermission.create, ObjectPermission.role),
- CreatePermission(ActionPermission.update, ObjectPermission.role),
- CreatePermission(ActionPermission.delete, ObjectPermission.role),
- CreatePermission(ActionPermission.list, ObjectPermission.role),
- CreatePermission(ActionPermission.detail, ObjectPermission.role),
- ]
- },
- };
-```
-
-Notice that, the key is your entity name plus "s" and the value is list of permission for that entity.
+public static readonly List>> permissions =
+ [
+ Permission.CreatebasicPermissions(PermissionResource.User),
+ Permission.CreatebasicPermissions(PermissionResource.Role),
+ ];
+```
+
+Notice that, the key is **primary permission** and value is **list of relative permissions**
Permission combibes from action and entity name.
For example:
@@ -480,27 +371,28 @@ For example:
create:user
```
-Let's take a look at ActionPermission and ObjectPermission class
+Let's take a look at PermissionAction and PermissionResource class
```csharp
-public static class ActionPermission
+public class PermissionAction
{
- public const string create = nameof(create);
- public const string update = nameof(update);
- public const string delete = nameof(delete);
- public const string detail = nameof(detail);
- public const string list = nameof(list);
- public const string testa = nameof(testa);
+ public const string Create = nameof(Create);
+ public const string Update = nameof(Update);
+ public const string Delete = nameof(Delete);
+ public const string Detail = nameof(Detail);
+ public const string List = nameof(List);
+ public const string Test = nameof(Test);
+ public const string Testing = nameof(Testing);
}
-public static class ObjectPermission
+public class PermissionResource
{
- public const string user = nameof(user);
- public const string role = nameof(role);
+ public const string User = nameof(User);
+ public const string Role = nameof(Role);
}
```
-Define your new one, then push it into PermissionGroups dictionary, and restart application.
+Define your new one at permissions dictionary then stop and start application again
### 8.2.4. Filtering
@@ -591,15 +483,10 @@ For more examples and get better understand, you can visit
'Cause I designed filter input based on [Strapi filter](https://docs.strapi.io/dev-docs/api/rest/filters-locale-publication)
-To Apply dynamic filter, you just call any list method at Repository class
+To Apply dynamic filter, you just call any list method at
```csharp
- await unitOfWork
- .Repository()
- .CursorPagedListAsync(
- new ListUserSpecification(),
- query.ValidateQuery().ValidateFilter(typeof(ListUserResponse))
- );
+unitOfWork.DynamicReadOnlyRepository()
```
### 8.2.5. Pagination
@@ -609,24 +496,27 @@ This template supports offset pagination and cursor pagination.
To Enable offset pagination just add this line
```csharp
- await unitOfWork
- .Repository()
- .PagedListAsync(
- new ListUserSpecification(),
- query.ValidateQuery().ValidateFilter(typeof(ListUserResponse)),
- cancellationToken
- );
+var response = await unitOfWork
+ .DynamicReadOnlyRepository(true)
+ .PagedListAsync(
+ new ListUserSpecification(),
+ query,
+ ListUserMapping.Selector(),
+ cancellationToken: cancellationToken
+ );
```
To Enable cursor pagination just add this line
```csharp
- await unitOfWork
- .Repository()
- .CursorPagedListAsync(
- new ListUserSpecification(),
- query.ValidateQuery().ValidateFilter(typeof(ListUserResponse))
- );
+var response = await unitOfWork
+ .DynamicReadOnlyRepository(true)
+ .CursorPagedListAsync(
+ new ListUserSpecification(),
+ query,
+ ListUserMapping.Selector(),
+ cancellationToken: cancellationToken
+ );
```
```json
@@ -634,92 +524,49 @@ To Enable cursor pagination just add this line
"results": {
"data": [
{
- "firstName": "Sang",
- "lastName": "Tran",
- "username": "sang.tran",
- "email": "sang.tran@gmail.com",
- "phoneNumber": "0925123123",
- "dayOfBirth": "2024-12-31T17:00:00Z",
- "gender": 1,
- "province": {
- "code": "79",
- "name": "Hồ Chí Minh",
- "nameEn": "Ho Chi Minh",
- "fullName": "Thành phố Hồ Chí Minh",
- "fullNameEn": "Ho Chi Minh City",
- "customName": "Thành phố Hồ Chí Minh",
- "createdBy": "SYSTEM",
- "updatedBy": "01JD936AXSDNMQ713P5XMVRQDV",
- "updatedAt": "2024-11-24T05:50:26Z",
- "id": "01JAZDXCWY3Z9K3XS0AYZ733NF",
- "createdAt": "2024-11-09T13:13:27Z"
- },
- "district": {
- "code": "783",
- "name": "Củ Chi",
- "nameEn": "Cu Chi",
- "fullName": "Huyện Củ Chi",
- "fullNameEn": "Cu Chi District",
- "customName": null,
- "createdBy": "SYSTEM",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JAZDXDGSP0J0XF10836TR3QY",
- "createdAt": "2024-11-09T13:13:27Z"
- },
- "commune": {
- "code": "27505",
- "name": "Trung Lập Thượng",
- "nameEn": "Trung Lap Thuong",
- "fullName": "Xã Trung Lập Thượng",
- "fullNameEn": "Trung Lap Thuong Commune",
- "customName": null,
- "createdBy": "SYSTEM",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JAZDXEAS1A1RJ4FSTWKW7RJA",
- "createdAt": "2024-11-09T13:13:27Z"
- },
- "street": "abc",
- "avatar": "http:localhost:9000/the-template-project/Users/avatarcute2.1737642177170.jpg?AWSAccessKeyId=bAWMwoigEBePW8tyS4et&Expires=1737896145&Signature=X9c8uoe%2FiGmYZkixo4MdEsXaeog%3D",
+ "firstName": "sang",
+ "lastName": "minh",
+ "username": "sang.minh123",
+ "email": "sang.minh123@gmail.com",
+ "phoneNumber": "0925123320",
+ "dayOfBirth": "1990-01-09T17:00:00Z",
+ "gender": 2,
+ "address": "abcdef,Xã Phước Vĩnh An,Huyện Củ Chi,Thành phố Hồ Chí Minh",
+ "avatar": null,
"status": 1,
"createdBy": "01JD936AXSDNMQ713P5XMVRQDV",
- "updatedBy": null,
- "updatedAt": null,
- "id": "01JJ9RPW9B0FJV39JSNNT5ZKSB",
- "createdAt": "2025-01-23T14:22:56Z"
+ "updatedBy": "01JD936AXSDNMQ713P5XMVRQDV",
+ "updatedAt": "2025-04-16T14:26:01Z",
+ "id": "01JRZFDA1F7ZV4P7CFS5WSHW8A",
+ "createdAt": "2025-04-16T14:17:54Z"
}
],
"paging": {
"pageSize": 1,
- "totalPage": 21,
+ "totalPage": 3,
"hasNextPage": true,
"hasPreviousPage": false,
- "cursor": {
- "before": null,
- "after": "9x1HiQ0V+K5Dadbuh3QjfggTc3Ap7o9dgd5FbAJlEsWEyBO33wPBu66g+D0sb26sUncnOcmKHAWlQD5RHDiE44qCV+K11jKIjwOVSFY8XD3OsiA8biRl6dKTNvWNaoYhNh30nNwhHzWTAXQVYunsg0k9gykJWKbSzrI="
- }
+ "before": null,
+ "after": "q+blUlBQci5KTSxJTXEsUbJSUDIyMDLVNTDRNTQLMTK0MjS3MjXRMzG3tDAx1DYwtzIwUNIB6/FMASk2MPQKinJzcTR0M48KMwkwd3YLNg0P9gi3cFTi5aoFAA=="
}
},
- "statusCode": 200,
- "message": "SUCCESS"
+ "status": 200,
+ "message": "Success"
}
```
-
# 9. Technology
- .NET 8
- EntityFramework core 8
-- AutoMapper
+- PostgreSQL
- Fluent validation
-- Medator
-- XUnit, FluentAssertion, Respawn
+- Mediator
+- XUnit, Shouldly, Respawn
- OpenTelemetry
-- PostgreSQL
+- Serilog
- Redis
- ElasticSearch
-- Serilog
- Docker
- Github Workflow
diff --git a/Screenshots/S3-login.png b/Screenshots/S3-login.png
new file mode 100644
index 00000000..0ba4df23
Binary files /dev/null and b/Screenshots/S3-login.png differ
diff --git a/Screenshots/create-key-s3.PNG b/Screenshots/create-key-s3.PNG
new file mode 100644
index 00000000..8d229a49
Binary files /dev/null and b/Screenshots/create-key-s3.PNG differ
diff --git a/Screenshots/crosscutting-concern.png b/Screenshots/crosscutting-concern.png
deleted file mode 100644
index c29a4c89..00000000
Binary files a/Screenshots/crosscutting-concern.png and /dev/null differ
diff --git a/Screenshots/role-api.png b/Screenshots/role-api.png
new file mode 100644
index 00000000..76bf1bcc
Binary files /dev/null and b/Screenshots/role-api.png differ
diff --git a/Screenshots/search-sort.png b/Screenshots/search-sort.png
deleted file mode 100644
index 0df0cb42..00000000
Binary files a/Screenshots/search-sort.png and /dev/null differ
diff --git a/Screenshots/trace.png b/Screenshots/trace.png
new file mode 100644
index 00000000..48433532
Binary files /dev/null and b/Screenshots/trace.png differ
diff --git a/Screenshots/user-api.png b/Screenshots/user-api.png
new file mode 100644
index 00000000..8f7abbbd
Binary files /dev/null and b/Screenshots/user-api.png differ
diff --git a/docker-compose.yml b/docker-compose.yml
index 185156a1..4bfd9315 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,6 +4,8 @@ services:
environment:
- ASPNETCORE_ENVIRONMENT=Production
- urls=http://0.0.0.0:8080
+ - S3AwsSettings__AccessKey=${S3_ACCESS_KEY}
+ - S3AwsSettings__SecretKey=${S3_SECRET_KEY}
expose:
- "8080"
networks:
@@ -13,26 +15,6 @@ services:
interval: 30s
timeout: 10s
retries: 3
- deploy:
- replicas: 2
- restart_policy:
- condition: on-failure
- delay: 5s
- max_attempts: 3
-
- nginx:
- image: nginx:latest
- container_name: nginx
- ports:
- - "80:80"
- volumes:
- - ./nginx.conf:/etc/nginx/nginx.conf:ro
- - ./logs:/var/log/nginx
- depends_on:
- - webapi
- networks:
- - the-template-network
-
networks:
the-template-network:
external: true
diff --git a/nginx.conf b/nginx.conf
deleted file mode 100644
index 38b884cb..00000000
--- a/nginx.conf
+++ /dev/null
@@ -1,37 +0,0 @@
-worker_processes auto;
-
-events {
- worker_connections 1024;
-}
-
-http {
- # Define log file paths
- access_log /var/log/nginx/access.log;
- error_log /var/log/nginx/error.log warn;
-
- upstream backend {
- # Use Docker's DNS-based service discovery
- server webapi:8080 max_fails=3 fail_timeout=60s;
- }
-
- server {
- listen 80;
-
- # Proxy configuration
- location / {
- proxy_pass http://backend;
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection keep-alive;
- proxy_set_header Host $host;
- proxy_cache_bypass $http_upgrade;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-
- # Health check endpoint
- location /health {
- proxy_pass http://backend;
- }
- }
-}
diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj
index f167d5a3..15990937 100644
--- a/src/Api/Api.csproj
+++ b/src/Api/Api.csproj
@@ -19,10 +19,11 @@
-
+
+
-
+
@@ -33,8 +34,6 @@
-
-
diff --git a/src/Api/Converters/DateTimeOffsetConvert.cs b/src/Api/Converters/DateTimeOffsetConvert.cs
index e914f8cb..dcdb7f85 100644
--- a/src/Api/Converters/DateTimeOffsetConvert.cs
+++ b/src/Api/Converters/DateTimeOffsetConvert.cs
@@ -5,13 +5,21 @@ namespace Api.Converters;
public class DateTimeOffsetConvert : JsonConverter
{
- public override DateTimeOffset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ public override DateTimeOffset Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
{
return DateTime.Parse(reader.GetString()!);
}
- public override void Write(Utf8JsonWriter writer, DateTimeOffset value, JsonSerializerOptions options)
+ public override void Write(
+ Utf8JsonWriter writer,
+ DateTimeOffset value,
+ JsonSerializerOptions options
+ )
{
writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ssZ"));
}
-}
\ No newline at end of file
+}
diff --git a/src/Api/Converters/DatetimeConverter.cs b/src/Api/Converters/DatetimeConverter.cs
index 9b2db8f6..e9a15f45 100644
--- a/src/Api/Converters/DatetimeConverter.cs
+++ b/src/Api/Converters/DatetimeConverter.cs
@@ -5,7 +5,11 @@ namespace Api.Converters;
public class DatetimeConverter : JsonConverter
{
- public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ public override DateTime Read(
+ ref Utf8JsonReader reader,
+ Type typeToConvert,
+ JsonSerializerOptions options
+ )
{
return DateTime.Parse(reader.GetString()!);
}
@@ -14,4 +18,4 @@ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializer
{
writer.WriteStringValue(value.ToUniversalTime().ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ssZ"));
}
-}
\ No newline at end of file
+}
diff --git a/src/Api/Endpoints/AuditLogs/ListAuditLog.cs b/src/Api/Endpoints/AuditLogs/ListAuditLog.cs
deleted file mode 100644
index 0b261a10..00000000
--- a/src/Api/Endpoints/AuditLogs/ListAuditLog.cs
+++ /dev/null
@@ -1,27 +0,0 @@
-using Amazon.Runtime;
-using Api.common.RouteResults;
-using Api.common.Routers;
-using Application.Features.AuditLogs.Queries;
-using Ardalis.ApiEndpoints;
-using Contracts.ApiWrapper;
-using Contracts.Dtos.Responses;
-using Mediator;
-using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
-
-namespace Api.Endpoints.AuditLogs;
-
-public class ListAuditLog(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse>
- >
-{
- [HttpGet(Router.AuditLogRoute.AuditLog)]
- [SwaggerOperation(Tags = [Router.AuditLogRoute.Tags], Summary = "List audit log")]
- public override async Task<
- ActionResult>>
- > HandleAsync(
- [FromQuery] ListAuditlogQuery request,
- CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(request, cancellationToken));
-}
diff --git a/src/Api/Endpoints/AuditLogs/ListAuditLogEndpoint.cs b/src/Api/Endpoints/AuditLogs/ListAuditLogEndpoint.cs
new file mode 100644
index 00000000..14600085
--- /dev/null
+++ b/src/Api/Endpoints/AuditLogs/ListAuditLogEndpoint.cs
@@ -0,0 +1,42 @@
+using Api.common.Documents;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
+using Api.common.Routers;
+using Application.Features.AuditLogs.Queries;
+using Contracts.ApiWrapper;
+using Contracts.Dtos.Responses;
+using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.OpenApi.Models;
+
+namespace Api.Endpoints.AuditLogs;
+
+public class ListAuditLogEndpoint : IEndpoint
+{
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.AuditLogRoute.AuditLog, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get list of audit logs",
+ Description = "Returns a list of audit logs",
+ Tags = [new OpenApiTag() { Name = Router.AuditLogRoute.Tags }],
+ Parameters = operation.AddDocs(),
+ });
+ }
+
+ private async Task<
+ Results>>, ProblemHttpResult>
+ > HandleAsync(
+ ListAuditlogQuery request,
+ [FromServices] ISender sender,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
+}
diff --git a/src/Api/Endpoints/Permissions/ListPermissionEndpoint.cs b/src/Api/Endpoints/Permissions/ListPermissionEndpoint.cs
index 80000ad2..097e4560 100644
--- a/src/Api/Endpoints/Permissions/ListPermissionEndpoint.cs
+++ b/src/Api/Endpoints/Permissions/ListPermissionEndpoint.cs
@@ -1,28 +1,41 @@
-using Api.common.RouteResults;
+using Api.common.Documents;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Permissions;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Permissions;
-public class ListPermissionEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse>
- >
+public class ListPermissionEndpoint : IEndpoint
{
- [HttpGet(Router.PermissionRoute.Permissions)]
- [SwaggerOperation(Tags = [Router.PermissionRoute.Tags], Summary = "List permissions")]
- public override async Task<
- ActionResult>>
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.PermissionRoute.Permissions, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get list of Permissions in Application 📄",
+ Description = "Retrieves a list of permissions in Application.",
+ Tags = [new OpenApiTag() { Name = Router.PermissionRoute.Tags }],
+ Parameters = operation.AddDocs(),
+ });
+ }
+
+ private async Task<
+ Results>>, ProblemHttpResult>
> HandleAsync(
- [FromQuery] ListPermissionQuery request,
+ ListPermissionQuery request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
)
{
- return this.Ok200(await sender.Send(request, cancellationToken));
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
}
}
diff --git a/src/Api/Endpoints/Regions/ListCommuneEndpoint.cs b/src/Api/Endpoints/Regions/ListCommuneEndpoint.cs
index 32dbc8f5..c95a6af1 100644
--- a/src/Api/Endpoints/Regions/ListCommuneEndpoint.cs
+++ b/src/Api/Endpoints/Regions/ListCommuneEndpoint.cs
@@ -1,25 +1,43 @@
-using Api.common.RouteResults;
+using Api.common.Documents;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Common.Projections.Regions;
using Application.Features.Regions.Queries.List.Communes;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Contracts.Dtos.Responses;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Regions;
-public class ListCommuneEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse>
- >
+public class ListCommuneEndpoint : IEndpoint
{
- [HttpGet(Router.RegionRoute.Communes)]
- [SwaggerOperation(Tags = [Router.RegionRoute.Tags], Summary = "list Commune")]
- public override async Task<
- ActionResult>>
- > HandleAsync(ListCommuneQuery request, CancellationToken cancellationToken = default) =>
- this.Ok200(await sender.Send(request, cancellationToken));
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.RegionRoute.Communes, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get list of communes 🗺️ 🇻🇳",
+ Description = "Retrieves a list of communes in Vietnam.",
+ Tags = [new OpenApiTag() { Name = Router.RegionRoute.Tags }],
+ Parameters = operation.AddDocs(),
+ });
+ }
+
+ private async Task<
+ Results>>, ProblemHttpResult>
+ > HandleAsync(
+ ListCommuneQuery request,
+ [FromServices] ISender sender,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/Regions/ListDistrictEndpoint.cs b/src/Api/Endpoints/Regions/ListDistrictEndpoint.cs
index bfef6219..3e5eff42 100644
--- a/src/Api/Endpoints/Regions/ListDistrictEndpoint.cs
+++ b/src/Api/Endpoints/Regions/ListDistrictEndpoint.cs
@@ -1,25 +1,43 @@
-using Api.common.RouteResults;
+using Api.common.Documents;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Common.Projections.Regions;
using Application.Features.Regions.Queries.List.Districts;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Contracts.Dtos.Responses;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Regions;
-public class ListDistrictEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse>
- >
+public class ListDistrictEndpoint : IEndpoint
{
- [HttpGet(Router.RegionRoute.Districts)]
- [SwaggerOperation(Tags = [Router.RegionRoute.Tags], Summary = "list District")]
- public override async Task<
- ActionResult>>
- > HandleAsync(ListDistrictQuery request, CancellationToken cancellationToken = default) =>
- this.Ok200(await sender.Send(request, cancellationToken));
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.RegionRoute.Districts, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get list of districts 🗺️ 🇻🇳",
+ Description = "Retrieves a list of districts in Vietnam.",
+ Tags = [new OpenApiTag() { Name = Router.RegionRoute.Tags }],
+ Parameters = operation.AddDocs(),
+ });
+ }
+
+ private async Task<
+ Results>>, ProblemHttpResult>
+ > HandleAsync(
+ ListDistrictQuery request,
+ [FromServices] ISender sender,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/Regions/ListProvinceEndpoint.cs b/src/Api/Endpoints/Regions/ListProvinceEndpoint.cs
index 7a0db390..bad4bdc6 100644
--- a/src/Api/Endpoints/Regions/ListProvinceEndpoint.cs
+++ b/src/Api/Endpoints/Regions/ListProvinceEndpoint.cs
@@ -1,25 +1,43 @@
-using Api.common.RouteResults;
+using Api.common.Documents;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Common.Projections.Regions;
using Application.Features.Regions.Queries.List.Provinces;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Contracts.Dtos.Responses;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Regions;
-public class ListProvinceEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse>
- >
+public class ListProvinceEndpoint : IEndpoint
{
- [HttpGet(Router.RegionRoute.Provinces)]
- [SwaggerOperation(Tags = [Router.RegionRoute.Tags], Summary = "list Province")]
- public override async Task<
- ActionResult>>
- > HandleAsync(ListProvinceQuery request, CancellationToken cancellationToken = default) =>
- this.Ok200(await sender.Send(request, cancellationToken));
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.RegionRoute.Provinces, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get list of provinces 🗺️ 🇻🇳",
+ Description = "Retrieves a list of provinces in Vietnam.",
+ Tags = [new OpenApiTag() { Name = Router.RegionRoute.Tags }],
+ Parameters = operation.AddDocs(),
+ });
+ }
+
+ private async Task<
+ Results>>, ProblemHttpResult>
+ > HandleAsync(
+ ListProvinceQuery request,
+ [FromServices] ISender sender,
+ CancellationToken cancellationToken = default
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/Roles/CreateRoleEndpoint.cs b/src/Api/Endpoints/Roles/CreateRoleEndpoint.cs
index 3cdbf5a0..96dd4049 100644
--- a/src/Api/Endpoints/Roles/CreateRoleEndpoint.cs
+++ b/src/Api/Endpoints/Roles/CreateRoleEndpoint.cs
@@ -1,30 +1,45 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Roles.Commands.Create;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Roles;
-public class CreateRoleEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse
- >
+public class CreateRoleEndpoint : IEndpoint
{
- [HttpPost(Router.RoleRoute.Roles)]
- [SwaggerOperation(Tags = [Router.RoleRoute.Tags], Summary = "create Role")]
- [AuthorizeBy(permissions: $"{ActionPermission.create}:{ObjectPermission.role}")]
- public override async Task>> HandleAsync(
- CreateRoleCommand request,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPost(Router.RoleRoute.Roles, HandleAsync)
+ .WithOpenApi(x => new OpenApiOperation(x)
+ {
+ Summary = "Create role 👮",
+ Description =
+ "Creates a new role with optional claims like permissions, etc. This endpoint can be used to define the authorization boundaries within your application. Provide a list of claims to associate them with the newly created role.",
+ Tags = [new OpenApiTag() { Name = Router.RoleRoute.Tags }],
+ })
+ .WithRequestValidation()
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Create, PermissionResource.Role)
+ );
+ }
+
+ private async Task<
+ Results>, ProblemHttpResult>
+ > HandleAsync(
+ [FromBody] CreateRoleCommand request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
)
{
- CreateRoleResponse role = await sender.Send(request, cancellationToken);
- return this.Created201(Router.RoleRoute.GetRouteName, role.Id, role);
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToCreatedResult(result.Value!.Id, Router.RoleRoute.GetRouteName);
}
}
diff --git a/src/Api/Endpoints/Roles/DeleteRoleEndpoint.cs b/src/Api/Endpoints/Roles/DeleteRoleEndpoint.cs
index d3a5aae3..bbc1c3cf 100644
--- a/src/Api/Endpoints/Roles/DeleteRoleEndpoint.cs
+++ b/src/Api/Endpoints/Roles/DeleteRoleEndpoint.cs
@@ -1,29 +1,42 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Roles.Commands.Delete;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
-using Contracts.Constants;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Roles;
-public class DeleteRoleEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult>
+public class DeleteRoleEndpoint : IEndpoint
{
- [HttpDelete(Router.RoleRoute.GetUpdateDelete)]
- [SwaggerOperation(Tags = [Router.RoleRoute.Tags], Summary = "Delete Role")]
- [AuthorizeBy(permissions: $"{ActionPermission.delete}:{ObjectPermission.role}")]
- public override async Task>> HandleAsync(
- [FromRoute(Name = RoutePath.Id)] string roleId,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapDelete(Router.RoleRoute.GetUpdateDelete, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = " Delete role 🗑️",
+ Description =
+ "Deletes an existing role by its unique ID. Once deleted, the role and its associated claims/permission will no longer be available",
+ Tags = [new OpenApiTag() { Name = Router.RoleRoute.Tags }],
+ })
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Delete, PermissionResource.Role)
+ );
+ }
+
+ private async Task> HandleAsync(
+ [FromRoute] string id,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
)
{
- await sender.Send(new DeleteRoleCommand(Ulid.Parse(roleId)), cancellationToken);
- return this.NoContent204();
+ var result = await sender.Send(new DeleteRoleCommand(Ulid.Parse(id)), cancellationToken);
+ return result.ToNoContentResult();
}
}
diff --git a/src/Api/Endpoints/Roles/GetRoleDetailEndpoint.cs b/src/Api/Endpoints/Roles/GetRoleDetailEndpoint.cs
index 8fd469dc..ff14ec45 100644
--- a/src/Api/Endpoints/Roles/GetRoleDetailEndpoint.cs
+++ b/src/Api/Endpoints/Roles/GetRoleDetailEndpoint.cs
@@ -1,24 +1,44 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Roles.Queries.Detail;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Roles;
-public class GetRoleDetailEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult>
+public class GetRoleDetailEndpoint : IEndpoint
{
- [HttpGet(Router.RoleRoute.GetUpdateDelete, Name = Router.RoleRoute.GetRouteName)]
- [SwaggerOperation(Tags = [Router.RoleRoute.Tags], Summary = "Get detail Role")]
- [AuthorizeBy(permissions: $"{ActionPermission.detail}:{ObjectPermission.role}")]
- public override async Task>> HandleAsync(
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.RoleRoute.GetUpdateDelete, HandleAsync)
+ .WithName(Router.RoleRoute.GetRouteName)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get role details 🔎",
+ Description =
+ "Retrieves detailed information about a specific role, including its name and associated claims/permissions. Use this to review or audit the role’s configurations.",
+ Tags = [new OpenApiTag() { Name = Router.RoleRoute.Tags }],
+ })
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Detail, PermissionResource.Role)
+ );
+ }
+
+ private async Task>, ProblemHttpResult>> HandleAsync(
[FromRoute] string id,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(new GetRoleDetailQuery(Ulid.Parse(id)), cancellationToken));
+ )
+ {
+ var command = new GetRoleDetailQuery(Ulid.Parse(id));
+ var result = await sender.Send(command, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/Roles/ListRoleEndpoint.cs b/src/Api/Endpoints/Roles/ListRoleEndpoint.cs
index 09680498..cd83f0ea 100644
--- a/src/Api/Endpoints/Roles/ListRoleEndpoint.cs
+++ b/src/Api/Endpoints/Roles/ListRoleEndpoint.cs
@@ -1,28 +1,46 @@
-using Api.common.RouteResults;
+using Api.common.Documents;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Roles.Queries.List;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Roles;
-public class ListRoleEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse>
- >
+public class ListRoleEndpoint : IEndpoint
{
- [HttpGet(Router.RoleRoute.Roles)]
- [SwaggerOperation(Tags = [Router.RoleRoute.Tags], Summary = "List Role")]
- [AuthorizeBy(permissions: $"{ActionPermission.list}:{ObjectPermission.role}")]
- public override async Task<
- ActionResult>>
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.RoleRoute.Roles, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get list of roles 📋",
+ Description =
+ "Retrieves a list of all available roles in the system, along with their basic information (e.g., name, assigned permissions, etc.).",
+ Tags = [new OpenApiTag() { Name = Router.RoleRoute.Tags }],
+ Parameters = operation.AddDocs(),
+ })
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.List, PermissionResource.Role)
+ );
+ }
+
+ private async Task<
+ Results>>, ProblemHttpResult>
> HandleAsync(
- [FromQuery] ListRoleQuery request,
+ ListRoleQuery request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(request, cancellationToken));
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/Roles/UpdateRoleEndpoint.cs b/src/Api/Endpoints/Roles/UpdateRoleEndpoint.cs
index fecdbf46..7109ada6 100644
--- a/src/Api/Endpoints/Roles/UpdateRoleEndpoint.cs
+++ b/src/Api/Endpoints/Roles/UpdateRoleEndpoint.cs
@@ -1,26 +1,45 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Roles.Commands.Update;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.Roles;
-public class UpdateRoleEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse
- >
+public class UpdateRoleEndpoint : IEndpoint
{
- [HttpPut(Router.RoleRoute.GetUpdateDelete)]
- [SwaggerOperation(Tags = [Router.RoleRoute.Tags], Summary = "update Role")]
- [AuthorizeBy(permissions: $"{ActionPermission.update}:{ObjectPermission.role}")]
- public override async Task>> HandleAsync(
- UpdateRoleCommand request,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPut(Router.RoleRoute.GetUpdateDelete, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Update role 📝",
+ Description =
+ "Updates an existing role's information. You can modify the name and add or remove claims/permissions. This endpoint helps ensure your authorization model stays current with your users' needs.",
+ Tags = [new OpenApiTag() { Name = Router.RoleRoute.Tags }],
+ })
+ .WithRequestValidation()
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Update, PermissionResource.Role)
+ );
+ }
+
+ private async Task>, ProblemHttpResult>> HandleAsync(
+ [FromRoute] string id,
+ [FromBody] RoleUpdateRequest request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(request, cancellationToken));
+ )
+ {
+ var command = new UpdateRoleCommand() { RoleId = id.ToString(), UpdateData = request };
+ var result = await sender.Send(command, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/User/ChangeUserPasswordEnpoint.cs b/src/Api/Endpoints/User/ChangeUserPasswordEnpoint.cs
index dcdf367c..8d340cdc 100644
--- a/src/Api/Endpoints/User/ChangeUserPasswordEnpoint.cs
+++ b/src/Api/Endpoints/User/ChangeUserPasswordEnpoint.cs
@@ -1,26 +1,38 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Commands.ChangePassword;
-using Ardalis.ApiEndpoints;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class ChangeUserPasswordEnpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult
+public class ChangeUserPasswordEnpoint : IEndpoint
{
- [HttpPut(Router.UserRoute.ChangePassword)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "reset User password")]
- [AuthorizeBy]
- public override async Task HandleAsync(
- ChangeUserPasswordCommand request,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPut(Router.UserRoute.ChangePassword, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Change user password 🔑",
+ Description =
+ "Allows an authenticated user to change their current password by providing the old and new password.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .RequireAuth();
+ }
+
+ private async Task> HandleAsync(
+ [FromBody] ChangeUserPasswordCommand request,
+ ISender sender,
CancellationToken cancellationToken = default
)
{
- await sender.Send(request, cancellationToken);
- return this.NoContent204();
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToNoContentResult();
}
}
diff --git a/src/Api/Endpoints/User/CreateUserEndpoint.cs b/src/Api/Endpoints/User/CreateUserEndpoint.cs
index c8c97834..93a27f5f 100644
--- a/src/Api/Endpoints/User/CreateUserEndpoint.cs
+++ b/src/Api/Endpoints/User/CreateUserEndpoint.cs
@@ -1,30 +1,45 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Commands.Create;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class CreateUserEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse
- >
+public class CreateUserEndpoint : IEndpoint
{
- [HttpPost(Router.UserRoute.Users)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "create User")]
- [AuthorizeBy(permissions: $"{ActionPermission.create}:{ObjectPermission.user}")]
- public override async Task>> HandleAsync(
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPost(Router.UserRoute.Users, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Create user 🧑",
+ Description = "Creates a new user and returns the created user details.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .WithRequestValidation()
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Create, PermissionResource.User)
+ )
+ .DisableAntiforgery();
+ }
+
+ private async Task<
+ Results>, ProblemHttpResult>
+ > HandleAsync(
[FromForm] CreateUserCommand request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
)
{
- CreateUserResponse user = await sender.Send(request, cancellationToken);
- return this.Created201(Router.UserRoute.GetRouteName, user.Id, user);
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToCreatedResult(result.Value!.Id, Router.UserRoute.GetRouteName);
}
}
diff --git a/src/Api/Endpoints/User/DeleteUserEndpoint.cs b/src/Api/Endpoints/User/DeleteUserEndpoint.cs
index 0fa96c21..797e1bd8 100644
--- a/src/Api/Endpoints/User/DeleteUserEndpoint.cs
+++ b/src/Api/Endpoints/User/DeleteUserEndpoint.cs
@@ -1,28 +1,40 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Commands.Delete;
-using Ardalis.ApiEndpoints;
-using Contracts.Constants;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class DeleteUserEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult
+public class DeleteUserEndpoint : IEndpoint
{
- [HttpDelete(Router.UserRoute.GetUpdateDelete)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "Delete User")]
- [AuthorizeBy(permissions: $"{ActionPermission.delete}:{ObjectPermission.user}")]
- public override async Task HandleAsync(
- [FromRoute(Name = RoutePath.Id)] string userId,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapDelete(Router.UserRoute.GetUpdateDelete, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Delete user 🗑️",
+ Description = "Deletes an existing user identified by their unique ID.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Delete, PermissionResource.User)
+ );
+ }
+
+ private async Task> HandleAsync(
+ [FromRoute] string id,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
)
{
- await sender.Send(new DeleteUserCommand(Ulid.Parse(userId)), cancellationToken);
- return this.NoContent204();
+ var result = await sender.Send(new DeleteUserCommand(Ulid.Parse(id)), cancellationToken);
+ return result.ToNoContentResult();
}
}
diff --git a/src/Api/Endpoints/User/GetUserDetailEndpoint.cs b/src/Api/Endpoints/User/GetUserDetailEndpoint.cs
index 44422b52..a8964851 100644
--- a/src/Api/Endpoints/User/GetUserDetailEndpoint.cs
+++ b/src/Api/Endpoints/User/GetUserDetailEndpoint.cs
@@ -1,28 +1,45 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Queries.Detail;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
-using Contracts.Constants;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class GetUserDetailEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult>
+public class GetUserDetailEndpoint : IEndpoint
{
- [HttpGet(Router.UserRoute.GetUpdateDelete, Name = Router.UserRoute.GetRouteName)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "Detail User")]
- [AuthorizeBy(permissions: $"{ActionPermission.create}:{ObjectPermission.user}")]
- public override async Task>> HandleAsync(
- [FromRoute(Name = RoutePath.Id)] string userId,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.UserRoute.GetUpdateDelete, HandleAsync)
+ .WithName(Router.UserRoute.GetRouteName)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get user by ID 🧾",
+ Description = "Retrieves detailed information of a user based on their unique ID.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Detail, PermissionResource.User)
+ );
+ }
+
+ private async Task<
+ Results>, ProblemHttpResult>
+ > HandleAsync(
+ [FromRoute] string id,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
- ) =>
- this.Ok200(
- await sender.Send(new GetUserDetailQuery(Ulid.Parse(userId)), cancellationToken)
- );
+ )
+ {
+ var command = new GetUserDetailQuery(Ulid.Parse(id));
+ var result = await sender.Send(command, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/User/GetUserProfileEndpoint.cs b/src/Api/Endpoints/User/GetUserProfileEndpoint.cs
index 79c5955e..0ba0b557 100644
--- a/src/Api/Endpoints/User/GetUserProfileEndpoint.cs
+++ b/src/Api/Endpoints/User/GetUserProfileEndpoint.cs
@@ -1,22 +1,36 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Queries.Profiles;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class GetUserProfileEndpoint(ISender sender)
- : EndpointBaseAsync.WithoutRequest.WithActionResult>
+public class GetUserProfileEndpoint : IEndpoint
{
- [HttpGet(Router.UserRoute.Profile)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "Profile User")]
- [AuthorizeBy]
- public override async Task>> HandleAsync(
- CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(new GetUserProfileQuery(), cancellationToken));
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.UserRoute.Profile, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get current user's profile 🧑💼",
+ Description = "Returns user profile if found",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .RequireAuth();
+ }
+
+ private async Task<
+ Results>, ProblemHttpResult>
+ > HandleAsync([FromServices] ISender sender, CancellationToken cancellationToken = default)
+ {
+ var result = await sender.Send(new GetUserProfileQuery(), cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/User/ListUserEndpoint.cs b/src/Api/Endpoints/User/ListUserEndpoint.cs
index 7902e74f..9bb8da46 100644
--- a/src/Api/Endpoints/User/ListUserEndpoint.cs
+++ b/src/Api/Endpoints/User/ListUserEndpoint.cs
@@ -1,29 +1,46 @@
-using Api.common.RouteResults;
+using Api.common.Documents;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Queries.List;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Contracts.Dtos.Responses;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class ListUserEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse>
- >
+public class ListUserEndpoint : IEndpoint
{
- [HttpGet(Router.UserRoute.Users)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "list User")]
- [AuthorizeBy(permissions: $"{ActionPermission.list}:{ObjectPermission.user}")]
- public override async Task<
- ActionResult>>
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapGet(Router.UserRoute.Users, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Get list of user 📄",
+ Description = "Retrieves a list of all registered users in the system.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ Parameters = operation.AddDocs(),
+ })
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.List, PermissionResource.User)
+ );
+ }
+
+ private async Task<
+ Results>>, ProblemHttpResult>
> HandleAsync(
- [FromQuery] ListUserQuery request,
+ ListUserQuery request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(request, cancellationToken));
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/User/LoginEndpoint.cs b/src/Api/Endpoints/User/LoginEndpoint.cs
index 019de958..cefac81e 100644
--- a/src/Api/Endpoints/User/LoginEndpoint.cs
+++ b/src/Api/Endpoints/User/LoginEndpoint.cs
@@ -1,23 +1,37 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Users.Commands.Login;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class LoginEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse
- >
+public class LoginEndpoint : IEndpoint
{
- [HttpPost(Router.UserRoute.Login)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "Logging in User")]
- public override async Task>> HandleAsync(
- LoginUserCommand request,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPost(Router.UserRoute.Login, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Login user 🔓",
+ Description = " Authenticates a user with valid credentials and returns an access",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ });
+ }
+
+ private async Task>, ProblemHttpResult>> HandleAsync(
+ [FromBody] LoginUserCommand request,
+ ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(request, cancellationToken));
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/User/RefreshUserTokenEndpoint.cs b/src/Api/Endpoints/User/RefreshUserTokenEndpoint.cs
index 6c08d4fa..5eef688b 100644
--- a/src/Api/Endpoints/User/RefreshUserTokenEndpoint.cs
+++ b/src/Api/Endpoints/User/RefreshUserTokenEndpoint.cs
@@ -1,25 +1,39 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Users.Commands.Token;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class RefreshUserTokenEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse
- >
+public class RefreshUserTokenEndpoint() : IEndpoint
{
- private readonly ISender sender = sender;
+ public EndpointVersion Version => EndpointVersion.One;
- [HttpPost(Router.UserRoute.RefreshToken)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "refresh token")]
- public override async Task>> HandleAsync(
- RefreshUserTokenCommand request,
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPost(Router.UserRoute.RefreshToken, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Refresh Access Token 🔄 🔐",
+ Description = "obtains a new pair of token by providing a valid refresh token.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ });
+ }
+
+ private async Task<
+ Results>, ProblemHttpResult>
+ > HandleAsync(
+ [FromBody] RefreshUserTokenCommand request,
+ ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(request, cancellationToken));
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/User/RequestResetUserPasswordEndpoint.cs b/src/Api/Endpoints/User/RequestResetUserPasswordEndpoint.cs
index 24ad512a..d19dec94 100644
--- a/src/Api/Endpoints/User/RequestResetUserPasswordEndpoint.cs
+++ b/src/Api/Endpoints/User/RequestResetUserPasswordEndpoint.cs
@@ -1,24 +1,37 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Users.Commands.RequestResetPassword;
-using Ardalis.ApiEndpoints;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class RequestResetUserPasswordEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult
+public class RequestResetUserPasswordEndpoint : IEndpoint
{
- [HttpPut(Router.UserRoute.RequestResetPassowrd)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "request reset User password")]
- public override async Task HandleAsync(
- RequestResetUserPasswordCommand request,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPost(Router.UserRoute.RequestResetPassowrd, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Request password reset 📧",
+ Description =
+ "Sends a reset password email to the user based on their email address.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ });
+ }
+
+ private async Task> HandleAsync(
+ [FromBody] RequestResetUserPasswordCommand request,
+ ISender sender,
CancellationToken cancellationToken = default
)
{
- await sender.Send(request, cancellationToken);
- return this.NoContent204();
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToNoContentResult();
}
}
diff --git a/src/Api/Endpoints/User/ResetUserPasswordEndpoint.cs b/src/Api/Endpoints/User/ResetUserPasswordEndpoint.cs
index ea85e679..a83c282a 100644
--- a/src/Api/Endpoints/User/ResetUserPasswordEndpoint.cs
+++ b/src/Api/Endpoints/User/ResetUserPasswordEndpoint.cs
@@ -1,24 +1,37 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
using Application.Features.Users.Commands.ResetPassword;
-using Ardalis.ApiEndpoints;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class ResetUserPasswordEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult
+public class ResetUserPasswordEndpoint : IEndpoint
{
- [HttpPut(Router.UserRoute.ResetPassowrd)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "reset User password")]
- public override async Task HandleAsync(
- ResetUserPasswordCommand request,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPut(Router.UserRoute.ResetPassowrd, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Reset user password 🔄 🔑",
+ Description =
+ "Resets a user's password using a valid token from a password reset request.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ });
+ }
+
+ private async Task> HandleAsync(
+ [FromBody] ResetUserPasswordCommand request,
+ ISender sender,
CancellationToken cancellationToken = default
)
{
- await sender.Send(request, cancellationToken);
- return this.NoContent204();
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToNoContentResult();
}
}
diff --git a/src/Api/Endpoints/User/UpdateUserEndpoint.cs b/src/Api/Endpoints/User/UpdateUserEndpoint.cs
index bc22d75e..107b21e0 100644
--- a/src/Api/Endpoints/User/UpdateUserEndpoint.cs
+++ b/src/Api/Endpoints/User/UpdateUserEndpoint.cs
@@ -1,26 +1,45 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Commands.Update;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Infrastructure.Constants;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class UpdateUserEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse
- >
+public class UpdateUserEndpoint : IEndpoint
{
- [HttpPut(Router.UserRoute.GetUpdateDelete)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "Update User")]
- [AuthorizeBy(permissions: $"{ActionPermission.update}:{ObjectPermission.user}")]
- public override async Task>> HandleAsync(
- UpdateUserCommand command,
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPut(Router.UserRoute.GetUpdateDelete, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = " Update user ✏️ 🧑💻",
+ Description = "Updates the information of an existing user identified by their ID.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .WithRequestValidation()
+ .RequireAuth(
+ permissions: Permission.Generate(PermissionAction.Update, PermissionResource.User)
+ )
+ .DisableAntiforgery();
+ }
+
+ private async Task>, ProblemHttpResult>> HandleAsync(
+ [FromRoute] string id,
+ [FromForm] UserUpdateRequest request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(command, cancellationToken));
+ )
+ {
+ var command = new UpdateUserCommand() { UserId = id.ToString(), UpdateData = request };
+ var result = await sender.Send(command, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Endpoints/User/UpdateUserProfileEndpoint.cs b/src/Api/Endpoints/User/UpdateUserProfileEndpoint.cs
index 0ee7d962..e432afd6 100644
--- a/src/Api/Endpoints/User/UpdateUserProfileEndpoint.cs
+++ b/src/Api/Endpoints/User/UpdateUserProfileEndpoint.cs
@@ -1,25 +1,41 @@
-using Api.common.RouteResults;
+using Api.common.EndpointConfigurations;
+using Api.common.Results;
using Api.common.Routers;
-using Application.Common.Auth;
using Application.Features.Users.Commands.Profiles;
-using Ardalis.ApiEndpoints;
using Contracts.ApiWrapper;
using Mediator;
+using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
-using Swashbuckle.AspNetCore.Annotations;
+using Microsoft.OpenApi.Models;
namespace Api.Endpoints.User;
-public class UpdateUserProfileEndpoint(ISender sender)
- : EndpointBaseAsync.WithRequest.WithActionResult<
- ApiResponse
- >
+public class UpdateUserProfileEndpoint : IEndpoint
{
- [HttpPut(Router.UserRoute.Profile)]
- [SwaggerOperation(Tags = [Router.UserRoute.Tags], Summary = "Update Profile User")]
- [AuthorizeBy]
- public override async Task>> HandleAsync(
+ public EndpointVersion Version => EndpointVersion.One;
+
+ public void MapEndpoint(IEndpointRouteBuilder app)
+ {
+ app.MapPut(Router.UserRoute.Profile, HandleAsync)
+ .WithOpenApi(operation => new OpenApiOperation(operation)
+ {
+ Summary = "Update user profile 🛠️ 👨 📋",
+ Description = "Updates profile information for the currently authenticated user.",
+ Tags = [new OpenApiTag() { Name = Router.UserRoute.Tags }],
+ })
+ .WithRequestValidation()
+ .RequireAuth();
+ }
+
+ private async Task<
+ Results>, ProblemHttpResult>
+ > HandleAsync(
[FromForm] UpdateUserProfileCommand request,
+ [FromServices] ISender sender,
CancellationToken cancellationToken = default
- ) => this.Ok200(await sender.Send(request, cancellationToken));
+ )
+ {
+ var result = await sender.Send(request, cancellationToken);
+ return result.ToResult();
+ }
}
diff --git a/src/Api/Extensions/ApiVersioningExtension.cs b/src/Api/Extensions/ApiVersioningExtension.cs
new file mode 100644
index 00000000..c87d4d35
--- /dev/null
+++ b/src/Api/Extensions/ApiVersioningExtension.cs
@@ -0,0 +1,30 @@
+using Asp.Versioning;
+using Asp.Versioning.Builder;
+
+namespace Api.Extensions;
+
+public static class ApiVersioningExtension
+{
+ public static IServiceCollection AddApiVersion(this IServiceCollection services)
+ {
+ services
+ .AddEndpointsApiExplorer()
+ .AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1);
+ options.ReportApiVersions = true;
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ApiVersionReader = ApiVersionReader.Combine(
+ new UrlSegmentApiVersionReader(),
+ new HeaderApiVersionReader("X-Api-Version")
+ );
+ })
+ .AddApiExplorer(config =>
+ {
+ config.GroupNameFormat = "'v'V";
+ config.SubstituteApiVersionInUrl = true;
+ });
+
+ return services;
+ }
+}
diff --git a/src/Api/Extensions/InfomationLoggingExtension.cs b/src/Api/Extensions/InfomationLoggingExtension.cs
new file mode 100644
index 00000000..4c8d2dac
--- /dev/null
+++ b/src/Api/Extensions/InfomationLoggingExtension.cs
@@ -0,0 +1,32 @@
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+
+namespace Api.Extensions;
+
+public static class InfomationLoggingExtension
+{
+ public static void AddLog(
+ this WebApplication webApplication,
+ Serilog.ILogger logger,
+ string swaggerRoute,
+ string healthCheckRoute
+ ) =>
+ webApplication.Lifetime.ApplicationStarted.Register(() =>
+ {
+ var server = webApplication.Services.GetRequiredService();
+ var addresses = server.Features.Get()?.Addresses.ToArray();
+
+ if (addresses != null && addresses.Length > 0)
+ {
+ string? url = addresses?[0];
+ string? renewUrl =
+ url?.Contains("0.0.0.0") == true ? url.Replace("0.0.0.0", "localhost") : url;
+ logger.Information("Application is running at: {Url}", renewUrl);
+ logger.Information("Swagger UI is running at: {Url}", $"{renewUrl}/{swaggerRoute}");
+ logger.Information(
+ "Application health check is running at: {Url}",
+ $"{renewUrl}{healthCheckRoute}"
+ );
+ }
+ });
+}
diff --git a/src/Api/Extensions/MiddlewareRegisterExtension.cs b/src/Api/Extensions/MiddlewareRegisterExtension.cs
index 3fa04ce7..05558dff 100644
--- a/src/Api/Extensions/MiddlewareRegisterExtension.cs
+++ b/src/Api/Extensions/MiddlewareRegisterExtension.cs
@@ -4,18 +4,8 @@ namespace Api.Extensions;
public static class MiddlewareRegisterExtension
{
- public static void ExceptionHandler(this IApplicationBuilder app)
- {
- app.UseMiddleware();
- }
-
public static void CurrentUser(this IApplicationBuilder app)
{
app.UseMiddleware();
}
-
- public static void LogContext(this IApplicationBuilder app)
- {
- app.UseMiddleware();
- }
}
diff --git a/src/Api/Extensions/ProblemDetailExtention.cs b/src/Api/Extensions/ProblemDetailExtention.cs
new file mode 100644
index 00000000..910c25ae
--- /dev/null
+++ b/src/Api/Extensions/ProblemDetailExtention.cs
@@ -0,0 +1,37 @@
+using System.Diagnostics;
+using Api.Middlewares;
+using Microsoft.AspNetCore.Http.Features;
+
+namespace Api.Extensions;
+
+public static class ProblemDetailExtention
+{
+ public static IServiceCollection AddErrorDetails(this IServiceCollection services)
+ {
+ return services
+ .AddProblemDetails(options =>
+ {
+ options.CustomizeProblemDetails = context =>
+ {
+ context.ProblemDetails.Instance =
+ $"{context.HttpContext.Request.Method} {context.HttpContext.Request.Path}";
+ context.ProblemDetails.Extensions.TryAdd(
+ "requestId",
+ context.HttpContext.TraceIdentifier
+ );
+ IHttpActivityFeature? activityFeature =
+ context.HttpContext.Features.Get();
+
+ context.ProblemDetails.Extensions.TryAdd(
+ "traceId",
+ activityFeature?.Activity?.TraceId.ToString()
+ );
+ context.ProblemDetails.Extensions.TryAdd(
+ "spanId",
+ activityFeature?.Activity?.SpanId.ToString()
+ );
+ };
+ })
+ .AddExceptionHandler();
+ }
+}
diff --git a/src/Api/Extensions/SwaggerExtension.cs b/src/Api/Extensions/SwaggerExtension.cs
index 3a4c9263..0e6e506c 100644
--- a/src/Api/Extensions/SwaggerExtension.cs
+++ b/src/Api/Extensions/SwaggerExtension.cs
@@ -18,7 +18,6 @@ IConfiguration configuration
return services.AddSwaggerGen(option =>
{
- option.EnableAnnotations();
option.AddSecurityDefinition(
JwtBearerDefaults.AuthenticationScheme,
new OpenApiSecurityScheme
@@ -50,11 +49,11 @@ IConfiguration configuration
);
option.SwaggerDoc(
- openApiSettings?.Version,
+ "v1",
new OpenApiInfo()
{
Title = $"{openApiSettings?.ApplicationName} Documentation",
- Version = openApiSettings?.Version,
+ Version = "v1",
Description = $"Well come to the {openApiSettings?.ApplicationName} API",
Contact = new OpenApiContact()
{
diff --git a/src/Api/Middlewares/GlobalExceptionHandler.cs b/src/Api/Middlewares/GlobalExceptionHandler.cs
deleted file mode 100644
index b26271df..00000000
--- a/src/Api/Middlewares/GlobalExceptionHandler.cs
+++ /dev/null
@@ -1,56 +0,0 @@
-using System.Reflection;
-using Api.Middlewares.GlobalExceptionHandlers;
-
-namespace Api.Middlewares;
-
-public class GlobalExceptionHandler(RequestDelegate next)
-{
- public async Task InvokeAsync(HttpContext context)
- {
- try
- {
- await next.Invoke(context);
- }
- catch (Exception exception)
- {
- await HandleExceptionAsync(context, exception);
- }
- }
-
- private async Task HandleExceptionAsync(HttpContext context, Exception exception)
- {
- var types = Assembly
- .GetAssembly(typeof(IHandlerException<>))
- ?.GetTypes()
- .Where(x =>
- x.GetInterfaces()
- .Any(p =>
- p.IsGenericType
- && p.GetGenericTypeDefinition() == typeof(IHandlerException<>)
- )
- )
- .Select(type => new
- {
- type,
- iType = type.GetInterfaces()
- .FirstOrDefault(p =>
- p.GetGenericTypeDefinition() == typeof(IHandlerException<>)
- )!
- .GenericTypeArguments[0],
- });
-
- Type? type = types?.FirstOrDefault(x => x.iType == exception.GetType())?.type;
- type ??= typeof(InternalServerExceptionHandler);
-
- await Invoke(type, context, exception);
- }
-
- private async Task Invoke(Type type, HttpContext context, Exception exception)
- {
- MethodInfo? method = type!.GetMethod(nameof(IHandlerException.Handle));
-
- var handler = Activator.CreateInstance(type);
-
- await (Task)method?.Invoke(handler, [context, exception])!;
- }
-}
diff --git a/src/Api/Middlewares/GlobalExceptionHandlers/BadRequestExceptionHandler.cs b/src/Api/Middlewares/GlobalExceptionHandlers/BadRequestExceptionHandler.cs
deleted file mode 100644
index bf8e5597..00000000
--- a/src/Api/Middlewares/GlobalExceptionHandlers/BadRequestExceptionHandler.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using System.Diagnostics;
-using Application.Common.Exceptions;
-using Contracts.ApiWrapper;
-
-namespace Api.Middlewares.GlobalExceptionHandlers;
-
-public class BadRequestExceptionHandler : IHandlerException
-{
- public async Task Handle(HttpContext httpContext, Exception ex)
- {
- var exception = (BadRequestException)ex;
-
- httpContext.Response.StatusCode = exception.HttpStatusCode;
-
- ErrorResponse error =
- new(
- exception.Errors,
- exception.GetType().Name,
- exception.Message,
- new()
- {
- TraceId = Activity.Current?.Context.TraceId.ToString(),
- SpanId = Activity.Current?.Context.SpanId.ToString(),
- }
- );
-
- await httpContext.Response.WriteAsJsonAsync(error, error.GetOptions());
- }
-}
diff --git a/src/Api/Middlewares/GlobalExceptionHandlers/IHandlerException.cs b/src/Api/Middlewares/GlobalExceptionHandlers/IHandlerException.cs
deleted file mode 100644
index 093b33f5..00000000
--- a/src/Api/Middlewares/GlobalExceptionHandlers/IHandlerException.cs
+++ /dev/null
@@ -1,11 +0,0 @@
-using SharedKernel.Exceptions;
-
-namespace Api.Middlewares.GlobalExceptionHandlers;
-
-public interface IHandlerException : IHandlerException
- where T : CustomException { }
-
-public interface IHandlerException
-{
- Task Handle(HttpContext httpContext, Exception ex);
-}
diff --git a/src/Api/Middlewares/GlobalExceptionHandlers/InternalServerExceptionHandler.cs b/src/Api/Middlewares/GlobalExceptionHandlers/InternalServerExceptionHandler.cs
deleted file mode 100644
index cb422d88..00000000
--- a/src/Api/Middlewares/GlobalExceptionHandlers/InternalServerExceptionHandler.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System.Diagnostics;
-using Contracts.ApiWrapper;
-
-namespace Api.Middlewares.GlobalExceptionHandlers;
-
-public class InternalServerExceptionHandler() : IHandlerException
-{
- public async Task Handle(HttpContext httpContext, Exception ex)
- {
- httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
-
- var error = new ErrorResponse(
- ex.Message,
- trace: new()
- {
- TraceId = Activity.Current?.Context.TraceId.ToString(),
- SpanId = Activity.Current?.Context.SpanId.ToString(),
- }
- );
-
- await httpContext.Response.WriteAsJsonAsync(error, error.GetOptions());
- }
-}
diff --git a/src/Api/Middlewares/GlobalExceptionHandlers/NotFoundExceptionHandler.cs b/src/Api/Middlewares/GlobalExceptionHandlers/NotFoundExceptionHandler.cs
deleted file mode 100644
index 6c1291c0..00000000
--- a/src/Api/Middlewares/GlobalExceptionHandlers/NotFoundExceptionHandler.cs
+++ /dev/null
@@ -1,30 +0,0 @@
-using System.Diagnostics;
-using Application.Common.Exceptions;
-using Contracts.ApiWrapper;
-
-namespace Api.Middlewares.GlobalExceptionHandlers;
-
-public class NotFoundExceptionHandler : IHandlerException
-{
- public async Task Handle(HttpContext httpContext, Exception ex)
- {
- var exception = (NotFoundException)ex;
-
- httpContext.Response.StatusCode = exception.HttpStatusCode;
-
- ErrorResponse error =
- new(
- exception.Errors,
- exception.GetType().Name,
- exception.Message,
- new()
- {
- TraceId = Activity.Current?.Context.TraceId.ToString(),
- SpanId = Activity.Current?.Context.SpanId.ToString(),
- },
- exception.HttpStatusCode
- );
-
- await httpContext.Response.WriteAsJsonAsync(error, error.GetOptions());
- }
-}
diff --git a/src/Api/Middlewares/GlobalExceptionHandlers/ValidationExceptionHandler.cs b/src/Api/Middlewares/GlobalExceptionHandlers/ValidationExceptionHandler.cs
deleted file mode 100644
index 3c2fd969..00000000
--- a/src/Api/Middlewares/GlobalExceptionHandlers/ValidationExceptionHandler.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using System.Diagnostics;
-using Application.Common.Exceptions;
-using Contracts.ApiWrapper;
-
-namespace Api.Middlewares.GlobalExceptionHandlers;
-
-public class ValidationExceptionHandler : IHandlerException
-{
- public async Task Handle(HttpContext httpContext, Exception ex)
- {
- var exception = (ValidationException)ex;
-
- httpContext.Response.StatusCode = exception.HttpStatusCode;
-
- var error = new ErrorResponse(
- exception.ValidationErrors,
- exception.GetType().Name,
- exception.Message,
- new()
- {
- TraceId = Activity.Current?.Context.TraceId.ToString(),
- SpanId = Activity.Current?.Context.SpanId.ToString(),
- }
- );
-
- await httpContext.Response.WriteAsJsonAsync(error, error.GetOptions());
- }
-}
diff --git a/src/Api/Middlewares/GlobalProblemDetailHandler.cs b/src/Api/Middlewares/GlobalProblemDetailHandler.cs
new file mode 100644
index 00000000..5b6f5195
--- /dev/null
+++ b/src/Api/Middlewares/GlobalProblemDetailHandler.cs
@@ -0,0 +1,51 @@
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Api.Middlewares;
+
+public class GlobalProblemDetailHandler(
+ IProblemDetailsService problemDetailsService,
+ Serilog.ILogger logger
+) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken
+ )
+ {
+ IHttpActivityFeature? activityFeature = httpContext.Features.Get();
+ string? traceId = activityFeature?.Activity?.TraceId.ToString();
+ string? spanId = activityFeature?.Activity?.SpanId.ToString();
+ logger.Error(
+ "\n\n{exception} error's occured having tracing identifier [traceId:{traceId}, spanId:{spanId}]\nwith message '{Message}'\n{StackTrace}\n",
+ exception.GetType().Name,
+ traceId,
+ spanId,
+ exception.Message,
+ exception.StackTrace?.TrimStart()
+ );
+
+ int code = StatusCodes.Status500InternalServerError;
+ httpContext.Response.StatusCode = code;
+
+ ProblemDetails problemDetail =
+ new()
+ {
+ Status = code,
+ Title = "An Error has occured",
+ Detail = exception.Message,
+ Type = exception.GetType().Name,
+ };
+
+ return await problemDetailsService.TryWriteAsync(
+ new()
+ {
+ HttpContext = httpContext,
+ ProblemDetails = problemDetail,
+ Exception = exception,
+ }
+ );
+ }
+}
diff --git a/src/Api/Middlewares/LogContextMiddleware.cs b/src/Api/Middlewares/LogContextMiddleware.cs
deleted file mode 100644
index 232bccf3..00000000
--- a/src/Api/Middlewares/LogContextMiddleware.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using System.Diagnostics;
-using Serilog.Context;
-
-namespace Api.Middlewares;
-
-public class LogContextMiddleware(RequestDelegate next)
-{
- public async Task InvokeAsync(HttpContext context)
- {
- var traceId = Activity.Current?.TraceId.ToString();
- var spanId = Activity.Current?.SpanId.ToString();
-
- using (LogContext.PushProperty("trace-id", traceId))
- using (LogContext.PushProperty("span-id", spanId))
- {
- context.Response.Headers["trace-id"] = traceId;
- context.Response.Headers["span-id"] = spanId;
-
- await next(context);
- }
- }
-}
diff --git a/src/Api/Program.cs b/src/Api/Program.cs
index 5c0e3173..20531ce7 100644
--- a/src/Api/Program.cs
+++ b/src/Api/Program.cs
@@ -1,14 +1,15 @@
using System.Runtime.InteropServices;
+using Api.common.EndpointConfigurations;
+using Api.common.Routers;
using Api.Converters;
using Api.Extensions;
using Application;
+using Cysharp.Serialization.Json;
using HealthChecks.UI.Client;
using Infrastructure;
using Infrastructure.Data;
-using Infrastructure.Services.Hangfires;
+using Infrastructure.Services.Hangfire;
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
-using Microsoft.AspNetCore.Hosting.Server;
-using Microsoft.AspNetCore.Hosting.Server.Features;
using Serilog;
using Swashbuckle.AspNetCore.SwaggerUI;
@@ -20,17 +21,19 @@
string? url = builder.Configuration["urls"] ?? "http://0.0.0.0:8080";
builder.WebHost.UseUrls(url);
builder.AddConfiguration();
-builder
- .Services.AddControllers()
- .AddJsonOptions(option =>
- {
- option.JsonSerializerOptions.Converters.Add(new DatetimeConverter());
- option.JsonSerializerOptions.Converters.Add(new DateTimeOffsetConvert());
- option.JsonSerializerOptions.Converters.Add(
- new Cysharp.Serialization.Json.UlidJsonConverter()
- );
- });
+
+services.AddEndpoints();
+services.ConfigureHttpJsonOptions(options =>
+{
+ options.SerializerOptions.Converters.Add(new DatetimeConverter());
+ options.SerializerOptions.Converters.Add(new DateTimeOffsetConvert());
+ options.SerializerOptions.Converters.Add(new UlidJsonConverter());
+});
+
+services.AddAuthorization();
+services.AddErrorDetails();
services.AddSwagger(configuration);
+services.AddApiVersion();
services.AddOpenTelemetryTracing(configuration);
builder.AddSerialogs();
services.AddHealthChecks();
@@ -38,7 +41,7 @@
#endregion
#region layers dependencies
-services.AddInfrastructureDependencies(configuration, builder.Environment.EnvironmentName);
+services.AddInfrastructureDependencies(configuration);
services.AddApplicationDependencies();
#endregion
@@ -62,7 +65,7 @@
&& app.Environment.EnvironmentName != "Testing-Development"
)
{
- var scope = app.Services.CreateScope();
+ using var scope = app.Services.CreateScope();
var serviceProvider = scope.ServiceProvider;
await RegionDataSeeding.SeedingAsync(serviceProvider);
await DbInitializer.InitializeAsync(serviceProvider);
@@ -71,49 +74,28 @@
app.UseHangfireDashboard(configuration);
- const string routeRefix = "docs";
+ string routeRefix = configuration.GetSection("SwaggerRoutePrefix").Get() ?? "docs";
if (isDevelopment)
{
app.UseSwagger();
app.UseSwaggerUI(configs =>
{
- configs.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
+ configs.SwaggerEndpoint("/swagger/v1/swagger.json", "The Template API V1");
configs.RoutePrefix = routeRefix;
configs.ConfigObject.PersistAuthorization = true;
configs.DocExpansion(DocExpansion.None);
});
- app.Lifetime.ApplicationStarted.Register(() =>
- {
- var server = app.Services.GetRequiredService();
- var addresses = server.Features.Get()?.Addresses.ToArray();
-
- if (addresses != null && addresses.Length > 0)
- {
- string? url = addresses?[0];
- string? renewUrl =
- url?.Contains("0.0.0.0") == true ? url.Replace("0.0.0.0", "localhost") : url;
- Log.Logger.Information("Application is running at: {Url}", renewUrl);
- Log.Logger.Information(
- "Swagger UI is running at: {Url}",
- $"{renewUrl}/{routeRefix}"
- );
- Log.Logger.Information(
- "Application health check is running at: {Url}",
- $"{renewUrl}{healthCheckPath}"
- );
- }
- });
+ app.AddLog(Log.Logger, routeRefix, healthCheckPath);
}
+ app.UseStatusCodePages();
+ app.UseExceptionHandler();
app.UseAuthentication();
app.CurrentUser();
app.UseAuthorization();
app.UseDetection();
- app.UseSerilogRequestLogging();
- app.LogContext();
- app.ExceptionHandler();
- app.MapControllers();
+ app.MapEndpoints(apiVersion: EndpointVersion.One);
Log.Logger.Information(
"Application is in {environment} environment",
diff --git a/src/Api/Resources/Translations/Message.en.resx b/src/Api/Resources/Translations/Message.en.resx
index 00fe5e84..89fb4a15 100644
--- a/src/Api/Resources/Translations/Message.en.resx
+++ b/src/Api/Resources/Translations/Message.en.resx
@@ -1,17 +1,17 @@
-
-
+
-
+
-
-
-
-
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
+
-
+
@@ -123,11 +123,9 @@
Name
-
Description
-
claims
@@ -139,11 +137,9 @@
Type
-
Value
-
user
@@ -231,11 +227,9 @@
the between operator
-
its array index
-
its operator
@@ -247,7 +241,6 @@
its property
-
integer data type
@@ -289,4 +282,8 @@
Refresh token
-
\ No newline at end of file
+
+ the In operator
+
+
+
diff --git a/src/Api/Resources/Translations/Message.vi.resx b/src/Api/Resources/Translations/Message.vi.resx
index c8f9a492..2a69b390 100644
--- a/src/Api/Resources/Translations/Message.vi.resx
+++ b/src/Api/Resources/Translations/Message.vi.resx
@@ -1,17 +1,17 @@
-
-
+
-
+
-
-
-
-
+
+
+
+
-
-
+
+
-
-
+
+
-
-
-
-
+
+
+
+
-
+
-
+
@@ -119,27 +119,21 @@
Vai trò
-
Tên
-
Mô tả
-
các thông tin xác nhận
-
Các thông tin xác nhận
-
Loại thông tin
-
Dữ liệu thông tin
@@ -199,7 +193,6 @@
Danh sách vai trò
-
Các thông tin xác nhận
@@ -230,16 +223,13 @@
- phương thức nằm giữa
-
+ phương thức Between
vị trí trong mảng
-
phương thức
-
phần tử
@@ -250,15 +240,15 @@
- kiểu số thực
+ là kiểu số thực
- kiểu ngày giờ
+ là kiểu ngày giờ
- kiếu ulid
+ là kiếu ulid
@@ -289,4 +279,12 @@
Mã làm mới
-
\ No newline at end of file
+
+ phương thứ In
+
+
+
+ mã làm mới hiện tại
+
+
+
diff --git a/src/Api/Settings/OpenApiSettings.cs b/src/Api/Settings/OpenApiSettings.cs
index 59dd5adf..5ab16793 100644
--- a/src/Api/Settings/OpenApiSettings.cs
+++ b/src/Api/Settings/OpenApiSettings.cs
@@ -4,8 +4,6 @@ public class OpenApiSettings
{
public string? ApplicationName { get; set; }
- public string? Version { get; set; }
-
public string? Name { get; set; }
public string? Email { get; set; }
diff --git a/src/Api/appsettings.Production.json b/src/Api/appsettings.Production.json
index 041077b5..d3634808 100644
--- a/src/Api/appsettings.Production.json
+++ b/src/Api/appsettings.Production.json
@@ -5,7 +5,8 @@
"Default": "Warning",
"Override": {
"Microsoft": "Warning",
- "System": "Warning"
+ "System": "Warning",
+ "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
},
"WriteTo": [
@@ -22,8 +23,6 @@
"AllowedHosts": "*",
"S3AwsSettings": {
"ServiceUrl": "minio1:9000",
- "AccessKey": "BMy2mJX8IXXqmwwn5ucN",
- "SecretKey": "GEeZxE8rTKcTcwOJ3Y3tdFNDqx9iVI9YhOgLhrRP",
"BucketName": "the-template-project",
"PublicUrl": "minio1:9000",
"PreSignedUrlExpirationInMinutes": 1440,
@@ -72,9 +71,7 @@
},
"QueueSettings": {
"OriginQueueName": "queue:the_queue",
- "DeadLetterQueueName": "queue:the_dead_letter_queue",
- "MaxRetryAttempts": 5,
- "DeadLetterMaxRetryAttempts": 10,
+ "MaxRetryAttempts": 10,
"MaximumDelayInSec": 90
},
"ElasticsearchSettings": {
diff --git a/src/Api/appsettings.Testing-Deployment.json b/src/Api/appsettings.Testing-Deployment.json
index 3385748d..56e25583 100644
--- a/src/Api/appsettings.Testing-Deployment.json
+++ b/src/Api/appsettings.Testing-Deployment.json
@@ -6,7 +6,8 @@
"Default": "Information",
"Override": {
"Microsoft": "Information",
- "System": "Warning"
+ "System": "Warning",
+ "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
},
"WriteTo": [
@@ -21,6 +22,7 @@
}
},
"AllowedHosts": "*",
+ "HealthCheckPath": "/api/health",
"S3AwsSettings": {
"ServiceUrl": "http://localhost:9000",
"AccessKey": "BMy2mJX8IXXqmwwn5ucN",
@@ -60,14 +62,13 @@
"Name": "Anonymous",
"Email": "minhsang.1mil@gmail.com"
},
+ "SwaggerRoutePrefix": "docs",
"RedisDatabaseSettings": {
"IsEnbaled": false
},
"QueueSettings": {
"OriginQueueName": "queue:the_queue",
- "DeadLetterQueueName": "queue:the_dead_letter_queue",
- "MaxRetryAttempts": 5,
- "DeadLetterMaxRetryAttempts": 10,
+ "MaxRetryAttempts": 10,
"MaximumDelayInSec": 90
},
"ElasticsearchSettings": {
diff --git a/src/Api/appsettings.example.json b/src/Api/appsettings.example.json
index d8dcf896..391ca81b 100644
--- a/src/Api/appsettings.example.json
+++ b/src/Api/appsettings.example.json
@@ -6,7 +6,8 @@
"Default": "Information",
"Override": {
"Microsoft": "Information",
- "System": "Warning"
+ "System": "Warning",
+ "Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware": "Fatal"
}
},
"WriteTo": [
@@ -21,18 +22,19 @@
}
},
"AllowedHosts": "*",
+ "HealthCheckPath": "/api/health",
"S3AwsSettings": {
- "ServiceUrl": "[your_host]:9000",
- "AccessKey": "[yours]",
- "SecretKey": "[yours]",
+ "ServiceUrl": "http://localhost:9000",
+ "AccessKey": "01JRYKYBJZZYSR67Q9JM6D080C",
+ "SecretKey": "01JRYKYPZD0ZJZG8SW4RCSPW68",
"BucketName": "the-template-project",
- "PublicUrl": "[your_host]:9000",
+ "PublicUrl": "http://localhost:9000",
"PreSignedUrlExpirationInMinutes": 1440,
"Protocol": 1
},
"SecuritySettings": {
"JwtSettings": {
- "SecretKey": "[yours]",
+ "SecretKey": "3YDPg1E0pXAC9kD1iMtbciNj24gpDAxngqv8r6LRczWGx",
"ExpireTimeAccessToken": 1,
"ExpireTimeRefreshToken": 1
}
@@ -41,20 +43,20 @@
"ServiceName": "TheTemplate",
"ServiceVersion": "1.0.0",
"ActivitySourceName": "TheTemplate.Souce",
- "Otelp": "[your_host]:4317",
+ "Otelp": "http://localhost:4317",
"OtelpOption": 3,
"IsEnabled": false
},
"SerilogSettings": {
"IsDistributeLog": false,
- "SeqUrl": "[your_host]:5341"
+ "SeqUrl": "http://localhost:5341"
},
"EmailSettings": {
- "From": "[yours]",
- "Host": "[yours]",
+ "From": "yours",
+ "Host": "yours",
"Port": 587,
- "Username": "[yours]",
- "Password": "[yours]",
+ "Username": "yours",
+ "Password": "yours",
"MailType": 1
},
"ForgotPassowordUrl": "http://localhost:3000/resetPassword",
@@ -63,28 +65,26 @@
"ApplicationName": "TheTemplate",
"Version": "v1",
"Name": "Anonymous",
- "Email": "[your_email]"
+ "Email": "example@gmail.com"
},
"RedisDatabaseSettings": {
- "Host": "[your_host]",
+ "Host": "localhost",
"Port": 6379,
- "Password": "[your_password]",
+ "Password": "your_password",
"IsEnbaled": false
},
"QueueSettings": {
"OriginQueueName": "queue:the_queue",
- "DeadLetterQueueName": "queue:the_dead_letter_queue",
- "MaxRetryAttempts": 5,
- "DeadLetterMaxRetryAttempts": 10,
+ "MaxRetryAttempts": 10,
"MaximumDelayInSec": 90
},
"ElasticsearchSettings": {
- "Nodes": ["[your_host]:9200"],
+ "Nodes": ["http://localhost:9200"],
"DefaultSize": 9999,
"IsEnbaled": false,
"DefaultIndex": "default_index",
- "Password": "[your_password]",
- "Username": "[your_username]"
+ "Password": "your_password",
+ "Username": "your_username"
},
"DatabaseSettings": {
"DatabaseConnection": "Host=localhost;Username=[your_username];Password=[your_password];Database=example"
diff --git a/src/Api/common/Documents/QueryParamRequestDocument.cs b/src/Api/common/Documents/QueryParamRequestDocument.cs
new file mode 100644
index 00000000..c4741902
--- /dev/null
+++ b/src/Api/common/Documents/QueryParamRequestDocument.cs
@@ -0,0 +1,107 @@
+using Microsoft.OpenApi.Any;
+using Microsoft.OpenApi.Models;
+
+namespace Api.common.Documents;
+
+public static class QueryParamRequestDocument
+{
+ public static IList AddDocs(this OpenApiOperation _)
+ {
+ return
+ [
+ new()
+ {
+ Name = "page",
+ In = ParameterLocation.Query,
+ Required = false,
+ Schema = new() { Type = "integer", Default = new OpenApiInteger(1) },
+ },
+ new()
+ {
+ Name = "pageSize",
+ In = ParameterLocation.Query,
+ Required = false,
+ Schema = new() { Type = "integer", Default = new OpenApiInteger(100) },
+ },
+ new()
+ {
+ Name = "before",
+ In = ParameterLocation.Query,
+ Schema = new()
+ {
+ Type = "string",
+ Description = "The cursor for the previous move",
+ },
+ },
+ new()
+ {
+ Name = "after",
+ In = ParameterLocation.Query,
+ Schema = new() { Type = "string", Description = "The cursor for the next move" },
+ },
+ new()
+ {
+ Name = "keyword",
+ In = ParameterLocation.Query,
+ Schema = new() { Type = "string" },
+ },
+ new()
+ {
+ Name = "targets",
+ In = ParameterLocation.Query,
+ Schema = new()
+ {
+ Type = "array",
+ Items = new() { Type = "string" },
+ },
+ },
+ new()
+ {
+ Name = "sort",
+ In = ParameterLocation.Query,
+ Schema = new()
+ {
+ Type = "string",
+ Example = new OpenApiString("createdAt:desc, id"),
+ },
+ },
+ new()
+ {
+ Name = "filter",
+ In = ParameterLocation.Query,
+ Schema = new()
+ {
+ Type = "object",
+ AdditionalPropertiesAllowed = true,
+ Description =
+ "query string like : filter[$and][0][gender][$eq]=1&filter&filter[$and][1][dayOfBirth][$between][0]=2002-10-01&filter[$and][1][dayOfBirth][$between][1]=2005-10-01",
+ Example = new OpenApiObject(
+ new()
+ {
+ ["filter"] = new OpenApiObject(
+ new()
+ {
+ ["$and"] = new OpenApiObject()
+ {
+ ["gender"] = new OpenApiObject()
+ {
+ ["$eq"] = new OpenApiInteger(1),
+ },
+ ["dayofBirth"] = new OpenApiObject()
+ {
+ ["$between"] = new OpenApiArray()
+ {
+ new OpenApiDate(new DateTime(2002, 10, 1)),
+ new OpenApiDate(new DateTime(2005, 10, 1)),
+ },
+ },
+ },
+ }
+ ),
+ }
+ ),
+ },
+ },
+ ];
+ }
+}
diff --git a/src/Api/common/EndpointConfigurations/CustomAuthoriztionBuilder.cs b/src/Api/common/EndpointConfigurations/CustomAuthoriztionBuilder.cs
new file mode 100644
index 00000000..e9e673de
--- /dev/null
+++ b/src/Api/common/EndpointConfigurations/CustomAuthoriztionBuilder.cs
@@ -0,0 +1,126 @@
+using Application.Common.Auth;
+using Ardalis.GuardClauses;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Api.common.EndpointConfigurations;
+
+public class RequiredAuthorization(TBuilder endpointBuilder)
+ where TBuilder : IEndpointConventionBuilder
+{
+ public TBuilder EndpointBuilder { get; private set; } = endpointBuilder;
+
+ public IList Permissions { get; set; } = [];
+ public IList Roles { get; set; } = [];
+
+ public AuthorizeByAttribute? AuthorizeBy { get; set; }
+}
+
+public static class RequiredAuthorizationBuilder
+{
+ public static RequiredAuthorization StartAuthorization(
+ this TBuilder builder
+ )
+ where TBuilder : IEndpointConventionBuilder
+ {
+ return new(builder);
+ }
+
+ public static RequiredAuthorization AddPermission(
+ this RequiredAuthorization requiredAuthorization,
+ string permission
+ )
+ where TBuilder : IEndpointConventionBuilder
+ {
+ requiredAuthorization.Permissions.Add(permission);
+ return requiredAuthorization;
+ }
+
+ public static RequiredAuthorization AddRoles(
+ this RequiredAuthorization requiredAuthorization,
+ string role
+ )
+ where TBuilder : IEndpointConventionBuilder
+ {
+ requiredAuthorization.Roles.Add(role);
+ return requiredAuthorization;
+ }
+
+ public static TBuilder Authorize(
+ this RequiredAuthorization RequiredAuthorization
+ )
+ where TBuilder : IEndpointConventionBuilder
+ {
+ Guard.Against.Null(
+ RequiredAuthorization.EndpointBuilder,
+ nameof(RequiredAuthorization.EndpointBuilder)
+ );
+
+ if (RequiredAuthorization.Permissions.Count == 0 && RequiredAuthorization.Roles.Count == 0)
+ {
+ return RequiredAuthorization.EndpointBuilder.RequireAuthorization();
+ }
+
+ RequiredAuthorization.AuthorizeBy = new(
+ roles: string.Join(",", RequiredAuthorization.Roles),
+ permissions: string.Join(",", RequiredAuthorization.Permissions)
+ );
+
+ return RequiredAuthorization.EndpointBuilder.RequireAuthorization(
+ RequiredAuthorization.AuthorizeBy
+ );
+ }
+
+ public static TBuilder RequireAuth(
+ this TBuilder builder,
+ string? roles = null,
+ string? permissions = null
+ )
+ where TBuilder : IEndpointConventionBuilder
+ {
+ Guard.Against.Null(builder, nameof(builder));
+
+ if (string.IsNullOrWhiteSpace(roles) && string.IsNullOrWhiteSpace(permissions))
+ {
+ builder.RequireAuthorization();
+ }
+
+ return builder.RequireAuthorization(new AuthorizeByAttribute(roles, permissions));
+ }
+
+ public static TBuilder RequireAuthorization(
+ this TBuilder builder,
+ params IAuthorizeData[] authorizeData
+ )
+ where TBuilder : IEndpointConventionBuilder
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+
+ ArgumentNullException.ThrowIfNull(authorizeData);
+
+ if (authorizeData.Length == 0)
+ {
+ authorizeData = [new AuthorizeByAttribute()];
+ }
+
+ RequireAuthorizationCore(builder, authorizeData);
+ return builder;
+ }
+
+ private static void RequireAuthorizationCore(
+ TBuilder builder,
+ IEnumerable authorizeData
+ )
+ where TBuilder : IEndpointConventionBuilder
+ {
+ builder.Add(endpointBuilder =>
+ {
+ foreach (var data in authorizeData)
+ {
+ endpointBuilder.Metadata.Add(data);
+ }
+ });
+ }
+}
diff --git a/src/Api/common/EndpointConfigurations/EndpointValidationExtension.cs b/src/Api/common/EndpointConfigurations/EndpointValidationExtension.cs
new file mode 100644
index 00000000..428fca17
--- /dev/null
+++ b/src/Api/common/EndpointConfigurations/EndpointValidationExtension.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Http;
+
+namespace Api.common.EndpointConfigurations;
+
+public static class EndpointValidationExtension
+{
+ public static RouteHandlerBuilder WithRequestValidation(
+ this RouteHandlerBuilder route
+ )
+ where TRequest : class
+ {
+ return route
+ .AddEndpointFilter>()
+ .ProducesProblem(StatusCodes.Status400BadRequest, "application/problem+json ");
+ }
+}
diff --git a/src/Api/common/EndpointConfigurations/EnpointMapping.cs b/src/Api/common/EndpointConfigurations/EnpointMapping.cs
new file mode 100644
index 00000000..619272fa
--- /dev/null
+++ b/src/Api/common/EndpointConfigurations/EnpointMapping.cs
@@ -0,0 +1,48 @@
+using System.Reflection;
+using Api.common.Routers;
+using Api.Extensions;
+using Asp.Versioning;
+using Asp.Versioning.Builder;
+using Contracts.Constants;
+
+namespace Api.common.EndpointConfigurations;
+
+public static class EnpointMapping
+{
+ public static IServiceCollection AddEndpoints(this IServiceCollection services)
+ {
+ Assembly assembly = Assembly.GetExecutingAssembly();
+ return services.Scan(scan =>
+ scan.FromAssemblies(assembly)
+ .AddClasses(classes => classes.AssignableTo())
+ .As()
+ .WithSingletonLifetime()
+ );
+ }
+
+ public static IApplicationBuilder MapEndpoints(
+ this WebApplication app,
+ EndpointVersion apiVersion
+ )
+ {
+ ApiVersionSet apiVersionSet = app.NewApiVersionSet()
+ .HasApiVersion(new ApiVersion((int)apiVersion))
+ .ReportApiVersions()
+ .Build();
+
+ List endpoints =
+ [
+ .. app
+ .Services.GetRequiredService>()
+ .Where(endpoint => endpoint.Version == apiVersion),
+ ];
+
+ RouteGroupBuilder routeGroupBuilder = app.MapGroup(
+ $"/{RoutePath.prefix}" + "v{version:apiVersion}/"
+ )
+ .WithApiVersionSet(apiVersionSet);
+
+ endpoints.ForEach(endpoint => endpoint.MapEndpoint(routeGroupBuilder));
+ return app;
+ }
+}
diff --git a/src/Api/common/EndpointConfigurations/IEndpoint.cs b/src/Api/common/EndpointConfigurations/IEndpoint.cs
new file mode 100644
index 00000000..5e2e4e65
--- /dev/null
+++ b/src/Api/common/EndpointConfigurations/IEndpoint.cs
@@ -0,0 +1,10 @@
+using Api.common.Routers;
+
+namespace Api.common.EndpointConfigurations;
+
+public interface IEndpoint
+{
+ public EndpointVersion Version { get; }
+
+ public void MapEndpoint(IEndpointRouteBuilder app);
+}
diff --git a/src/Api/common/EndpointConfigurations/ValidationFilter.cs b/src/Api/common/EndpointConfigurations/ValidationFilter.cs
new file mode 100644
index 00000000..60039aed
--- /dev/null
+++ b/src/Api/common/EndpointConfigurations/ValidationFilter.cs
@@ -0,0 +1,45 @@
+using Application.Common.Errors;
+using Contracts.ApiWrapper;
+using FluentValidation;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Api.common.EndpointConfigurations;
+
+public class ValidationFilter(IValidator validator) : IEndpointFilter
+ where TRequest : class
+{
+ public async ValueTask