feat: Integrate YARP Reverse Proxy for API routing, removing local API controllers, and updating project configuration and documentation.

This commit is contained in:
Ho Ngoc Hai
2026-01-19 09:30:52 +07:00
parent 3b41106606
commit e1dfc4e7a7
6 changed files with 143 additions and 259 deletions

View File

@@ -1,22 +1,45 @@
# WebClientBase - Blazor Web App .NET 10
Base frontend web application cho GoodGo Platform được xây dựng với Blazor WebAssembly Hosted.
Base frontend web application cho GoodGo Platform được xây dựng với Blazor WebAssembly + BFF Pattern.
## Features / Tính năng
## Architecture / Kiến trúc
- **Blazor WebAssembly Hosted** - Client chạy trong browser, Server host API
- **Shared Library Pattern** - DTOs và Validation được chia sẻ giữa Client và Server
- **Data Annotations Validation** - Viết 1 lần, dùng chung cả 2 đầu
- **[ApiController] Auto-Validation** - Server tự động validate, không cần `if (!ModelState.IsValid)`
- **Dark/Light Mode** - CSS variables với theme toggle
- **Glassmorphism UI** - Modern design với backdrop blur và shadows
```
┌─────────────────────────────────────────────────────────────────────┐
│ Browser │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Blazor WebAssembly Client │ │
│ └─────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────────┘
│ /api/*
┌─────────────────────────────────────────────────────────────────────┐
│ BFF (Backend for Frontend) │
│ WebClientBase.Server + YARP │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Routes: /api/iam/** → iam-service │ │
│ │ /api/merchants/** → merchant-service │ │
│ │ /api/catalog/** → catalog-service │ │
│ │ /api/orders/** → order-service │ │
│ └─────────────────────────────────────────────────────────────┘ │
└────────────────────────────────┬────────────────────────────────────┘
│ Internal Network
┌─────────────────────────────────────────────────────────────────────┐
│ Microservices │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ IAM │ │ Merchant │ │ Catalog │ │ Order │ │
│ │ :5101 │ │ :5102 │ │ :5103 │ │ :5104 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
## Tech Stack
| Layer | Technology |
|-------|------------|
| Client | Blazor WebAssembly (.NET 10) |
| Server | ASP.NET Core Web API (.NET 10) |
| BFF | ASP.NET Core + YARP Reverse Proxy |
| Shared | Class Library với Data Annotations |
| Styling | CSS Variables, Dark Mode |
@@ -28,10 +51,11 @@ web-client-base-net/
├── src/
│ ├── WebClientBase.Client/ # Blazor WebAssembly
│ │ ├── Layout/ # MainLayout, NavMenu
│ │ ├── Pages/ # Razor pages (Products, Auth)
│ │ ├── Pages/ # Razor pages
│ │ └── wwwroot/css/ # Design System CSS
│ ├── WebClientBase.Server/ # ASP.NET Core Host
│ │ ── Controllers/ # API Controllers
│ ├── WebClientBase.Server/ # BFF with YARP Proxy
│ │ ── Program.cs # YARP configuration
│ │ └── yarp.json # Routes configuration
│ └── WebClientBase.Shared/ # Shared Library
│ └── DTOs/ # Data Transfer Objects
└── Dockerfile
@@ -46,71 +70,27 @@ cd apps/web-client-base-net
# Restore packages
dotnet restore
# Run Server (hosts both API and Blazor client)
# Run BFF Server (hosts Blazor client + YARP proxy)
dotnet run --project src/WebClientBase.Server
# Open browser
# https://localhost:5001 hoặc http://localhost:5000
# http://localhost:5091
```
## Shared Validation Pattern / Mẫu Validation Chia Sẻ
## BFF Routes Configuration
### 1. Define DTO with Validation (một lần)
```csharp
// WebClientBase.Shared/DTOs/ProductDto.cs
public class ProductDto
{
[Required(ErrorMessage = "Tên là bắt buộc / Name is required")]
[StringLength(100, MinimumLength = 3)]
public string Name { get; set; } = string.Empty;
Routes được cấu hình trong `yarp.json`:
[Range(0.01, 1_000_000)]
public decimal Price { get; set; }
}
```
| Client Route | Proxied To | Description |
|--------------|------------|-------------|
| `/api/iam/**` | `http://localhost:5101/` | Identity & Access Management |
| `/api/merchants/**` | `http://localhost:5102/` | Merchant Service |
| `/api/catalog/**` | `http://localhost:5103/` | Catalog Service |
| `/api/orders/**` | `http://localhost:5104/` | Order Service |
### 2. Server (Auto-validation với [ApiController])
```csharp
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpPost]
public IActionResult Create(ProductDto request)
{
// Không cần if (!ModelState.IsValid) - [ApiController] đã làm hộ
return Ok(ApiResponse<ProductDto>.Ok(request));
}
}
```
### 3. Client (DataAnnotationsValidator)
```razor
<EditForm Model="@product" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<InputText @bind-Value="product.Name" />
<ValidationMessage For="() => product.Name" />
<button type="submit">Submit</button>
</EditForm>
```
## API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/products` | Get all products |
| POST | `/api/products` | Create product |
| PUT | `/api/products` | Update product |
| POST | `/api/auth/register` | Register user |
| POST | `/api/auth/login` | Login |
| GET | `/api/auth/profile/{id}` | Get profile |
| GET | `/health` | Health check |
> **Note:** Addresses có thể override qua environment variables trong production.
## Related Skills
- [React Enterprise Architect](../../.agent/skills/react-enterprise-architect/SKILL.md)
- [Tailwind Design System](../../.agent/skills/tailwind-design-system/SKILL.md)
- [API Aggregation](../../.agent/skills/api-aggregation/SKILL.md) - BFF Pattern
- [Project Rules](../../.agent/skills/project-rules/SKILL.md)

View File

@@ -1,86 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using WebClientBase.Shared;
using WebClientBase.Shared.DTOs;
namespace WebClientBase.Server.Controllers;
/// <summary>
/// EN: Authentication API controller.
/// VI: Auth API controller.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class AuthController : ControllerBase
{
/// <summary>
/// EN: Register a new user.
/// VI: Đăng ký user mới.
/// </summary>
/// <param name="request">Registration data with validation</param>
[HttpPost("register")]
[ProducesResponseType(typeof(ApiResponse<UserProfileDto>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<ApiResponse<UserProfileDto>> Register([FromBody] RegisterDto request)
{
// EN: [ApiController] validates before this code runs
// VI: [ApiController] validate trước khi code này chạy
// EN: Demo - create user profile
// VI: Demo - tạo user profile
var profile = new UserProfileDto
{
Id = Guid.NewGuid(),
Email = request.Email,
DisplayName = request.DisplayName,
CreatedAt = DateTime.UtcNow
};
return CreatedAtAction(nameof(GetProfile), new { id = profile.Id }, ApiResponse<UserProfileDto>.Ok(profile));
}
/// <summary>
/// EN: Login with email and password.
/// VI: Đăng nhập với email và mật khẩu.
/// </summary>
[HttpPost("login")]
[ProducesResponseType(typeof(ApiResponse<UserProfileDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status401Unauthorized)]
public ActionResult<ApiResponse<UserProfileDto>> Login([FromBody] LoginDto request)
{
// EN: Demo - always succeed with mock profile
// VI: Demo - luôn thành công với profile giả
var profile = new UserProfileDto
{
Id = Guid.NewGuid(),
Email = request.Email,
DisplayName = "Demo User",
CreatedAt = DateTime.UtcNow.AddDays(-30)
};
return Ok(ApiResponse<UserProfileDto>.Ok(profile));
}
/// <summary>
/// EN: Get user profile by ID.
/// VI: Lấy user profile theo ID.
/// </summary>
[HttpGet("profile/{id:guid}")]
[ProducesResponseType(typeof(ApiResponse<UserProfileDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ApiResponse), StatusCodes.Status404NotFound)]
public ActionResult<ApiResponse<UserProfileDto>> GetProfile(Guid id)
{
// EN: Demo - return mock profile
// VI: Demo - trả về profile giả
var profile = new UserProfileDto
{
Id = id,
Email = "demo@example.com",
DisplayName = "Demo User",
AvatarUrl = "https://example.com/avatar.jpg",
CreatedAt = DateTime.UtcNow.AddDays(-30)
};
return Ok(ApiResponse<UserProfileDto>.Ok(profile));
}
}

View File

@@ -1,95 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using WebClientBase.Shared;
using WebClientBase.Shared.DTOs;
namespace WebClientBase.Server.Controllers;
/// <summary>
/// EN: Products API controller with automatic ModelState validation.
/// VI: Products API controller với validation ModelState tự động.
/// </summary>
/// <remarks>
/// EN: The [ApiController] attribute automatically validates ModelState before action executes.
/// VI: Attribute [ApiController] tự động validate ModelState trước khi action được thực thi.
/// </remarks>
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
// EN: In-memory storage for demo purposes
// VI: Lưu trữ trong bộ nhớ cho mục đích demo
private static readonly List<ProductDto> _products = new()
{
new ProductDto { Name = "MacBook Pro", Description = "Laptop cao cấp", Price = 2499.99m, Quantity = 10 },
new ProductDto { Name = "iPhone 15 Pro", Description = "Điện thoại thông minh", Price = 1199.99m, Quantity = 50 }
};
/// <summary>
/// EN: Get all products.
/// VI: Lấy tất cả sản phẩm.
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(ApiResponse<List<ProductDto>>), StatusCodes.Status200OK)]
public ActionResult<ApiResponse<List<ProductDto>>> GetAll()
{
return Ok(ApiResponse<List<ProductDto>>.Ok(_products));
}
/// <summary>
/// EN: Create a new product.
/// VI: Tạo sản phẩm mới.
/// </summary>
/// <param name="request">Product data with validation</param>
/// <remarks>
/// EN: [ApiController] automatically returns 400 if ModelState is invalid.
/// VI: [ApiController] tự động trả về 400 nếu ModelState không hợp lệ.
/// No need for: if (!ModelState.IsValid) return BadRequest(ModelState);
/// </remarks>
[HttpPost]
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<ApiResponse<ProductDto>> Create([FromBody] CreateProductDto request)
{
// EN: Validation is automatically done by [ApiController] before reaching here
// VI: Validation được tự động thực hiện bởi [ApiController] trước khi đến đây
var product = new ProductDto
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
Quantity = request.Quantity
};
_products.Add(product);
return CreatedAtAction(
nameof(GetAll),
ApiResponse<ProductDto>.Ok(product)
);
}
/// <summary>
/// EN: Update an existing product.
/// VI: Cập nhật sản phẩm đã có.
/// </summary>
[HttpPut]
[ProducesResponseType(typeof(ApiResponse<ProductDto>), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
public ActionResult<ApiResponse<ProductDto>> Update([FromBody] UpdateProductDto request)
{
// EN: Find and update (simplified for demo)
// VI: Tìm và cập nhật (đơn giản hóa cho demo)
var existing = _products.FirstOrDefault(p => p.Name == request.Name);
if (existing == null)
{
return NotFound(ApiResponse<ProductDto>.Fail("Sản phẩm không tồn tại / Product not found"));
}
existing.Description = request.Description;
existing.Price = request.Price;
existing.Quantity = request.Quantity;
return Ok(ApiResponse<ProductDto>.Ok(existing));
}
}

View File

@@ -1,6 +1,6 @@
/// <summary>
/// EN: ASP.NET Core Server with Blazor WebAssembly hosting.
/// VI: ASP.NET Core Server với Blazor WebAssembly hosting.
/// EN: ASP.NET Core BFF (Backend for Frontend) with YARP Reverse Proxy.
/// VI: ASP.NET Core BFF (Backend for Frontend) với YARP Reverse Proxy.
/// </summary>
var builder = WebApplication.CreateBuilder(args);
@@ -10,9 +10,14 @@ var builder = WebApplication.CreateBuilder(args);
// VI: Thêm các services vào container
// ═══════════════════════════════════════════════════════════════════════════════
// EN: Add controllers with automatic ModelState validation via [ApiController]
// VI: Thêm controllers với validation ModelState tự động qua [ApiController]
builder.Services.AddControllers();
// EN: Load YARP configuration from yarp.json
// VI: Load cấu hình YARP từ yarp.json
builder.Configuration.AddJsonFile("yarp.json", optional: false, reloadOnChange: true);
// EN: Add YARP Reverse Proxy
// VI: Thêm YARP Reverse Proxy
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
// EN: Add OpenAPI/Swagger support
// VI: Thêm hỗ trợ OpenAPI/Swagger
@@ -24,7 +29,7 @@ builder.Services.AddCors(options =>
{
options.AddPolicy("BlazorClient", policy =>
{
policy.WithOrigins("https://localhost:5001", "http://localhost:5000")
policy.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader();
});
@@ -55,16 +60,15 @@ app.UseCors("BlazorClient");
// EN: Serve static files with fingerprinting support (.NET 10+)
// VI: Phục vụ static files với hỗ trợ fingerprinting (.NET 10+)
// IMPORTANT: MapStaticAssets handles fingerprinted file mappings automatically
app.MapStaticAssets();
// EN: Map health check endpoint
// VI: Map endpoint health check
app.MapHealthChecks("/health");
// EN: Map API controllers
// VI: Map các API controllers
app.MapControllers();
// EN: Map YARP Reverse Proxy routes to microservices
// VI: Map các routes YARP Reverse Proxy đến microservices
app.MapReverseProxy();
// EN: Fallback to index.html for SPA routing
// VI: Fallback đến index.html cho SPA routing

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.1" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,80 @@
{
"ReverseProxy": {
"Routes": {
"iam-route": {
"ClusterId": "iam-cluster",
"Match": {
"Path": "/api/iam/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/iam"
}
]
},
"merchant-route": {
"ClusterId": "merchant-cluster",
"Match": {
"Path": "/api/merchants/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/merchants"
}
]
},
"catalog-route": {
"ClusterId": "catalog-cluster",
"Match": {
"Path": "/api/catalog/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/catalog"
}
]
},
"order-route": {
"ClusterId": "order-cluster",
"Match": {
"Path": "/api/orders/{**catch-all}"
},
"Transforms": [
{
"PathRemovePrefix": "/api/orders"
}
]
}
},
"Clusters": {
"iam-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5101"
}
}
},
"merchant-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5102"
}
}
},
"catalog-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5103"
}
}
},
"order-cluster": {
"Destinations": {
"destination1": {
"Address": "http://localhost:5104"
}
}
}
}
}
}