Skip to content

Minimal API Integration

RzR edited this page Jul 4, 2026 · 2 revisions

Minimal API Integration

The minimal-API integration is gated by #if NET7_0_OR_GREATER. If you target net7.0, net8.0 or net9.0, the PagedListResult.Web package gives you a complete, idiomatic minimal-API surface that mirrors the MVC pipeline.

The types on this page live in the RzR.ResultMessage.Pagination.AspNetCore.Query namespace. This namespace was renamed in v5 (previously RzR.ResultMessage.Pagination.AspNetCore.MinimalApi) — see Migration Guide if you are upgrading.

Overview

Component Purpose
PagedQuery<TEntity> Bindable wrapper that extends PagedRequest and accepts the same query string the MVC binder accepts.
PagedQueryWithFilters<TEntity> Bindable wrapper that extends PageRequestWithFilters (adds Filters).
MinimalApiQueryBinder.Bind<TEntity>(target, ctx) Shared binding logic; reused by both wrappers. Internal implementation detail — the type is internal and is not intended to be called directly.
PagedResultEndpointFilter IEndpointFilter that emits the standard pagination headers.
WithPagedResult() / AddPagedResultEndpointFilter<TBuilder>() Attach the endpoint filter.
ToPagedHttpResult(this IPagedResult<T>, HttpContext?) Adapt the paged envelope to a minimal-API IResult.

PagedQuery<TEntity> and PagedQueryWithFilters<TEntity>

Both types implement the BindAsync(HttpContext) pattern recognized by minimal APIs, so you can take them directly in an endpoint delegate:

app.MapGet("/products",
    async (PagedQueryWithFilters<Product> q, AppDbContext db, HttpContext ctx, CancellationToken ct) =>
    {
        if (!q.IsValid)
            return Results.ValidationProblem(
                q.Errors.ToDictionary(e => e.Key, e => new[] { e.Value }));

        var paged = await db.Products.AsNoTracking()
            .GetPagedWithFiltersAsync(q, cancellationToken: ct);

        return paged.ToPagedHttpResult(ctx);
    })
    .WithPagedResult();

Both wrappers expose:

Member Type Description
IsValid bool true when Errors is empty.
Errors IDictionary<string, string> Per-key binding error messages, keyed case-insensitively. Same filter[i] convention as the MVC binder.

Both members come from IPagedQueryValidation (RzR.ResultMessage.Pagination.AspNetCore.Abstractions), the shared abstraction both wrappers implement.

Also usable in MVC. As of v5 these same wrapper types can be bound as an MVC action parameter via [FromPagedQuery]. IPagedQueryValidation is what makes IsValid / Errors behave identically in both hosts. See MVC Integration for the pattern and when to prefer it over the plain PagedRequest types.

Internally BindAsync resolves PagedListResultWebOptions and the IPageableMetadataRegistry from HttpContext.RequestServices, looks up the PageableMetadata registered for TEntity, and calls the internal MinimalApiQueryBinder.Bind<TEntity>(this, ctx) (not a public API — shown here only to explain the binding flow). The wrapper is always returned (never null) so the endpoint can decide whether to fail with ValidationProblem or proceed.

WithPagedResult() and AddPagedResultEndpointFilter<TBuilder>()

Both extension methods attach PagedResultEndpointFilter to the endpoint or route group:

app.MapGroup("/api")
   .WithPagedResult()
   .MapGet("/products", ...);

// equivalent
app.MapGroup("/api")
   .AddPagedResultEndpointFilter()
   .MapGet("/products", ...);

The filter inspects the endpoint's return value:

  1. If it is null, returns Results.NoContent().
  2. If it implements IPagedResult directly, calls PagedResponseHeaderWriter.TryApply.
  3. Otherwise it tries to unwrap a Value property via PagedAccessors (covers Ok(paged) and similar IResult wrappers) and applies the writer to the unwrapped value.

The filter does not reshape the body. It only writes headers. Use ToPagedHttpResult if you need the success body to keep the full envelope.

ToPagedHttpResult(this IPagedResult<T>, HttpContext?)

This adapter sits between your endpoint code and Results.Ok / the upstream ResultToHttpResult.ToHttpResult<T>:

Input Output
null reference Results.NoContent() (204).
paged.IsSuccess == true Results.Ok(paged) — serializes the whole envelope (CurrentPage, PageCount, Response, etc.).
paged.IsSuccess == false Casts the value to the non-generic RzR.ResultMessage.Abstractions.IResult (aliased as LibResult) and delegates to ResultToHttpResult.ToHttpResult(...), which produces the RFC 9457 ProblemDetails.

Why not call ResultToHttpResult.ToHttpResult<T>(paged) directly? Because the upstream generic adapter only serializes result.Response, dropping the paging metadata. ToPagedHttpResult keeps the envelope on success and routes failures through the same ProblemDetails machinery you already use elsewhere.

The optional HttpContext argument is forwarded so the eventual ProblemDetails has access to trace identifiers and instance URLs.

End-to-end example

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(...);
builder.Services
    .AddPagedListResultWeb(o =>
    {
        o.MaxPageSize = 200;
        o.EmitServerTimingHeader = true;
    })
    .ConfigurePageable<Product>(p =>
    {
        p.AllowSort("Id", "Name", "Price", "CreatedOn");
        p.AllowFilter("Status", "Price", "CreatedOn");
        p.AllowSearch("Name", "Description");
    });

var app = builder.Build();

var products = app.MapGroup("/products").WithPagedResult();

products.MapGet("/",
    async (PagedQueryWithFilters<Product> q, AppDbContext db, HttpContext ctx, CancellationToken ct) =>
    {
        if (!q.IsValid)
            return Results.ValidationProblem(
                q.Errors.ToDictionary(e => e.Key, e => new[] { e.Value }));

        var paged = await db.Products.AsNoTracking()
            .GetPagedWithFiltersAsync(q, cancellationToken: ct);

        return paged.ToPagedHttpResult(ctx);
    });

app.Run();

The endpoint:

  • accepts ?page=, ?pageSize=, ?search=, ?order=, ?filter= and the rest of the query-string syntax;
  • validates the request against the per-entity allow-list;
  • emits X-Total-Count, X-Page-Count, X-Page-Size, X-Current-Page, the RFC 5988 Link header (only because the verb is GET) and the optional Server-Timing header;
  • on success returns 200 OK with the full PagedResult<Product> envelope as JSON;
  • on failure returns application/problem+json with per-row error keys.

Common pitfalls

  • Envelope missing on success. If you return Results.Ok(paged.Response), only the Response collection is serialized. Use ToPagedHttpResult (or Results.Ok(paged)) to keep the metadata.
  • Headers missing. Make sure you registered the endpoint filter with WithPagedResult() on the route group or endpoint. The MVC PagedResultActionFilter does not run for minimal-API endpoints.
  • IsValid is always true even with bad input. Verify that the endpoint signature uses PagedQuery<T> / PagedQueryWithFilters<T> (not the raw PagedRequest); only the wrappers expose IsValid / Errors.
  • Type collision on IResult. ASP.NET Core's Microsoft.AspNetCore.Http.IResult collides with RzR.ResultMessage.Abstractions.IResult. Inside PagedListResult.Web we use the alias LibResult = RzR.ResultMessage.Abstractions.IResult. In your own endpoint code prefer the fully-qualified name when you need both, or add a similar alias at the top of the file.
  • Server-Timing not visible. EmitServerTimingHeader defaults to false. Set it to true in the options to opt in.

Clone this wiki locally