dotnet-cqrs/docs/http-integration/endpoint-mapping.md

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)