571 lines
12 KiB
Markdown
571 lines
12 KiB
Markdown
# Endpoint Mapping
|
|
|
|
How HTTP endpoints are automatically generated from commands and queries.
|
|
|
|
## Overview
|
|
|
|
The `MapSvrntyCommands()` and `MapSvrntyQueries()` extension methods use metadata discovery to automatically create Minimal API endpoints for all registered commands and queries.
|
|
|
|
**No manual controller code required!**
|
|
|
|
## Discovery Process
|
|
|
|
```
|
|
┌──────────────────────────────┐
|
|
│ Application Startup │
|
|
└─────────────┬────────────────┘
|
|
│
|
|
▼
|
|
┌──────────────────────────────┐
|
|
│ MapSvrntyCommands() │
|
|
│ 1. Get ICommandDiscovery │
|
|
│ 2. Get all registered │
|
|
│ command metadata │
|
|
└─────────────┬────────────────┘
|
|
│
|
|
▼
|
|
┌──────────────────────────────┐
|
|
│ For Each Command: │
|
|
│ 1. Check [IgnoreCommand] │
|
|
│ 2. Get command name │
|
|
│ 3. Get handler type │
|
|
│ 4. Get result type │
|
|
│ 5. Create endpoint │
|
|
└─────────────┬────────────────┘
|
|
│
|
|
▼
|
|
┌──────────────────────────────┐
|
|
│ Generated Endpoint: │
|
|
│ POST /api/command/{name} │
|
|
│ - Model binding │
|
|
│ - Validation │
|
|
│ - Authorization │
|
|
│ - Handler invocation │
|
|
│ - Response formatting │
|
|
└──────────────────────────────┘
|
|
```
|
|
|
|
## Command Mapping
|
|
|
|
### MapSvrntyCommands
|
|
|
|
```csharp
|
|
app.MapSvrntyCommands();
|
|
```
|
|
|
|
**This generates:**
|
|
|
|
```csharp
|
|
// For each registered command
|
|
app.MapPost("/api/command/{commandName}", async (
|
|
[FromBody] TCommand command,
|
|
[FromServices] ICommandHandler<TCommand, TResult> handler,
|
|
[FromServices] IValidator<TCommand>? validator,
|
|
[FromServices] ICommandAuthorizationService<TCommand>? authService,
|
|
HttpContext httpContext,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
// Validate
|
|
if (validator != null)
|
|
{
|
|
var validationResult = await validator.ValidateAsync(command, cancellationToken);
|
|
if (!validationResult.IsValid)
|
|
{
|
|
return Results.ValidationProblem(validationResult.ToDictionary());
|
|
}
|
|
}
|
|
|
|
// Authorize
|
|
if (authService != null)
|
|
{
|
|
var canExecute = await authService.CanExecuteAsync(
|
|
command,
|
|
httpContext.User,
|
|
cancellationToken);
|
|
|
|
if (!canExecute)
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
}
|
|
|
|
// Execute
|
|
var result = await handler.HandleAsync(command, cancellationToken);
|
|
|
|
// Return result
|
|
return result != null ? Results.Ok(result) : Results.NoContent();
|
|
})
|
|
.WithTags("Commands")
|
|
.WithOpenApi();
|
|
```
|
|
|
|
### Custom Route Prefix
|
|
|
|
```csharp
|
|
app.MapSvrntyCommands("my-commands");
|
|
// POST /my-commands/{commandName}
|
|
```
|
|
|
|
### Ignore Specific Commands
|
|
|
|
```csharp
|
|
[IgnoreCommand]
|
|
public record InternalCommand { }
|
|
|
|
// No endpoint generated
|
|
```
|
|
|
|
## Query Mapping
|
|
|
|
### MapSvrntyQueries
|
|
|
|
```csharp
|
|
app.MapSvrntyQueries();
|
|
```
|
|
|
|
**This generates TWO endpoints per query:**
|
|
|
|
#### GET Endpoint
|
|
|
|
```csharp
|
|
app.MapGet("/api/query/{queryName}", async (
|
|
[AsParameters] TQuery query,
|
|
[FromServices] IQueryHandler<TQuery, TResult> handler,
|
|
[FromServices] IQueryAuthorizationService<TQuery>? authService,
|
|
HttpContext httpContext,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
// Authorize
|
|
if (authService != null)
|
|
{
|
|
var canExecute = await authService.CanExecuteAsync(
|
|
query,
|
|
httpContext.User,
|
|
cancellationToken);
|
|
|
|
if (!canExecute)
|
|
{
|
|
return Results.Forbid();
|
|
}
|
|
}
|
|
|
|
// Execute
|
|
var result = await handler.HandleAsync(query, cancellationToken);
|
|
|
|
return Results.Ok(result);
|
|
})
|
|
.WithTags("Queries")
|
|
.WithOpenApi();
|
|
```
|
|
|
|
#### POST Endpoint
|
|
|
|
```csharp
|
|
app.MapPost("/api/query/{queryName}", async (
|
|
[FromBody] TQuery query,
|
|
[FromServices] IQueryHandler<TQuery, TResult> handler,
|
|
[FromServices] IQueryAuthorizationService<TQuery>? authService,
|
|
HttpContext httpContext,
|
|
CancellationToken cancellationToken) =>
|
|
{
|
|
// Same as GET but query comes from body
|
|
})
|
|
.WithTags("Queries")
|
|
.WithOpenApi();
|
|
```
|
|
|
|
## Naming Resolution
|
|
|
|
### Default Naming
|
|
|
|
Command/Query class names are converted to endpoints:
|
|
|
|
| Class Name | Endpoint |
|
|
|------------|----------|
|
|
| `CreateUserCommand` | `/api/command/createUser` |
|
|
| `GetUserQuery` | `/api/query/getUser` |
|
|
| `SearchProductsQuery` | `/api/query/searchProducts` |
|
|
| `UpdateOrderStatusCommand` | `/api/command/updateOrderStatus` |
|
|
|
|
**Rules:**
|
|
1. Remove "Command" or "Query" suffix
|
|
2. Convert to lowerCamelCase
|
|
3. Preserve numbers and underscores
|
|
|
|
### Custom Names
|
|
|
|
Use `[CommandName]` or `[QueryName]` attributes:
|
|
|
|
```csharp
|
|
[CommandName("users/create")]
|
|
public record CreateUserCommand { }
|
|
|
|
// Endpoint: POST /api/command/users/create
|
|
```
|
|
|
|
```csharp
|
|
[QueryName("products/search")]
|
|
public record SearchProductsQuery { }
|
|
|
|
// Endpoints:
|
|
// GET /api/query/products/search
|
|
// POST /api/query/products/search
|
|
```
|
|
|
|
## Model Binding
|
|
|
|
### Commands (POST only)
|
|
|
|
```csharp
|
|
POST /api/command/createUser
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"name": "John Doe",
|
|
"email": "john@example.com"
|
|
}
|
|
```
|
|
|
|
Model binding deserializes JSON to command object.
|
|
|
|
### Queries (GET)
|
|
|
|
```csharp
|
|
GET /api/query/searchProducts?category=Electronics&minPrice=100&maxPrice=500
|
|
```
|
|
|
|
Model binding maps query string parameters to query properties.
|
|
|
|
### Queries (POST)
|
|
|
|
```csharp
|
|
POST /api/query/searchProducts
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"category": "Electronics",
|
|
"minPrice": 100,
|
|
"maxPrice": 500
|
|
}
|
|
```
|
|
|
|
Model binding deserializes JSON to query object.
|
|
|
|
## Validation Integration
|
|
|
|
### Automatic Validation
|
|
|
|
If `IValidator<TCommand>` is registered:
|
|
|
|
```csharp
|
|
builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
|
|
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();
|
|
```
|
|
|
|
The endpoint automatically validates before calling the handler.
|
|
|
|
**Validation failure:**
|
|
```json
|
|
{
|
|
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
|
"title": "One or more validation errors occurred.",
|
|
"status": 400,
|
|
"errors": {
|
|
"Name": ["Name is required"]
|
|
}
|
|
}
|
|
```
|
|
|
|
## Authorization Integration
|
|
|
|
### Automatic Authorization
|
|
|
|
If authorization service is registered:
|
|
|
|
```csharp
|
|
builder.Services.AddScoped<ICommandAuthorizationService<DeleteUserCommand>, DeleteUserCommandAuthorization>();
|
|
```
|
|
|
|
The endpoint checks authorization before execution.
|
|
|
|
**Authorization failure:**
|
|
```
|
|
HTTP/1.1 403 Forbidden
|
|
```
|
|
|
|
## Response Types
|
|
|
|
### Commands Without Result
|
|
|
|
```csharp
|
|
public class DeleteUserCommandHandler : ICommandHandler<DeleteUserCommand>
|
|
{
|
|
public async Task HandleAsync(DeleteUserCommand command, CancellationToken cancellationToken)
|
|
{
|
|
// Delete user
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```
|
|
HTTP/1.1 204 No Content
|
|
```
|
|
|
|
### Commands With Result
|
|
|
|
```csharp
|
|
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
|
|
{
|
|
public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
|
|
{
|
|
return newUserId;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
HTTP/1.1 200 OK
|
|
Content-Type: application/json
|
|
|
|
42
|
|
```
|
|
|
|
### Queries
|
|
|
|
```csharp
|
|
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
|
|
{
|
|
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
|
|
{
|
|
return userDto;
|
|
}
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
HTTP/1.1 200 OK
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"id": 123,
|
|
"name": "John Doe",
|
|
"email": "john@example.com"
|
|
}
|
|
```
|
|
|
|
## OpenAPI Integration
|
|
|
|
### Automatic Tags
|
|
|
|
All command endpoints are tagged with "Commands":
|
|
|
|
```json
|
|
{
|
|
"paths": {
|
|
"/api/command/createUser": {
|
|
"post": {
|
|
"tags": ["Commands"],
|
|
...
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
All query endpoints are tagged with "Queries":
|
|
|
|
```json
|
|
{
|
|
"paths": {
|
|
"/api/query/getUser": {
|
|
"get": {
|
|
"tags": ["Queries"],
|
|
...
|
|
},
|
|
"post": {
|
|
"tags": ["Queries"],
|
|
...
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Request/Response Schemas
|
|
|
|
Swagger automatically documents request and response types:
|
|
|
|
```json
|
|
{
|
|
"components": {
|
|
"schemas": {
|
|
"CreateUserCommand": {
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string" },
|
|
"email": { "type": "string" }
|
|
}
|
|
},
|
|
"UserDto": {
|
|
"type": "object",
|
|
"properties": {
|
|
"id": { "type": "integer" },
|
|
"name": { "type": "string" },
|
|
"email": { "type": "string" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Content Negotiation
|
|
|
|
### Default: JSON
|
|
|
|
```
|
|
Accept: application/json
|
|
Content-Type: application/json
|
|
```
|
|
|
|
### XML Support (Optional)
|
|
|
|
```csharp
|
|
builder.Services.AddControllers()
|
|
.AddXmlSerializerFormatters();
|
|
```
|
|
|
|
```
|
|
Accept: application/xml
|
|
Content-Type: application/xml
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Validation Errors (400)
|
|
|
|
```json
|
|
{
|
|
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
|
|
"title": "One or more validation errors occurred.",
|
|
"status": 400,
|
|
"errors": { ... }
|
|
}
|
|
```
|
|
|
|
### Authorization Failures (403)
|
|
|
|
```
|
|
HTTP/1.1 403 Forbidden
|
|
```
|
|
|
|
### Not Found (404)
|
|
|
|
```csharp
|
|
throw new KeyNotFoundException("User not found");
|
|
```
|
|
|
|
```
|
|
HTTP/1.1 404 Not Found
|
|
```
|
|
|
|
### Unhandled Exceptions (500)
|
|
|
|
```
|
|
HTTP/1.1 500 Internal Server Error
|
|
```
|
|
|
|
## Customization
|
|
|
|
### Custom Endpoint Configuration
|
|
|
|
```csharp
|
|
app.MapSvrntyCommands(options =>
|
|
{
|
|
options.RoutePrefix = "my-commands";
|
|
options.RequireAuthorization = true;
|
|
options.AllowAnonymous = false;
|
|
});
|
|
```
|
|
|
|
### Per-Endpoint Customization
|
|
|
|
After mapping, you can customize individual endpoints:
|
|
|
|
```csharp
|
|
var commandEndpoints = app.MapSvrntyCommands();
|
|
|
|
// Customize specific endpoint
|
|
commandEndpoints
|
|
.Where(e => e.DisplayName == "CreateUser")
|
|
.RequireAuthorization("AdminOnly");
|
|
```
|
|
|
|
## Testing Endpoints
|
|
|
|
### Integration Tests
|
|
|
|
```csharp
|
|
public class CreateUserCommandTests : IClassFixture<WebApplicationFactory<Program>>
|
|
{
|
|
private readonly HttpClient _client;
|
|
|
|
public CreateUserCommandTests(WebApplicationFactory<Program> factory)
|
|
{
|
|
_client = factory.CreateClient();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateUser_WithValidData_Returns200()
|
|
{
|
|
var command = new { name = "John Doe", email = "john@example.com" };
|
|
|
|
var response = await _client.PostAsJsonAsync("/api/command/createUser", command);
|
|
|
|
response.EnsureSuccessStatusCode();
|
|
var userId = await response.Content.ReadFromJsonAsync<int>();
|
|
Assert.True(userId > 0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CreateUser_WithInvalidData_Returns400()
|
|
{
|
|
var command = new { name = "", email = "invalid" };
|
|
|
|
var response = await _client.PostAsJsonAsync("/api/command/createUser", command);
|
|
|
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
|
}
|
|
}
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
|
|
- Use MapSvrntyCommands() and MapSvrntyQueries()
|
|
- Let the framework handle endpoint generation
|
|
- Use [IgnoreCommand]/[IgnoreQuery] for internal operations
|
|
- Rely on automatic validation and authorization
|
|
- Use OpenAPI tags for organization
|
|
- Test endpoints with integration tests
|
|
|
|
### ❌ DON'T
|
|
|
|
- Don't create manual controllers for CQRS operations
|
|
- Don't bypass validation or authorization
|
|
- Don't expose internal commands via HTTP
|
|
- Don't skip error handling
|
|
- Don't ignore HTTP status codes
|
|
|
|
## See Also
|
|
|
|
- [HTTP Integration Overview](README.md)
|
|
- [Naming Conventions](naming-conventions.md)
|
|
- [HTTP Configuration](http-configuration.md)
|
|
- [Swagger Integration](swagger-integration.md)
|
|
- [Metadata Discovery](../architecture/metadata-discovery.md)
|