another step for constructing base project

This commit is contained in:
hamid
2026-06-17 22:53:49 +03:30
parent 5b4c0d183f
commit 5388bea320
76 changed files with 3836 additions and 1961 deletions
+114 -62
View File
@@ -1,109 +1,161 @@
# AGENTS.md — Balinyaar Server
Agent-oriented guide to the backend. For human setup/run instructions see [README.md](README.md).
> **Coding rules** are in [CONVENTIONS.md](CONVENTIONS.md) — read it before writing any server code.
---
## Role
You are a **senior .NET software engineer** working on this codebase. That means:
- You write production-quality code, not demo code. Every file you touch should look like it was written by someone who has shipped .NET APIs at scale.
- You understand the architecture and work _with_ it, not around it. Clean Architecture boundaries are non-negotiable.
- You think before you write. If a task is ambiguous, reason through the design first. If it requires touching a contract that other layers depend on, think about the downstream impact.
- You prefer simplicity and clarity over cleverness. The next engineer (or agent) should be able to read your code without a guide.
- You never leave the codebase in a worse state than you found it. If you touch a file, leave it at least as clean as it was.
---
## Stack
- **ASP.NET Core / .NET 10** (`net10.0`), Web API
- **Clean Architecture** (Domain → Application → Infrastructure → API)
- **CQRS** with **MediatR** (source-generated `Mediator`)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work)
- **ASP.NET Core Identity** with **JWE** (signed + AES-encrypted JWT), **OTP**, and **dynamic permission** authorization
- **Mapster** (mapping), **FluentValidation** (validation), **Serilog** (logging), **OpenTelemetry** + **prometheus-net** (observability), **NSwag/Swagger** (OpenAPI), **Asp.Versioning** (API versioning)
- **xUnit** + **NSubstitute** (tests)
- Centralized NuGet versions in `Directory.Packages.props`
- **CQRS** with **Mediator** (`martinothamar/Mediator` — source-generator based, not MediatR)
- **EF Core 10** + **SQL Server** (Repository + Unit of Work pattern)
- **ASP.NET Core Identity** with **JWE** (signed + AES-128-encrypted JWT), OTP, and dynamic permission authorization
- **Mapster** for mapping, **FluentValidation** for validation, **Serilog** for structured logging
- **OpenTelemetry** + **prometheus-net** for observability, **NSwag** for OpenAPI, **Asp.Versioning** for versioning
- **xUnit** + **NSubstitute** for tests
- All NuGet versions centrally pinned in `Directory.Packages.props`
---
## Commands (run from `server/`)
| Task | Command |
| ---------- | --------------------------------------------------------------------------------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Task | Command |
| ----------------- | ------- |
| Restore | `dotnet restore Baya.sln` |
| Build | `dotnet build Baya.sln` |
| Run API | `dotnet run --project src/API/Baya.Web.Api/Baya.Web.Api.csproj` |
| Test | `dotnet test Baya.sln` |
| Add migration | `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
| Update DB | `dotnet ef database update --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api` |
**Startup project:** `src/API/Baya.Web.Api`. Default URL `https://localhost:5002`, Swagger at `/swagger`. On boot the app applies EF migrations and seeds default users (`Program.cs``ApplyMigrationsAsync()` / `SeedDefaultUsersAsync()`), so a reachable DB is required.
**Default URL:** `https://localhost:5002` Swagger at `/swagger`.
On boot, `Program.cs` calls `ApplyMigrationsAsync()` and `SeedDefaultUsersAsync()` — a reachable SQL Server is required to start.
## Projects by layer
---
## Quality gates — run these before declaring work done
1. `dotnet build Baya.sln` — zero new warnings introduced.
2. `dotnet test Baya.sln` — all tests pass.
3. Read your own diff as if reviewing a PR. Ask: would a senior engineer approve this without comment?
---
## Project map
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Order, Role...), BaseEntity, IEntity, ITimeModification
│ └── Baya.Application CQRS Features/, Contracts/ (interfaces), Models/, MediatR pipeline (Common/)
│ ├── Baya.Domain Entities (User, Order, Role), BaseEntity, IEntity, ITimeModification
│ └── Baya.Application Features/ (Commands & Queries), Contracts/, Models/, pipeline behaviors (Common/)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Configuration/, Repositories/, Migrations/
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed), ServiceConfiguration/
│ ├── Baya.Infrastructure.CrossCutting Logging (Serilog)
│ └── Baya.Infrastructure.Monitoring HealthCheck / OpenTelemetry / Prometheus configs
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext, Repositories/, Configuration/, Migrations/
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/, appsettings*.json
│ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Attributes/
│ └── Plugins/Baya.Web.Plugins.Grpc GrpcPluginStartup, Services/, ProtoModels/
├── Shared/Baya.SharedKernel Extensions + validation base used by all layers
└── Tests/ Baya.Tests.Setup + Baya.Test.Infrastructure.Identity
│ ├── Baya.Web.Api Program.cs, Controllers/V1/, appsettings*.json
│ ├── Baya.WebFramework BaseController, Filters/, Middlewares/, Swagger/, Routing/
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models
├── Shared/Baya.SharedKernel Extensions + validation base
└── Tests/
├── Baya.Tests.Setup Shared test infrastructure (SQLite, NSubstitute setup)
└── Baya.Test.Infrastructure.Identity xUnit identity tests
```
Dependency direction points **inward**: Domain depends on nothing; Application depends on Domain; Infrastructure and API implement/consume Application's contracts. Never make Domain or Application reference Infrastructure or the API.
**Dependency direction points inward.** Domain has no dependencies. Application depends only on Domain. Infrastructure and API implement/consume Application contracts. Never make Domain or Application reference Infrastructure or the API — this is a hard rule.
## Startup wiring — `src/API/Baya.Web.Api/Program.cs`
---
Service registration is composed from per-layer extension methods (in each project's `ServiceConfiguration`):
## Startup wiring
Service registration is composed from per-layer extension methods (each project's `ServiceConfiguration/`):
```
ConfigureHealthChecks() · SetupOpenTelemetry()
AddApplicationServices() // MediatR + validators + pipeline behaviors
AddApplicationServices() // Mediator + validators + pipeline behaviors
RegisterIdentityServices(...) // Identity, JWT/JWE, authorization policies
AddPersistenceServices(...) // DbContext, UnitOfWork, repositories
AddWebFrameworkServices() // API versioning
AddSwagger("v1","v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices() // gRPC plugin
AddWebFrameworkServices() // API versioning + snake_case routing
AddSwagger("v1", "v1.1") · RegisterValidatorsAsServices() · AddMapster()
ConfigureGrpcPluginServices()
```
Pipeline order: exception handling → Swagger → routing → **authentication → authorization** → controllers → metrics → health checks → `ConfigureGrpcPipeline()`.
Pipeline order: exception handler → Swagger → routing → **authentication → authorization** → controllers → metrics → health checks → gRPC.
When adding infrastructure, expose it as an extension method and call it here rather than inlining into `Program.cs`.
When adding new infrastructure, expose it as an extension method and call it from `Program.cs` — never inline registrations there directly.
---
## CQRS — how a feature is shaped
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`. A query example (`Features/Order/Queries/GetAllOrders/`):
Features live under `Baya.Application/Features/<Area>/{Commands|Queries}/<Name>/`:
- `GetAllOrdersQuery.cs``record ... : IRequest<OperationResult<...>>`
- `GetAllOrdersQueryHandler.cs``internal` handler; depends on `IUnitOfWork`, `IMapper`; returns `OperationResult<T>`
- `GetAllOrdersQueryResult.cs` — the DTO returned
```
Features/Order/
├── Commands/CreateOrderCommand/
│ ├── CreateOrderCommand.cs record : ICommand<OperationResult<T>>
│ ├── CreateOrderCommand.Handler.cs internal sealed class : ICommandHandler<...>
│ └── CreateOrderCommand.Validator.cs
└── Queries/GetUserOrdersQuery/
├── GetUserOrdersQuery.cs
├── GetUserOrdersQuery.Handler.cs
└── GetUserOrdersQuery.Result.cs
```
Commands additionally implement `IValidatableModel<T>` and declare FluentValidation rules; the `ValidateCommandBehavior` MediatR pipeline (`Application/Common/`) runs validators before the handler and surfaces errors in `OperationResult`.
Handlers are `internal sealed`. Requests are `record` types. Validators use FluentValidation and are picked up automatically by the `ValidateCommandBehavior` pipeline behavior. Never throw for expected failures — use `OperationResult` factory methods.
**To add a feature:** create the folder with the request + handler (+ result/validator), then call it from a controller via `_sender.Send(...)`. Contracts the handler needs go in `Application/Contracts/` and are implemented in Infrastructure.
**To add a feature:** create the folder, implement request + handler + (optional) validator, add any new contracts to `Application/Contracts/` and implement them in Infrastructure, then wire a controller action to `sender.Send(...)`.
## Controllers & results
- Controllers live in `Baya.Web.Api/Controllers/V1/` and inherit `BaseController` (`Baya.WebFramework/BaseController/BaseController.cs`), which exposes `UserId`/`UserName`/etc. from claims and maps `OperationResult<T>``IActionResult`.
- All responses are wrapped in `OperationResult<T>` (`Application/Models/Common/`): `Result`, `IsSuccess`, `ErrorMessages`, `IsNotFound`, `IsException`. Use the factory methods (`SuccessResult`, `FailureResult`, `NotFoundResult`).
- Protected endpoints use `[Authorize(ConstantPolicies.DynamicPermission)]`.
---
## Persistence
- `ApplicationDbContext` (`Baya.Infrastructure.Persistence/ApplicationDbContext.cs`) extends `IdentityDbContext<...>`; it auto-registers `IEntity` types and applies all `IEntityTypeConfiguration` from the assembly.
- Per-entity config in `Configuration/<Area>Config/`. Repositories in `Repositories/` derive from `BaseAsyncRepository<T>`; expose them through `IUnitOfWork` (interface in `Application/Contracts/Persistence/`). Commit via `unitOfWork.CommitAsync()`.
- Migrations in `Migrations/`. Add new ones with the `dotnet ef` command above.
- Access the DB through `IUnitOfWork` — not `ApplicationDbContext` directly outside Infrastructure.
- Commit once per command via `unitOfWork.CommitAsync()`.
- Use `AsNoTracking()` on all read-only queries.
- Always project to a DTO in queries — never return entity objects from handlers.
- Add entity config in `Persistence/Configuration/<Area>Config/` implementing `IEntityTypeConfiguration<T>`.
---
## Identity & auth
- Token service: `Baya.Infrastructure.Identity/Jwt/JwtService.cs` (`IJwtService`) — issues JWE (HMAC-SHA256 signed, AES-128 encrypted), refresh tokens, and OTP/phone-based tokens.
- Custom Identity managers/stores under `Identity/Manager/` and `Identity/Store/`.
- Dynamic permissions: `Identity/PermissionManager/` (`DynamicPermissionService`, `DynamicPermissionHandler`, `ConstantPolicies`).
- Settings from `appsettings.json``IdentitySettings` (`SecretKey`, `Encryptkey` = 16 chars, `Issuer`, `Audience`, lifetimes).
- JWT/JWE issued by `IJwtService` (`Baya.Infrastructure.Identity/Jwt/JwtService.cs`).
- Dynamic permission system: `DynamicPermissionHandler` reads `[controller]` + `[action]` route values and checks role claims. The key format is set at runtime — always use `[controller]`/`[action]` tokens (see CONVENTIONS.md Routing rule) so the keys are consistent.
- Settings bound from `appsettings.json``IdentitySettings`.
## gRPC plugin
---
`Plugins/Baya.Web.Plugins.Grpc` is a self-contained module mounted via Application Parts. `GrpcPluginStartup.cs` provides `ConfigureGrpcPluginServices()` / `ConfigureGrpcPipeline()` (called from `Program.cs`). Proto contracts in `ProtoModels/*.proto`, services in `Services/`. The host uses HTTP/2 (`Kestrel` config) for gRPC.
## Conventions (see [CONVENTIONS.md](CONVENTIONS.md) for the full rule set)
## Conventions
Quick reference:
- All URL segments are `snake_case` via `SnakeCaseParameterTransformer` — use `[controller]`/`[action]` tokens.
- Controllers inherit `BaseController`, inject `ISender`, return `base.OperationResult(result)`.
- Never call `Ok()` / `BadRequest()` directly in controllers.
- Handlers are `internal sealed`; never throw for expected failures.
- Mapster for mapping; FluentValidation for validation.
- Package versions only in `Directory.Packages.props`.
- The `Baya.*` namespace is project naming — do not rename without explicit instruction.
- Add cross-layer wiring as `ServiceConfiguration` extension methods, not inline in `Program.cs`.
- Keep handlers `internal`; return `OperationResult<T>`; don't throw for expected failures (use `FailureResult`/`NotFoundResult`).
- Use Mapster for entity↔DTO mapping; FluentValidation for input validation.
- Centralize package versions in `Directory.Packages.props` (no inline `Version=` in `.csproj`).
- The `Baya*` namespace/`.sln` naming is internal project naming, **not** template branding — don't rename it without an explicit request (it touches every file and the EF migrations).
---
## Known build warnings (pre-existing — do not fix unless tasked)
| Warning | Project | Note |
| ------- | ------- | ---- |
| `NU1510` on `Microsoft.Extensions.Logging.Debug` | `Baya.Web.Api` | Redundant transitive reference, harmless |
| `NETSDK1057` (preview SDK) | all | .NET 10 SDK is preview on this machine |
+396
View File
@@ -0,0 +1,396 @@
# Server Coding Conventions
Rules enforced for all code in `server/`. These represent the standards expected from a **senior .NET engineer**. Read alongside [AGENTS.md](AGENTS.md).
When in doubt, ask: _would a senior engineer approve this diff without comment?_
---
## 1. Routing
### Rule: all URL segments must be `snake_case`
`SnakeCaseParameterTransformer` (`Baya.WebFramework/Routing/`) is registered globally via `RouteTokenTransformerConvention`. It converts `[controller]` and `[action]` tokens automatically.
```csharp
// ✅ transformer converts MyFeature → my_feature, GetBySlug → get_by_slug
[Route("api/v{version:apiVersion}/[controller]")]
public class MyFeatureController : BaseController
{
[HttpGet("[action]")]
public Task<IActionResult> GetBySlug(...) { }
}
// ❌ bypasses transformer — hardcoded segment escapes snake_case enforcement
[Route("api/v{version:apiVersion}/MyFeature")]
[HttpGet("GetBySlug")]
```
If a method name doesn't read cleanly as a URL, **rename the method** — don't hardcode the route string.
---
## 2. C# code quality
### Use the right type for the job
| Scenario | Use |
|---|---|
| Request/response/DTO | `record` (immutable, value semantics) |
| Domain entity | `class` (mutable state, encapsulated) |
| Shared small value | `readonly record struct` |
| Handler, service | `sealed class` |
### Language features — use them
```csharp
// ✅ primary constructor (C# 12)
public sealed class OrderHandler(IUnitOfWork uow, IMapper mapper) : IRequestHandler<...> { }
// ✅ switch expression over if/else chains
var label = status switch
{
OrderStatus.Pending => "Pending",
OrderStatus.Shipped => "Shipped",
OrderStatus.Cancelled => "Cancelled",
_ => throw new ArgumentOutOfRangeException(nameof(status))
};
// ✅ pattern matching
if (result is { IsSuccess: false, IsNotFound: true }) return NotFound();
// ✅ collection expressions (C# 12)
List<string> tags = ["new", "sale"];
```
### Immutability & safety
- Mark fields `readonly` unless mutation is genuinely needed.
- Prefer `IReadOnlyList<T>` / `IReadOnlyCollection<T>` over `List<T>` in signatures unless the caller needs to mutate.
- Never expose public setters on entities — use methods or constructors.
- Avoid `static` mutable state.
### Null handling
- Enable `<Nullable>enable</Nullable>` in any new project you create.
- Use guard clauses at the entry point; don't scatter null checks throughout.
- Prefer returning `OperationResult.NotFoundResult(...)` over returning `null` from handlers.
- Never use `null!` (null-forgiving) unless you can prove the value cannot be null and the compiler cannot.
### Naming
| Kind | Convention | Example |
|---|---|---|
| Class, record, interface | PascalCase | `OrderHandler`, `IOrderRepository` |
| Method | PascalCase | `GetUserOrdersAsync` |
| Parameter, local variable | camelCase | `orderId`, `userEmail` |
| Private field | `_camelCase` | `_unitOfWork` |
| Constant | PascalCase | `MaxRetryCount` |
| Generic type param | `T` or descriptive `TEntity` | |
| Command | `{Verb}{Noun}Command` | `CreateOrderCommand` |
| Query | `{Verb}{Noun}Query` | `GetUserOrdersQuery` |
| Handler | `{RequestName}Handler` | `CreateOrderCommandHandler` |
| Result DTO | `{RequestName}Result` | `CreateOrderCommandResult` |
No abbreviations unless universally understood (`dto`, `id`, `url`). No Hungarian notation (`strName`, `intCount`).
---
## 3. Async / await
```csharp
// ✅ always async all the way — no .Result or .Wait()
public async ValueTask<OperationResult<T>> Handle(MyQuery request, CancellationToken ct)
{
var entity = await _repository.GetAsync(request.Id, ct);
return OperationResult<T>.SuccessResult(_mapper.Map(entity));
}
// ❌ blocks the thread, risks deadlock
var result = _repository.GetAsync(id).Result;
// ✅ pass CancellationToken through every async call
await _db.SaveChangesAsync(cancellationToken);
// ❌ fire and forget with no error handling
_ = DoSomethingAsync();
```
- Every public async method must accept `CancellationToken` and pass it downstream.
- Use `ValueTask<T>` for hot paths (handlers, repositories). Use `Task<T>` for rarely-called or always-async methods.
- Never use `async void` — it swallows exceptions. Use `async Task` even for event-like callbacks.
- Do not add `.ConfigureAwait(false)` in this ASP.NET Core app — it's unnecessary and adds noise.
---
## 4. Controllers
Every controller must follow this skeleton:
```csharp
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "One-line description shown in Swagger")]
[Authorize(ConstantPolicies.DynamicPermission)] // or [Authorize], or omit for public
public sealed class MyFeatureController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<MyQueryResult>]
public async Task<IActionResult> GetSomething(CancellationToken ct)
=> OperationResult(await sender.Send(new MyQuery(), ct));
[HttpPost("[action]")]
[ProducesOkApiResponseType<MyCommandResult>]
public async Task<IActionResult> CreateSomething(MyCommand command, CancellationToken ct)
=> OperationResult(await sender.Send(command, ct));
}
```
Rules:
- `sealed` — controllers are not designed for inheritance beyond `BaseController`.
- Inject `ISender` via primary constructor — not `IMediator`.
- **Never call `Ok()`, `BadRequest()`, `NotFound()` directly** — always `base.OperationResult(result)`.
- Keep controller methods thin: one `Send`, one `OperationResult`. No business logic in controllers.
- Use `[Display(Description = "...")]` so NSwag generates meaningful Swagger tags.
- Pass `CancellationToken` from the action into `sender.Send(...)`.
### Authorization levels — use the narrowest that fits
| Attribute | When |
|---|---|
| _(none)_ | Truly public (health check, metrics) |
| `[Authorize]` | Any authenticated user |
| `[Authorize(ConstantPolicies.DynamicPermission)]` | Role/claim-gated admin action |
| `[RequireTokenWithoutAuthorization]` | Token must be present but may be expired (e.g. refresh) |
Apply at the **controller level** for uniform policy; override at the action level only for exceptions.
---
## 5. CQRS — feature structure
```
Features/<Area>/
├── Commands/<VerbNoun>Command/
│ ├── <Name>Command.cs record Command(…) : IRequest<OperationResult<T>>
│ ├── <Name>Command.Handler.cs internal sealed class Handler : IRequestHandler<…>
│ └── <Name>Command.Validator.cs AbstractValidator<Command> (omit if no validation needed)
└── Queries/<VerbNoun>Query/
├── <Name>Query.cs record Query(…) : IRequest<OperationResult<T>>
├── <Name>Query.Handler.cs internal sealed class Handler : IRequestHandler<…>
└── <Name>Query.Result.cs record Result(…) ← the DTO returned
```
- Request types are `record` — immutable.
- Handlers are `internal sealed` — they are never used outside the Application layer.
- **Handlers must not throw for expected failures.** Use `OperationResult` factory methods:
- `OperationResult<T>.SuccessResult(value)` — happy path
- `OperationResult<T>.FailureResult(errors)` — validation / business rule failure
- `OperationResult<T>.NotFoundResult(message)` — entity not found
- Only one handler per request type — no conditional dispatch.
- Contracts the handler depends on go in `Application/Contracts/` as interfaces; implementations live in Infrastructure.
---
## 6. Persistence — EF Core rules
```csharp
// ✅ project to DTO in the query — never load full entity for read operations
var dto = await _db.Orders
.AsNoTracking()
.Where(o => o.UserId == userId)
.Select(o => new OrderResult(o.Id, o.Status, o.CreatedAt))
.ToListAsync(ct);
// ❌ loads entire entity graph then maps in memory — N+1 risk
var orders = await _db.Orders.Include(o => o.Lines).ToListAsync();
var dtos = _mapper.Map<List<OrderResult>>(orders);
```
Rules:
- **Always use `AsNoTracking()`** on read-only queries.
- **Always project with `Select()`** in queries — never hydrate full entities just to map them.
- Never load more than you need. Pagination is mandatory for any unbounded list: `Skip` / `Take`.
- Use `Include` only in command handlers where you need to mutate the aggregate and need navigation properties loaded.
- Access the DB through `IUnitOfWork` in Application-layer handlers. `ApplicationDbContext` is only referenced directly inside Infrastructure.
- Commit once per command at the end: `await _unitOfWork.CommitAsync(ct)`.
- One `IEntityTypeConfiguration<T>` per entity, in `Persistence/Configuration/<Area>Config/`.
- Migrations command: `dotnet ef migrations add <Name> --project src/Infrastructure/Baya.Infrastructure.Persistence --startup-project src/API/Baya.Web.Api`
### Soft delete
Every entity that supports soft delete **must** declare a global EF query filter in its `IEntityTypeConfiguration<T>`:
```csharp
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasQueryFilter(o => !o.IsDeleted);
}
```
Without this filter, soft-deleted records appear in every query that doesn't explicitly filter them — a silent data leak. Never add `Where(x => !x.IsDeleted)` in individual queries; the filter makes it automatic and auditable.
### Entity audit fields
When designing or extending an entity, include audit fields alongside timestamps:
| Field | Type | Set by |
|---|---|---|
| `CreatedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Add) |
| `ModifiedAt` | `DateTimeOffset` | `SaveChangesAsync` override (on Update) |
| `CreatedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
| `ModifiedById` | `int?` | `SaveChangesAsync` override via `ICurrentUser` |
Wire `ICurrentUser` (HTTP context accessor wrapped in an interface, registered Scoped) into `ApplicationDbContext` so the context can stamp who made the change without handlers needing to pass it explicitly. Audit fields cannot be backfilled retroactively — design them in from the start.
---
## 7. Validation
- All commands that accept user input need a `FluentValidation` validator. The `ValidateCommandBehavior` pipeline behavior runs it automatically before the handler.
- Validators are registered automatically via `RegisterValidatorsAsServices()` in `Program.cs`.
- Validate at the boundary (command/query), not deep in the domain or repositories.
```csharp
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.UserId).GreaterThan(0);
RuleFor(x => x.Items).NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
```
---
## 8. Mapping — Mapster rules
- Use `IMapper` (injected via DI) for all entity↔DTO mapping in handlers.
- Register type adapter configs in `Program.cs` via `TypeAdapterConfig.GlobalSettings.Scan(...)`. Add new assemblies that contain mapping configs there.
- Never write manual mapping code when Mapster can infer it — only write custom `TypeAdapterConfig` when shapes diverge.
- Mapping happens **in the handler after the DB query**, not in the repository.
---
## 9. Error handling & logging
```csharp
// ✅ expected failure — use OperationResult, do not throw
if (user is null)
return OperationResult<T>.NotFoundResult("User not found.");
// ✅ unexpected failure — let it propagate; ExceptionHandler middleware catches it
// Log at the point you catch unexpected exceptions (ExceptionHandler logs automatically)
// ❌ swallowing exceptions
try { ... } catch { return OperationResult<T>.FailureResult(...); }
// ✅ structured logging — never interpolate sensitive data
_logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);
// ❌ logs PII / secrets
_logger.LogInformation($"Token for {user.Email}: {token}");
```
- Log at the correct level: `Debug` for trace info, `Information` for meaningful events, `Warning` for recoverable issues, `Error` for unexpected failures.
- Never log passwords, tokens, secrets, or full PII (email is borderline — use `userId` in logs instead).
- The global `ExceptionHandler` middleware catches unhandled exceptions — do not add try/catch in handlers for unknown exceptions; let them propagate.
---
## 10. Testing
### Arrange — Act — Assert, always
```csharp
[Fact]
public async Task CreateOrder_ValidCommand_ReturnsSuccess()
{
// Arrange
var command = new CreateOrderCommand(UserId: 1, Items: [new(ProductId: 5, Quantity: 2)]);
var handler = new CreateOrderCommandHandler(_unitOfWork, _mapper);
// Act
var result = await handler.Handle(command, CancellationToken.None);
// Assert
result.IsSuccess.Should().BeTrue();
result.Result.Should().NotBeNull();
}
```
- Test the **handler directly** — not the controller. Controllers are thin wrappers.
- Use `NSubstitute` for mocking: `Substitute.For<IUnitOfWork>()`.
- Integration tests use `Baya.Tests.Setup` which provides an in-memory SQLite context — prefer this over mocking the DB for persistence tests.
- Name tests: `{MethodUnderTest}_{Scenario}_{ExpectedOutcome}`.
- One assertion concept per test. Multiple `.Should()` calls are fine if they all verify the same outcome.
- Do not test EF internals (entity tracking, migrations) — test behavior through the handler.
### Integration tests — HTTP pipeline coverage
Handler tests verify business logic but leave the entire HTTP stack (routing, auth pipeline, middleware, `OperationResult → IActionResult` translation) untested. Each feature area must have at least one `WebApplicationFactory<Program>`-based test covering:
1. Happy path — authenticated request returns 200 with correct body shape.
2. Unauthenticated request returns 401.
3. Validation failure returns 400 with field-level error detail.
```csharp
public class MyFeatureApiTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetSomething_Authenticated_Returns200()
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokens.ValidAdminToken);
var response = await client.GetAsync("/api/v1/my_feature/get_something");
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
}
```
Place these tests in a dedicated `Baya.Test.Api` project so they can run against the full `Program.cs` wiring.
---
## 11. Security rules
- **Never hardcode secrets.** Keys, connection strings, and tokens come from `appsettings.*.json` / user-secrets / environment variables, bound to typed settings classes.
- `SecretKey` and `Encryptkey` (in `IdentitySettings`) must be set in environment-specific config, never in `appsettings.json` committed to the repo.
- Always validate all external input with FluentValidation before processing.
- EF Core parameterizes queries automatically — never concatenate raw SQL.
- If you must use raw SQL, use `FromSqlInterpolated` (parameterized), never `FromSqlRaw` with user data.
- Respect the principle of least privilege: grant `[Authorize(ConstantPolicies.DynamicPermission)]` to admin actions, not just `[Authorize]`.
- **Auth and OTP endpoints must be rate-limited.** Use ASP.NET Core's built-in `AddRateLimiter` (no extra NuGet package needed). Apply at minimum to: login, OTP request, and token refresh. A fixed window or token bucket policy per IP is the baseline. Register the limiter in a `ServiceConfiguration/` extension; add `app.UseRateLimiter()` before `app.UseAuthentication()` in `Program.cs`.
---
## 12. Service registration
- Every new infrastructure service gets an extension method in the project's `ServiceConfiguration/` folder.
- That extension is called from `Program.cs` — no inline DI registration in `Program.cs`.
- Register with the correct lifetime:
- **Singleton** — stateless, thread-safe services (e.g. `IHttpContextAccessor`)
- **Scoped** — per-request services (repositories, `DbContext`, handlers)
- **Transient** — lightweight, stateless (validators, transformers)
- All NuGet versions live in `Directory.Packages.props`. Never add `Version=` to a `<PackageReference>` in a `.csproj`.
---
## 13. Code organisation
- One type per file. File name matches the type name exactly.
- Handlers and validators go in the same feature folder — not in separate `Handlers/` or `Validators/` root folders.
- If a file exceeds ~150 lines, consider splitting it. Long files usually mean mixed concerns.
- Partial classes are only for generated code (source generators, EF scaffolding).
- Keep `Program.cs` as an orchestrator — extension method calls only, no logic.
+34 -34
View File
@@ -4,54 +4,54 @@
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Asp.Versioning.Http" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageVersion Include="Asp.Versioning.Http" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="10.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="FluentValidation" Version="12.1.0" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.71.0" />
<PackageVersion Include="Grpc.AspNetCore.Server.Reflection" Version="2.71.0" />
<PackageVersion Include="Mapster" Version="7.4.0" />
<PackageVersion Include="Mapster.DependencyInjection" Version="1.0.1" />
<PackageVersion Include="Mediator.Abstractions" Version="3.0.1" />
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="FluentValidation" Version="12.1.1" />
<PackageVersion Include="Grpc.AspNetCore" Version="2.80.0" />
<PackageVersion Include="Grpc.AspNetCore.Server.Reflection" Version="2.80.0" />
<PackageVersion Include="Mapster" Version="10.0.8" />
<PackageVersion Include="Mapster.DependencyInjection" Version="10.0.8" />
<PackageVersion Include="Mediator.Abstractions" Version="3.0.2" />
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Identity.Core" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Debug" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.6.2" />
<PackageVersion Include="NuGet.Packaging" Version="7.0.0" />
<PackageVersion Include="NSwag.AspNetCore" Version="14.7.1" />
<PackageVersion Include="NuGet.Packaging" Version="7.6.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.13.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.16.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.2" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageVersion Include="Pluralize.NET" Version="1.0.2" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.AspNetCore.HealthChecks" Version="8.2.1" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Span" Version="3.1.0" />
<PackageVersion Include="Serilog.Exceptions" Version="8.4.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.Elasticsearch" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="9.0.2" />
<PackageVersion Include="Serilog.Sinks.MSSqlServer" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.PeriodicBatching" Version="5.0.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Linq.Async" Version="7.0.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
</Project>
@@ -1,37 +0,0 @@
using Asp.Versioning;
using Baya.Application.Features.Admin.Commands.AddAdminCommand;
using Baya.Application.Features.Admin.Queries.GetToken;
using Baya.Application.Models.Jwt;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/AdminManager")]
public class AdminManagerController(ISender sender) : BaseController
{
[HttpPost("Login")]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> AdminLogin(AdminGetTokenQuery model)
{
var query = await sender.Send(model);
return base.OperationResult(query);
}
[Authorize(Roles = "admin")]
[HttpPost("NewAdmin")]
[ProducesOkApiResponseType]
public async Task<IActionResult> AddNewAdmin(AddAdminCommand model)
{
var commandResult = await sender.Send(model);
return base.OperationResult(commandResult);
}
}
}
@@ -1,36 +0,0 @@
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Order.Queries.GetAllOrders;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/OrderManagement")]
[Display(Description= "Managing Users related Orders")]
[Authorize(ConstantPolicies.DynamicPermission)]
public class OrderManagementController : BaseController
{
private readonly ISender _sender;
public OrderManagementController(ISender sender)
{
_sender = sender;
}
[HttpGet("OrderList")]
[ProducesOkApiResponseType<List<GetAllOrdersQueryResult>>]
public async Task<IActionResult> GetOrders()
{
var queryResult = await _sender.Send(new GetAllOrdersQuery());
return base.OperationResult(queryResult);
}
}
}
@@ -1,67 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Role.Commands.AddRoleCommand;
using Baya.Application.Features.Role.Commands.UpdateRoleClaimsCommand;
using Baya.Application.Features.Role.Queries.GetAllRolesQuery;
using Baya.Application.Features.Role.Queries.GetAuthorizableRoutesQuery;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/RoleManager")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Managing Related Roles for the System")]
public class RoleManagerController(ISender sender) : BaseController
{
[HttpGet("Roles")]
[ProducesOkApiResponseType<List<GetAllRolesQueryResponse>>]
public async Task<IActionResult> GetRoles()
{
var queryResult = await sender.Send(new GetAllRolesQuery());
return base.OperationResult(queryResult);
}
[HttpGet("AuthRoutes")]
[ProducesOkApiResponseType<List<GetAuthorizableRoutesQueryResponse>>]
public async Task<IActionResult> GetAuthRoutes()
{
var queryModel = await sender.Send(new GetAuthorizableRoutesQuery());
return base.OperationResult(queryModel);
}
/// <summary>
/// Update a role permissions (claims) based on RouteKey received in AuthRoutes API
/// </summary>
/// <param name="model"></param>
/// <returns></returns>
[HttpPut("UpdateRolePermissions")]
[ProducesOkApiResponseType]
public async Task<IActionResult> UpdateRolePermissions(UpdateRoleClaimsCommand model)
{
var commandResult =
await sender.Send(new UpdateRoleClaimsCommand(model.RoleId, model.RoleClaimValue));
return base.OperationResult(commandResult);
}
[HttpPost("NewRole")]
[ProducesOkApiResponseType]
public async Task<IActionResult> AddRole(AddRoleCommand model)
{
var commandResult = await sender.Send(model);
return base.OperationResult(commandResult);
}
}
}
@@ -1,36 +0,0 @@
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Users.Queries.GetUsers;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
namespace Baya.Web.Api.Controllers.V1.Admin
{
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/UserManagement")]
[Display(Description = "Managing API Users")]
[Authorize(ConstantPolicies.DynamicPermission)]
public class UserManagementController : BaseController
{
private readonly ISender _sender;
public UserManagementController(ISender sender)
{
_sender = sender;
}
[HttpGet("CurrentUsers")]
[ProducesOkApiResponseType<List<GetUsersQueryResponse>>]
public async Task<IActionResult> GetAllUsers()
{
var queryResult = await _sender.Send(new GetUsersQuery());
return base.OperationResult(queryResult);
}
}
}
@@ -1,52 +0,0 @@
using Asp.Versioning;
using Baya.Application.Features.Order.Commands;
using Baya.Application.Features.Order.Queries.GetUserOrders;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.Order;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/User")]
[Authorize]
public class OrderController(ISender sender) : BaseController
{
[HttpPost("CreateNewOrder")]
[ProducesOkApiResponseType]
public async Task<IActionResult> CreateNewOrder(AddOrderCommand model)
{
model.UserId = base.UserId;
var command = await sender.Send(model);
return base.OperationResult(command);
}
[HttpGet("GetUserOrders")]
[ProducesOkApiResponseType<List<GetUsersQueryResultModel>>]
public async Task<IActionResult> GetUserOrders()
{
var query = await sender.Send(new GetUserOrdersQueryModel(UserId));
return base.OperationResult(query);
}
[HttpPut("UpdateOrder")]
[ProducesOkApiResponseType]
public async Task<IActionResult> UpdateOrder(UpdateUserOrderCommand model)
{
model.UserId=base.UserId;
var command = await sender.Send(model);
return base.OperationResult(command);
}
[HttpDelete("DeleteAllUserOrders")]
[ProducesOkApiResponseType]
public async Task<IActionResult> DeleteAllUserOrders()
=> base.OperationResult(await sender.Send(new DeleteUserOrdersCommand(base.UserId)));
}
@@ -1,88 +0,0 @@
using Asp.Versioning;
using Baya.Application.Features.Users.Commands.Create;
using Baya.Application.Features.Users.Commands.RefreshUserTokenCommand;
using Baya.Application.Features.Users.Commands.RequestLogout;
using Baya.Application.Features.Users.Queries.GenerateUserToken;
using Baya.Application.Features.Users.Queries.TokenRequest;
using Baya.Application.Models.Jwt;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.Swagger;
using Mediator;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1.UserManagement;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/User")]
public class UserController : BaseController
{
private readonly IMediator _mediator;
public UserController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost("Register")]
[ProducesOkApiResponseType<UserCreateCommandResult>]
public async Task<IActionResult> CreateUser(UserCreateCommand model)
{
var command = await _mediator.Send(model);
return base.OperationResult(command);
}
[HttpPost("TokenRequest")]
[ProducesOkApiResponseType<UserTokenRequestQueryResponse>]
public async Task<IActionResult> TokenRequest(UserTokenRequestQuery model)
{
var query = await _mediator.Send(model);
return base.OperationResult(query);
}
[HttpPost("LoginConfirmation")]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> ValidateUser(GenerateUserTokenQuery model)
{
var result = await _mediator.Send(model);
return base.OperationResult(result);
}
[HttpPost("RefreshSignIn")]
[RequireTokenWithoutAuthorization]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> RefreshUserToken(RefreshUserTokenCommand model)
{
var checkCurrentAccessTokenValidity =await HttpContext.AuthenticateAsync(JwtBearerDefaults.AuthenticationScheme);
if (checkCurrentAccessTokenValidity.Succeeded)
return BadRequest("Current access token is valid. No need to refresh");
var newTokenResult = await _mediator.Send(model);
return base.OperationResult(newTokenResult);
}
[HttpPost("Logout")]
[Authorize]
[ProducesOkApiResponseType]
public async Task<IActionResult> RequestLogout()
{
var commandResult = await _mediator.Send(new RequestLogoutCommand(base.UserId));
return base.OperationResult(commandResult);
}
[HttpPost("PasswordTokenRequest")]
[ProducesOkApiResponseType<AccessToken>]
public async Task<IActionResult> PasswordTokenRequest(PasswordUserTokenRequestQuery model)
=> base.OperationResult(await _mediator.Send(model));
}
+3 -1
View File
@@ -11,14 +11,15 @@ using Baya.Infrastructure.Identity.ServiceConfiguration;
using Baya.Infrastructure.Monitoring.Configurations;
using Baya.Infrastructure.Persistence.ServiceConfiguration;
using Baya.SharedKernel.Extensions;
using Baya.Web.Api.Controllers.V1.UserManagement;
using Baya.Web.Plugins.Grpc;
using Baya.WebFramework.Filters;
using Baya.WebFramework.Middlewares;
using Baya.WebFramework.Routing;
using Baya.WebFramework.ServiceConfiguration;
using Baya.WebFramework.Swagger;
using Mapster;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Serilog;
var builder = WebApplication.CreateBuilder(args);
@@ -39,6 +40,7 @@ var identitySettings = configuration.GetSection(nameof(IdentitySettings)).Get<Id
builder.Services.AddControllers(options =>
{
options.Conventions.Add(new RouteTokenTransformerConvention(new SnakeCaseParameterTransformer()));
options.Filters.Add(typeof(OkResultAttribute));
options.Filters.Add(typeof(NotFoundResultAttribute));
options.Filters.Add(typeof(ContentResultFilterAttribute));
@@ -0,0 +1,17 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Routing;
namespace Baya.WebFramework.Routing;
public sealed class SnakeCaseParameterTransformer : IOutboundParameterTransformer
{
private static readonly Regex _upperAfterLower = new(@"([a-z0-9])([A-Z])", RegexOptions.Compiled);
private static readonly Regex _consecutiveUpper = new(@"([A-Z]+)([A-Z][a-z])", RegexOptions.Compiled);
public string TransformOutbound(object value)
{
if (value is null) return null;
var s = _consecutiveUpper.Replace(value.ToString()!, "$1_$2");
return _upperAfterLower.Replace(s, "$1_$2").ToLowerInvariant();
}
}
@@ -23,6 +23,7 @@ public class UnitOfWork : IUnitOfWork
public ValueTask RollBackAsync()
{
return _db.DisposeAsync();
_db.ChangeTracker.Clear();
return ValueTask.CompletedTask;
}
}