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

12 KiB

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

app.MapSvrntyCommands();

This generates:

// 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

app.MapSvrntyCommands("my-commands");
// POST /my-commands/{commandName}

Ignore Specific Commands

[IgnoreCommand]
public record InternalCommand { }

// No endpoint generated

Query Mapping

MapSvrntyQueries

app.MapSvrntyQueries();

This generates TWO endpoints per query:

GET Endpoint

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

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:

[CommandName("users/create")]
public record CreateUserCommand { }

// Endpoint: POST /api/command/users/create
[QueryName("products/search")]
public record SearchProductsQuery { }

// Endpoints:
//   GET  /api/query/products/search
//   POST /api/query/products/search

Model Binding

Commands (POST only)

POST /api/command/createUser
Content-Type: application/json

{
  "name": "John Doe",
  "email": "john@example.com"
}

Model binding deserializes JSON to command object.

Queries (GET)

GET /api/query/searchProducts?category=Electronics&minPrice=100&maxPrice=500

Model binding maps query string parameters to query properties.

Queries (POST)

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:

builder.Services.AddCommand<CreateUserCommand, int, CreateUserCommandHandler>();
builder.Services.AddTransient<IValidator<CreateUserCommand>, CreateUserCommandValidator>();

The endpoint automatically validates before calling the handler.

Validation failure:

{
  "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:

builder.Services.AddScoped<ICommandAuthorizationService<DeleteUserCommand>, DeleteUserCommandAuthorization>();

The endpoint checks authorization before execution.

Authorization failure:

HTTP/1.1 403 Forbidden

Response Types

Commands Without Result

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

public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
    public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
    {
        return newUserId;
    }
}

Response:

HTTP/1.1 200 OK
Content-Type: application/json

42

Queries

public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
    public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
    {
        return userDto;
    }
}

Response:

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":

{
  "paths": {
    "/api/command/createUser": {
      "post": {
        "tags": ["Commands"],
        ...
      }
    }
  }
}

All query endpoints are tagged with "Queries":

{
  "paths": {
    "/api/query/getUser": {
      "get": {
        "tags": ["Queries"],
        ...
      },
      "post": {
        "tags": ["Queries"],
        ...
      }
    }
  }
}

Request/Response Schemas

Swagger automatically documents request and response types:

{
  "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)

builder.Services.AddControllers()
    .AddXmlSerializerFormatters();
Accept: application/xml
Content-Type: application/xml

Error Handling

Validation Errors (400)

{
  "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)

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

app.MapSvrntyCommands(options =>
{
    options.RoutePrefix = "my-commands";
    options.RequireAuthorization = true;
    options.AllowAnonymous = false;
});

Per-Endpoint Customization

After mapping, you can customize individual endpoints:

var commandEndpoints = app.MapSvrntyCommands();

// Customize specific endpoint
commandEndpoints
    .Where(e => e.DisplayName == "CreateUser")
    .RequireAuthorization("AdminOnly");

Testing Endpoints

Integration Tests

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