Skip to content

MVC Integration

RzR edited this page Jul 4, 2026 · 2 revisions

MVC Integration

This page covers the controller-side surface of PagedListResult.Web. For the minimal-API counterpart see Minimal API Integration.

BaseApiPagedResultController

A convenience base class that derives from RzR.ResultMessage.Web.ResultBaseApiController. Inheriting from it gives you four helpers:

Method Behavior
PagedOkResult<T>(IPagedResult<T> response) On success returns Ok(response) (status 200, JSON envelope). On failure returns response.AsProblemDetails(HttpStatusCode.BadRequest).
PagedXmlResult<T>(IPagedResult<T> response) On success serializes the result through ToSoapXmlPagedResult() and returns it as Content("...", "text/xml"). On failure returns the same ProblemDetails as above.
JsonResult<T>(IPagedResult<T> response) [Obsolete("Use PagedOkResult instead.")] thin wrapper retained for one release for backward compatibility.
XmlResult<T>(IPagedResult<T> response) [Obsolete("Use PagedXmlResult instead.")] thin wrapper retained for one release.

The two obsolete wrappers will be removed in a future major release. See Migration Guide for the rename.

The class is decorated with [ApiController] upstream; you do not need to add it again.

Minimal usage

[Produces("application/json")]
[Route("api/[controller]/[action]")]
public sealed class ProductsController : BaseApiPagedResultController
{
    private readonly AppDbContext _db;
    public ProductsController(AppDbContext db) => _db = db;

    [HttpPost]
    public async Task<IActionResult> List(
        [FromBody] PageRequestWithFilters request, CancellationToken ct)
    {
        IPagedResult<Product> result = await _db.Products
            .AsNoTracking()
            .GetPagedWithFiltersAsync(request, cancellationToken: ct);

        return PagedOkResult(result);
    }
}

[FromPagedQuery]

Use this attribute on an action parameter when you want a PagedRequest-derived model to be bound from the query string instead of the body. The attribute lives in RzR.ResultMessage.Pagination.AspNetCore.Attributes and implements IBindingSourceMetadata so MVC routes the parameter through the model binder for BindingSource.Query.

Constructor Purpose
[FromPagedQuery] Bind without an entity type. The allow-list lookup is skipped; every query-string field is accepted.
[FromPagedQuery(typeof(Product))] Bind and constrain by the PageableMetadata registered for Product.

The attribute exposes a single EntityType property (settable via the constructor or by attribute syntax: [FromPagedQuery(EntityType = typeof(Product))]).

The full grammar of the supported query string is documented in Query String Syntax. A short example:

GET /api/products/list
    ?page=2
    &pageSize=20
    &order=price:desc
    &search=usb
    &filter=status:Equals:active
    &filter=price:Between:10,100
    &fields=Id,Name,Price
    &predefinedField=Id
    &predefinedRecords=1,2,3

Example controller

[HttpGet]
public async Task<IActionResult> Search(
    [FromPagedQuery(typeof(Product))] PageRequestWithFilters request,
    CancellationToken ct)
{
    if (!ModelState.IsValid)
        return ValidationProblem(ModelState);

    var paged = await _db.Products.AsNoTracking()
        .GetPagedWithFiltersAsync(request, cancellationToken: ct);

    return PagedOkResult(paged);
}

PagedRequestModelBinder writes per-row binding errors as filter[i] keys in ModelStateDictionary (where i is the zero-based index of the offending filter in the query string). Returning ValidationProblem(ModelState) produces the standard ASP.NET Core 400 ProblemDetails response carrying those error keys.

Using PagedQuery<T> / PagedQueryWithFilters<T> in MVC

As of v5, the minimal-API query wrappers PagedQuery<T> and PagedQueryWithFilters<T> (namespace RzR.ResultMessage.Pagination.AspNetCore.Query) are dual-host. Alongside their BindAsync(HttpContext) support for minimal APIs (see Minimal API Integration), they can now also be bound as an MVC action parameter decorated with [FromPagedQuery]. Both types implement IPagedQueryValidation, which exposes IsValid and Errors.

For most MVC controllers, keep using the plain PagedRequest / PageRequestWithFilters types shown above — per the types' own XML docs this remains the preferred MVC parameter shape, and it plays best with [ApiController]'s automatic ModelState validation and 400 response.

The wrapper is a niche option for controllers without [ApiController] (no automatic 400 on an invalid ModelState), where you want to inspect request.IsValid / request.Errors yourself instead of relying on ModelState:

[HttpGet("paged")]
public async Task<IActionResult> Paged(
    [FromPagedQuery(typeof(Product))] PagedQueryWithFilters<Product> request,
    CancellationToken ct)
{
    if (!request.IsValid)
        return ValidationProblem(
            new ValidationProblemDetails(
                request.Errors.ToDictionary(e => e.Key, e => new[] { e.Value })));

    var paged = await _db.Products.AsNoTracking()
        .GetPagedWithFiltersAsync(request, cancellationToken: ct);

    return PagedOkResult(paged);
}

How the binder is registered

AddPagedListResultWeb configures MvcOptions with three additions:

  1. PagedResultApplicationModelConvention -> auto-attaches OpenAPI annotations.
  2. ServiceFilterAttribute(typeof(PagedResultActionFilter)) -> emits the response headers around every action.
  3. PagedRequestModelBinderProvider inserted at index 0 of ModelBinderProviders -> wins over the default complex-object binder when [FromPagedQuery] is present.

The provider is intentionally narrow:

if (!context.Metadata.IsComplexType) return null;
if (context.BindingInfo.BindingSource != BindingSource.Query) return null;
if (context.BindingInfo.PropertyFilterProvider != null) return null;
if (!typeof(PagedRequest).IsAssignableFrom(modelType)) return null;
return new BinderTypeModelBinder(typeof(PagedRequestModelBinder));

This means non-paged complex types and body-bound parameters are not affected.

PagedResultActionFilter

Registered as a [ServiceFilter] by AddPagedListResultWeb. It runs after the action and:

  1. Inspects context.Result. If it is ObjectResult or JsonResult, extracts Value.
  2. Calls PagedResponseHeaderWriter.TryApply(context.HttpContext, value, options). The writer recognizes any IPagedResult (also through reflection on minimal-API envelopes) and emits the configured headers.
  3. Continues the pipeline with await next().

Headers do not change the body. If you replace the action result with something that does not carry an IPagedResult, the filter is a no-op.

Status code mapping

By default success returns 200 OK and failure returns 400 Bad Request. Override the failure code globally via the options:

builder.Services.AddPagedListResultWeb(o =>
{
    o.StatusCodeMapper = (result, defaultCode) =>
    {
        // Inspect result.Messages and choose a different code if applicable.
        return defaultCode;
    };
});

The mapper is used by IProblemDetailsResultFactory.AsProblemDetails(...) from the upstream RzR.ResultMessage.Web package.

Common pitfalls

  • [FromPagedQuery] is not active. Verify you registered the binder with AddPagedListResultWeb(...). Without that call the binder provider is not in MvcOptions and the default complex-object binder takes over.
  • ModelState is empty even though the query is wrong. Make sure the action signature carries the PagedRequest-derived parameter directly. The binder writes errors against the parameter's ModelStateDictionary, which is only created when the binder is invoked.
  • JSON envelope is missing on success. Confirm you returned PagedOkResult(...) and not Ok(...) followed by manual shaping. The base controller method also calls IProblemDetailsResultFactory for failures, which Ok does not.
  • Headers are missing. Verify the action returns ObjectResult or JsonResult (which PagedOkResult does). If you wrap the result in a custom IActionResult, also call PagedResponseHeaderWriter.TryApply(...) from inside it or use WithPagedResult style filters.

Cookbook

End-to-end controller examples covering MediatR handlers, predefined records and SOAP/XML are in Recipes.

Clone this wiki locally