Skip to content

Endpoint Classes

github-actions[bot] edited this page Sep 3, 2026 · 4 revisions

Endpoint Classes

The five base types

Base Handler signature Response
ApiEndpoint<TRequest, TResponse> Task<TResponse> HandleAsync(TRequest, CancellationToken) JSON body, SuccessStatus
ApiEndpoint<TRequest> Task HandleAsync(TRequest, CancellationToken) 204 No Content
ApiEndpointWithoutRequest<TResponse> Task<TResponse> HandleAsync(CancellationToken) JSON body, no bound contract
ApiEndpointWithResult<TRequest, TResponse> Task<EndpointResult<TResponse>> HandleAsync(TRequest, CancellationToken) JSON body, status from the result
ApiEndpoint Task HandleAsync(CancellationToken) You write the response yourself

Status decided by the handler

public override async Task<EndpointResult<InvoiceView>> HandleAsync(CreateInvoice cmd, CancellationToken ct)
{
    var (invoice, created) = await store.UpsertAsync(cmd, ct);
    return created
        ? EndpointResult.Status(StatusCodes.Status201Created, InvoiceView.From(invoice))
        : EndpointResult.Ok(InvoiceView.From(invoice));
}

The documented schema stays InvoiceView. The wrapper never reaches the wire.

Writing the response yourself

Derive from the non-generic ApiEndpoint and use HttpContext directly. Reach for this when the response is not JSON at all: a file stream, an event stream, a redirect.

[Get("invoices/{invoiceId}/pdf")]
public sealed class Endpoint(IInvoicePdfStore store) : ApiEndpoint
{
    public override async Task HandleAsync(CancellationToken ct)
    {
        HttpContext.Response.ContentType = "application/pdf";
        await store.CopyToAsync(HttpContext.Request.RouteValues["invoiceId"]!.ToString()!, HttpContext.Response.Body, ct);
    }
}

Nothing is bound and nothing is written on success — the handler owns the status code, the content type, and the body. The shared failure path still applies when it throws: fault renderers, then exception translators, then the sanitized 500. Deriving ApiEndpointBase directly is not mappable — it has no handler method for the pipeline to dispatch — and is reported as NE0005 at build time.

Route attributes

[Get], [Post], [Put], [Patch], and [Delete] all derive from EndpointRouteAttribute. The route is literal and relative to whatever prefix the group was mapped with.

[Post("invoices/{invoiceId}/lines")]

Configure

Most endpoints need no Configure override at all: the attribute carries the route and the namespace carries the operation. Override it to refine anything else.

public override void Configure(ApiEndpointOptions options)
{
    options.BodyMode = EndpointBodyMode.RequiredWithContentType;
    options.Accepts = ["application/json"];
    options.SuccessStatus = StatusCodes.Status202Accepted;
}
Option Meaning
Method, Route Set by the route attribute; override to compute a route
Operation Pins the operation identifier, overriding derivation
Name Pins the endpoint name itself, for an identifier frozen in an already-published document
Accepts Content types the request is accepted as; also decides whether a request schema is documented
BodyMode How the request body is treated. See Binding
SuccessStatus The status written at runtime on success
DocumentedStatus The status the document declares, when it deliberately differs from the runtime one
ResponseType The success body's type, for the response-owning shape. Ignored by the typed bases, which take it from their type argument
SuccessContentType The content type the success response is documented as. Defaults to JSON
DocumentAuthResponses Forces the documented 401/403 pair on or off
ContainFailures Whether the group answers an unhandled exception, or the host pipeline does
Convention(...) Registers an ordinary ASP.NET Core convention

Name versus operation

Operation is the identifier; the name is what the document publishes, and by default it is {group}_{operation}. Setting Name replaces that outright. It exists for one situation: an operation identifier that was published before the naming scheme and that clients already generate from. Reach for Operation first — a derived name keeps the convention intact, and a hand-written one has to stay unique across the host by hand.

options.Operation = "LoginPage";
options.Name = "AspNetCoreIdentityLoginPage";   // frozen; predates the scheme

Owning the whole response

An ApiEndpoint writes its own response, so it declares what that response is rather than having it inferred:

options.ResponseType = typeof(TraceDetail);   // what the body is
options.SuccessContentType = "text/event-stream";
options.ContainFailures = false;              // let the host's exception pipeline answer faults

Owning the response is not the same as having nothing to say about it. All three describe what the handler writes rather than changing it — setting them moves the document, never the response.

Their defaults are not uniform, so it is worth being exact:

Left unset What you get
ResponseType No response body is documented at all
SuccessContentType JSON — but only once a body has been declared; with no body, no content type
ContainFailures Containment on: the group answers unhandled exceptions

Containment is what you want unless the owner's published contract makes the host responsible for unexpected failures — a host already running its own exception middleware, or one serving a UI whose error page is not a problem document. It is honoured only by the response-owning shape; a bound endpoint always contains, because its failure translation is what produces the documented status.

Configure runs on an uninitialized instance. It is invoked once at map time, before any constructor runs, so constructor-injected fields are null inside it. Read only the options argument. NE0003 warns when Configure reads constructor-injected state.

Reaching ASP.NET Core

options.Convention hands you the standard builder:

public override void Configure(ApiEndpointOptions options)
{
    options.Convention(b => b.RequireRateLimiting("invoices").CacheOutput().WithSummary("Fetch an invoice"));
}

Documented versus runtime status

An endpoint can return one status and document another. This exists for published contracts that declared a status the implementation does not actually produce, where changing the document would break clients:

options.SuccessStatus = StatusCodes.Status201Created;   // what callers receive
options.DocumentedStatus = StatusCodes.Status200OK;     // what the document says

Authorization

Authorization is applied with ordinary ASP.NET Core conventions:

options.Convention(b => b.RequireAuthorization("invoices:read"));

Implement IEndpointConventionAttribute to contribute authorization as a class-level attribute without the framework knowing anything about your policy model:

[AttributeUsage(AttributeTargets.Class)]
public sealed class RequirePermissionAttribute(string permission) : Attribute, IEndpointConventionAttribute
{
    public void Apply(IEndpointConventionBuilder builder) => builder.RequireAuthorization(permission);
}
[Get("invoices/{invoiceId}")]
[RequirePermission("invoices:read")]
public sealed class Endpoint : ApiEndpoint<GetInvoice, InvoiceView> { }

The 401 and 403 responses are then documented automatically, because the completed metadata carries authorization. Endpoints marked AllowAnonymous do not document them.

Clone this wiki locally