An Accommodating Approach to Pagination


This entry is part [part not set] of 2 in the series API Endpoint Pagination

In the previous post from this series I showed you a “quick and dirty” implementation of API endpoint pagination. It definitely left much to be desired. As promised, in this post I’ll illustrate a much more accommodating approach to pagination that includes interfaces, extensions, generics, input validation, safety checks, and a courteous response. Let’s jump right in!

The request

The first thing we’ll do is create some constants to use for default pagination values.

public static class Defaults
{
    public const int Page = 1;
    public const int PageSize = 10;
    public const int MaxPageSize = 100;
}

Next, we’ll create an interface that we can use for all requests that require pagination.

public interface IPageRequest
{
    int PageSize { get; set; }
    int Page { get; set; }
}

And now we’ll implement the interface with a new request model for our invoices endpoint.

public class GetInvoicesRequestModel : IPageRequest
{
    public GetInvoicesRequestModel()
    {
        Page = Defaults.Page;
        PageSize = Defaults.PageSize;
    }

    [FromRoute]
    public Guid CustomerId { get; set; }

    [Range(1, Int32.MaxValue)]
    public int Page { get; set; }

    [Range(1, Defaults.MaxPageSize)]
    public int PageSize { get; set; }
}

This new request model has several things going on. First off, we’re initializing the Page and PageSize properties in the constructor with default values in case the caller doesn’t provide them. Next, we’re designating that the CustomerId property is going to be provided via the route by using the [FromRoute] attribute from Microsoft.AspNetCore.Mvc. And finally, we’re setting up some automatic model validation by using the [Range(min, max)] attribute provided by System.ComponentModel.DataAnnotations. That allows us to set a maximum page size to prevent a caller from trying to bypass pagination altogether.

The data layer

In addition to the paged list of items, the data layer also needs to return the total count. This will pave the way for us to return a lot of helpful information in the response to the caller a bit later. Let’s build a generic model that the repository can use to return the paged results.

public class PagedResult<T>
{
    public PagedResult(int totalCount, List<T> data)
    {
        TotalCount = totalCount;
        Data = data ?? new List<T>();
    }

    public int TotalCount { get; }
    public List<T> Data { get; }
}

To make our lives a bit easier in the future, let’s create an IQueryable extension method. We’ll be able to reuse it for any other endpoints that may need pagination.

public static IQueryable<T> Paged<T>(
    this IQueryable<T> query,
    IPageRequest request)
{
    return query
        .Skip(request.PageSize * (request.Page - 1))
        .Take(request.PageSize);
}

And now we’ll use that generic result class and extension method to implement pagination in the repository.

public PagedResult<Invoice> Get(GetInvoicesRequestModel model)
{
    var baseQuery = _dbContext.Invoices.Where(x => x.CustomerId == model.CustomerId);
    var totalCount = baseQuery.Count();
    var pagedInvoices = baseQuery.Paged(model).ToList();
    return new PagedResult<Invoice>(totalCount, pagedInvoices);
}

The response

Now let’s create a generic response model that we can use with all paginated endpoints. Instead of simply returning a list of items that happens to be the requested page (like in the quick and dirty solution), we’re going to try to be more accommodating and include quite a bit more information that the caller can use to perform further page requests.

public class PagedResponse<T>
{
    private readonly string _path;

    public PagedResponse(
        IPageRequest pageRequest,
        HttpRequest httpRequest,
        PagedResult<T> pagedResult)
    {
        CurrentPage = pageRequest.Page;
        PageSize = pageRequest.PageSize;
        TotalCount = pagedResult.TotalCount;
        Data = pagedResult.Data;
        PageCount = PageSize == 0 ? 0 : (int)Math.Ceiling(TotalCount / (double)PageSize);
        Base = httpRequest.Host.ToUriComponent();
        _path = httpRequest.Path.ToUriComponent();
    }

    public int CurrentPage { get; }
    public int PageSize { get; }
    public int PageCount { get; }
    public int TotalCount { get; }
    public List<T> Data { get; }
    public string Base { get; }
    public string Self => GetPageUrl(CurrentPage);
    public string First => GetPageUrl(1);
    public string Last => PageCount == 1 ? null : GetPageUrl(PageCount);
    public string Prev => CurrentPage > 1 ? GetPageUrl(CurrentPage - 1) : null;
    public string Next => CurrentPage < PageCount ? GetPageUrl(CurrentPage + 1) : null;

    private string GetPageUrl(int page) => $"{_path}?page={page}&pageSize={PageSize}";
}

Let’s take a closer look at this generic response model. It includes CurrentPage and PageSize, which the caller should already know since those values come directly from the request. It also includes PageCount (the total number of pages) and TotalCount (the total number of items). And then we have the Self, First, Last, Prev, and Next properties. These are endpoint URIs that are very useful to the caller, enabling and simplifying page navigation in a UI, similar to the image below.

UI example of an accommodating approach to pagination

Tying it all together

Finally, it’s time to tie all these pieces together in the controller’s endpoint method.

[HttpGet("{customerId}")]
public ActionResult<PagedResponse<Invoice>> Get(
    [FromQuery] GetInvoicesRequestModel model)
{
    var result = _invoiceRepository.Get(model);
    return new PagedResponse<Invoice>(model, Request, result);
}

A final note. Take notice of the [FromQuery] attribute decorating the request model parameter. Without that attribute, .NET would assume the object will be found in the request body, such as in a POST request. But this is a GET request, so we must specify that the object should be composed from the query string parameters. And that’s why, if you’ll recall, we had to explicitly override that behavior for the CustomerId property since it’s coming from the route.

And that’s it; an accommodating approach to pagination. It’s much nicer than our “quick and dirty” solution, and paves the way to introduce pagination to any other endpoints that may need it in the future.

related source code can be found here.

Series Navigation

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.