Describing Endpoints

Give every endpoint an operationId, summary, tags and accurate responses with WithName, WithSummary, Produces, endpoint attributes and typed results.

Syntaxgroup.MapGet("/{id:int}", GetOrder) .WithName("GetOrder").WithSummary("Get one order") .ProducesProblem(StatusCodes.Status409Conflict);

The OpenAPI generator invents nothing. Every field in an operation comes from metadata objects attached to the endpoint: WithSummary("...") adds an IEndpointSummaryMetadata, WithTags an ITagsMetadata, Produces<T>(201) an IProducesResponseTypeMetadata. ApiExplorer gathers them into an ApiDescription and the generator maps that to the document. The runnable example prints those descriptions directly, which is exactly what the generator sees.

Where each field comes from

OpenAPI fieldMinimal APIAttribute (handlers and controllers)
operationIdWithName("GetOrder")[EndpointName], [HttpGet(Name = "GetOrder")]
summary / descriptionWithSummary, WithDescription[EndpointSummary], [EndpointDescription], XML comments
tagsWithTags("Orders") (often on the group)[Tags]; controllers default to the controller name
responsestyped results, Produces<T>, ProducesProblem, ProducesValidationProblem[ProducesResponseType<T>(201)]
requestBodyinferred from the bound parameter, or Accepts<T>("application/json")[Consumes]
parameter and property descriptions[Description] from System.ComponentModel, or XML param comments
omitted entirelyExcludeFromDescription()[ExcludeFromDescription], [ApiExplorerSettings(IgnoreApi = true)]

Typed results do most of the work

A handler declared as Task<Results<Ok<OrderResponse>, NotFound>> documents itself: each result type implements IEndpointMetadataProvider and contributes its status code and body type when the endpoint is built. A handler returning IResult is opaque. ApiExplorer then reports a single 200 with no body, even when the handler only ever returns Results.NoContent(); the runnable example shows exactly that for CancelOrder. Use Produces only for what types cannot express: responses produced by filters or middleware, or a non-JSON content type.

Team conventions that pay off

  • Name every endpoint. Client generators turn operationId into method names; without one they invent names from the path, and the names change when the route does.
  • Names are global and must be unique. A duplicate does not stop start-up: the app starts and requests fail with 500 and an InvalidOperationException listing the endpoints that share the name. Use verb plus noun (GetOrder, CancelOrder).
  • Tag at the group: app.MapGroup("/orders").WithTags("Orders") so new endpoints cannot forget it.
  • Summary is one line for the navigation pane; description carries behaviour, limits and error cases.

Production failure modes

Responses that do not come from the handler are invisible: a 401 from authentication or a rejection from the rate limiter never appears unless declared or added by an operation transformer. Declare them as they really are. A JWT bearer or cookie challenge is a 401 with an empty body, so use .Produces(StatusCodes.Status401Unauthorized), not ProducesProblem. The rate limiter answers 503 unless RejectionStatusCode is set to 429, and sends no problem body unless OnRejected writes one. Clients written from the document then treat them as unexpected. The opposite drift is just as common: a ProducesProblem(409) left in place after the conflict check was removed. Integration tests that assert status codes keep the document honest, because they fail when behaviour and declarations diverge.

Example

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

When to use it

  • Give each orders endpoint a stable operationId so the generated TypeScript client keeps method names like getOrder across route changes.
  • Document the 409 Conflict a POST /orders returns when the Idempotency-Key was already used with a different body.
  • Group invoice, payment and refund endpoints under their own tags so the docs navigation matches the product's features.

More examples

Attributes on a method-group handler

When handlers live in feature classes, attributes keep the documentation next to the code it describes: <code>[EndpointSummary]</code> and <code>[EndpointDescription]</code> work on methods and lambdas, and <code>[Description]</code> on a parameter becomes the parameter's description. The tag is set once on the group, and the typed return already documents 200 with an <code>Invoice</code> body and 404.

Example · csharp
using System.ComponentModel;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();

app.MapGroup("/invoices").WithTags("Invoices").MapInvoiceEndpoints();

app.Run();

public static class InvoiceEndpoints
{
    public static RouteGroupBuilder MapInvoiceEndpoints(this RouteGroupBuilder group)
    {
        group.MapGet("/{number}", GetInvoice).WithName("GetInvoice");
        return group;
    }

    [EndpointSummary("Get an invoice by number")]
    [EndpointDescription("Numbers are issued per calendar year, e.g. INV-2026-0042. Voided invoices are returned with status Void.")]
    private static async Task<Results<Ok<Invoice>, NotFound>> GetInvoice(
        [Description("Invoice number such as INV-2026-0042")] string number,
        CancellationToken cancellationToken)
    {
        await Task.Delay(5, cancellationToken);
        return number == "INV-2026-0042"
            ? TypedResults.Ok(new Invoice(number, 77, 412.80m, "Issued"))
            : TypedResults.NotFound();
    }
}

public record Invoice(string Number, int CustomerId, decimal Amount, string Status);

Pitfall: IResult hides the responses

<code>Results.Ok(...)</code> returns <code>IResult</code>, so nothing about the body or the 404 reaches ApiExplorer. Typed results fix it with no extra calls and also make unit tests easier because the return value can be inspected. For genuinely dynamic handlers such as file streams, <code>Produces</code> with an explicit content type is the honest description.

Example · csharp
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapOpenApi();

// Before: documented as "200 OK, no content". Clients never learn about 404 or the body.
app.MapGet("/v0/products/{sku}", (string sku) =>
    sku == "KETTLE-BLUE" ? Results.Ok(new Product(sku, "Blue kettle", 34.90m)) : Results.NotFound());

// After: 200 with a Product schema and 404, derived from the signature.
app.MapGet("/products/{sku}", Results<Ok<Product>, NotFound> (string sku) =>
    sku == "KETTLE-BLUE" ? TypedResults.Ok(new Product(sku, "Blue kettle", 34.90m)) : TypedResults.NotFound())
    .WithName("GetProduct");

// When the handler must stay untyped, declare the responses explicitly.
app.MapGet("/v0/products/{sku}/image", (string sku) => Results.File(new byte[] { 0x89, 0x50 }, "image/png"))
    .Produces(StatusCodes.Status200OK, contentType: "image/png")
    .Produces(StatusCodes.Status404NotFound)
    .WithName("GetProductImage");

app.Run();

record Product(string Sku, string Name, decimal Price);

The same metadata on a controller

The route <code>Name</code> becomes the operationId, the controller name becomes the tag, and with <code>GenerateDocumentationFile</code> on, the XML <code>summary</code> and <code>param</code> comments become the summary and parameter descriptions. A money-moving endpoint is never anonymous or unvalidated: the policy and the <code>[Range]</code> rule are enforced, and the 400, 401 and 403 they cause are declared so clients expect them. Without <code>[Produces("application/json")]</code> the document lists <code>text/plain</code> and <code>text/json</code> for every response as well, because MVC's output formatters can produce them.

Example · csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/payments")]
[Produces("application/json")]
[Authorize(Policy = "Payments.Refund")]
public class PaymentsController : ControllerBase
{
    /// <summary>Refund a captured payment.</summary>
    /// <param name="paymentId">The payment to refund.</param>
    /// <param name="request">Amount and reason; the amount may not exceed what was captured.</param>
    [HttpPost("{paymentId:guid}/refunds", Name = "RefundPayment")]
    [ProducesResponseType<RefundResponse>(StatusCodes.Status202Accepted)]
    [ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")]
    [ProducesResponseType(StatusCodes.Status401Unauthorized)]
    [ProducesResponseType(StatusCodes.Status403Forbidden)]
    [ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
    public ActionResult<RefundResponse> Refund(Guid paymentId, RefundRequest request)
    {
        if (request.Amount > 250.00m)
        {
            return Problem(statusCode: StatusCodes.Status409Conflict, title: "Refund exceeds captured amount");
        }
        return Accepted(new RefundResponse(Guid.CreateVersion7(), paymentId, request.Amount));
    }
}

public record RefundRequest(
    [Range(typeof(decimal), "0.01", "100000.00")] decimal Amount,
    [Required, StringLength(200)] string Reason);
public record RefundResponse(Guid RefundId, Guid PaymentId, decimal Amount);

Discussion

  • Be the first to comment on this lesson.