Mapping Endpoints

Map HTTP methods to handlers with MapGet, MapPost, MapPut, MapPatch and MapDelete, write async handlers and honour request cancellation.

Syntaxapp.MapGet("/orders/{id:int}", async (int id, OrderStore store, CancellationToken ct) => await store.FindAsync(id, ct) is { } order ? Results.Ok(order) : Results.NotFound());

A minimal API endpoint is three things: a route template, an HTTP method and a delegate. app.MapGet("/orders/{id:int}", handler) adds a RouteEndpoint to the app's endpoint data source. Routing picks the endpoint, and a request delegate generated from your handler's signature binds the parameters, calls the handler and writes whatever it returns.

The mapping methods

MethodMatchesTypical use
MapGetGETRead a resource or a list
MapPostPOSTCreate, or run a command
MapPutPUTReplace a whole resource
MapPatchPATCHChange part of a resource
MapDeleteDELETERemove a resource
MapMethodsA list you giveHEAD, OPTIONS or several verbs on one handler

When the path matches but the method does not, the client gets 405 Method Not Allowed, not 404. MapGet does not answer HEAD; if a load balancer or CDN probes with HEAD, map it with MapMethods.

What the return value becomes

  • An IResult (from Results or TypedResults) is executed and decides status, headers and body.
  • A string is written as 200 text/plain.
  • Any other type is serialized as 200 JSON with the web defaults (camelCase).
  • Task<T> and ValueTask<T> are awaited, then the same rules apply. void or Task gives 200 with an empty body.

A handler that returns a null Order? sends 200 with the body null. It does not become a 404; return Results.NotFound() yourself.

Lambdas or methods

A lambda is fine for a few lines. Once a handler branches, move it to a static method and pass the method group: app.MapGet("/orders/{id:int}", OrderHandlers.GetById). You can unit test it without HTTP and Program.cs stays a table of routes. Parameter names are part of the contract: {id} binds to a parameter called id. Rename it to orderId and the binder treats it as a query string value, so every call returns 400.

Async and cancellation

Handlers that do I/O should be async and return Task<T>. A CancellationToken parameter is bound to HttpContext.RequestAborted, which fires when the client disconnects or a request timeout expires. Pass it to EF Core, HttpClient and database calls so abandoned requests release connections. Never block with .Result or .Wait(); under load that starves the thread pool.

When handler mistakes surface

The request delegate for each endpoint is generated from the handler's signature the first time routing builds its endpoint table (the first request), not at Build(). A bad signature, such as two parameters that both infer from the body, throws then, and while it does every request to the app fails with 500, not only the faulty route. Have an integration test or a start-up smoke request hit the app so this fails in CI, not after the deploy. With Native AOT, the Request Delegate Generator does this work at compile time instead.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Expose GET, POST, PUT, PATCH and DELETE for /orders with handlers that live in an OrderHandlers class instead of Program.cs.
  • Stop an expensive report query when the browser tab that asked for it is closed, by passing the request's CancellationToken to the database call.
  • Answer HEAD /products/{sku}/image for a CDN health probe that currently receives 405 from a MapGet-only route.

More examples

Answering HEAD and several verbs with MapMethods

<code>MapMethods</code> takes the verbs explicitly, and the <code>HttpMethods</code> constants avoid typos such as <code>"Get "</code>. <code>Results.File</code> with a physical path handles HEAD correctly by sending headers without a body, and <code>Path.GetFileName</code> stops a SKU like <code>../../appsettings</code> from escaping the image folder. Splitting verbs into separate handlers is usually clearer; combine them only when the code is genuinely shared.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Uptime monitors and some CDNs send HEAD; MapGet alone would answer 405.
app.MapMethods("/products/{sku}/image", [HttpMethods.Get, HttpMethods.Head], (string sku, HttpContext context) =>
{
    var path = Path.Combine("catalog-images", Path.GetFileName(sku) + ".webp");
    if (!File.Exists(path)) return Results.NotFound();

    // For HEAD the server sends the headers and Content-Length but no body.
    return Results.File(Path.GetFullPath(path), "image/webp", enableRangeProcessing: true);
});

// One handler for several verbs is rare; prefer separate MapPut/MapPatch when the semantics differ.
app.MapMethods("/inventory/{sku}/reservation", [HttpMethods.Put, HttpMethods.Delete], (string sku, HttpRequest request) =>
    request.Method == HttpMethods.Put
        ? Results.Ok(new { sku, reserved = true })
        : Results.NoContent());

app.Run();

Cancellation that stops where it should

The request token is the right default for reads and for work done before the first side effect, so the handler checks it once and then stops observing it. Passing it to the capture call would be the bug: a disconnect after the provider received the POST but before it answered throws <code>OperationCanceledException</code>, the ledger write never runs, and the money has moved with no record. The capture and the ledger write therefore use <code>CancellationToken.None</code> (the <code>HttpClient</code> timeout still bounds the call), and the <code>Idempotency-Key</code> derived from the order id lets the client's retry reach the provider without capturing twice.

Example · csharp
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<PaymentGateway>(client => client.BaseAddress = new Uri("https://payments.example.com"));
builder.Services.AddSingleton<PaymentLedger>();
var app = builder.Build();

app.MapPost("/orders/{id:int}/payments", async (int id, PaymentRequest request, PaymentGateway gateway, PaymentLedger ledger, CancellationToken ct) =>
{
    // Before the first side effect: if the client has gone, do not start the capture.
    ct.ThrowIfCancellationRequested();

    // From here on the request token is ignored: a disconnect mid-capture must not abandon
    // a capture the provider may already have performed. The client's own timeout still applies.
    var capture = await gateway.CaptureAsync(id, request.Amount, CancellationToken.None);

    // Not cancellable: the money has moved, so the record must be written even if the client disconnected.
    await ledger.RecordAsync(id, capture.ProviderReference, request.Amount, CancellationToken.None);

    return Results.Ok(capture);
});

app.Run();

record PaymentRequest(decimal Amount);
record CaptureResult(string ProviderReference, string Status);

class PaymentGateway(HttpClient http)
{
    public async Task<CaptureResult> CaptureAsync(int orderId, decimal amount, CancellationToken ct)
    {
        using var message = new HttpRequestMessage(HttpMethod.Post, "/captures")
        {
            Content = JsonContent.Create(new { orderId, amount })
        };
        // Same key on every retry for this order, so the provider captures at most once.
        message.Headers.Add("Idempotency-Key", $"order-{orderId}-capture");
        using var response = await http.SendAsync(message, ct);
        response.EnsureSuccessStatusCode();
        return (await response.Content.ReadFromJsonAsync<CaptureResult>(ct))!;
    }
}

class PaymentLedger
{
    public Task RecordAsync(int orderId, string reference, decimal amount, CancellationToken ct) => Task.CompletedTask;
}

A unit test for a method-group handler

Because the handler is an ordinary static method, the test calls it directly: no server, no <code>HttpClient</code>, no JSON. The test asserts the concrete result type (<code>Ok&lt;Order&gt;</code>, <code>NotFound</code>) and reads <code>Value</code>. These are public types, whether the handler builds them with <code>TypedResults</code> or <code>Results</code>. Keep a handful of HTTP-level tests as well, because routing, binding and serialization are not exercised here.

Example · csharp
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http.HttpResults;
using Xunit;

public class OrderHandlerTests
{
    [Fact]
    public async Task GetById_returns_404_for_an_unknown_order()
    {
        var store = new OrderStore();

        var result = await OrderHandlers.GetById(999, store, TestContext.Current.CancellationToken);

        Assert.IsType<NotFound>(result);
    }

    [Fact]
    public async Task GetById_returns_the_order()
    {
        var store = new OrderStore();
        store.Add(new Order(7, "C-1001", 129.90m));

        var result = await OrderHandlers.GetById(7, store, TestContext.Current.CancellationToken);

        var ok = Assert.IsType<Ok<Order>>(result);
        Assert.Equal(129.90m, ok.Value!.Total);
    }
}

public static class OrderHandlers
{
    public static async Task<IResult> GetById(int id, OrderStore store, CancellationToken ct)
    {
        var order = await store.FindAsync(id, ct);
        return order is null ? TypedResults.NotFound() : TypedResults.Ok(order);
    }
}

public sealed class OrderStore
{
    private readonly ConcurrentDictionary<int, Order> _orders = new();
    public void Add(Order order) => _orders[order.Id] = order;
    public Task<Order?> FindAsync(int id, CancellationToken ct) => Task.FromResult(_orders.GetValueOrDefault(id));
}

public record Order(int Id, string CustomerId, decimal Total);

Discussion

  • Be the first to comment on this lesson.