Photo by Kelly Sikkema on Unsplash
Table of Contents
Open Table of Contents
What is Pagination
With pagination, we divide a large set of content or data into separate pages to make it more accessible and manageable for users. Instead of displaying all the content on a single page, we combine it into smaller chunks with pagination.
How to implement it
We will get pagination information from query. For example: http://.../resource?pageIndex=1&pageSize=30
We should limit the page size. If it exceeds the limit we should return BadRequest
. (We can also automatically equalize the page size to the limit)
We should consider pagination as default. Data may grow unexpectedly.
If the pageIndex
or pageSize
is not specified in the query we should return with default values. (For example, 1 for pageIndex
and 10 for pageSize
).
We should return metadata with pagination result. Generally, this pagination result involves:
- Total item count
- Total page count
- Page size
- Page index
- Next page URI (if exists)
- Previous page URI (if exists)
We will use the response header to put pagination metadata. For example X-Pagination
We can use Skip
and Take
functions for pagination.
Implementation
Create the project
dotnet new webapi -n PaginationExample
Install in memory db provider for testing
dotnet add package Microsoft.EntityFrameworkCore.InMemory
Create Models
Very basic Item model for testing pagination:
public class Item
{
public required int Id { get; set; }
public required string Name { get; set; }
}
PaginationQueryParameters
is a generic class for routes that use pagination:
public record PaginationQueryParameters(int PageIndex = 1, int PageSize = 10);
PaginatedHttpGetAttribute
is helper attribute we will use. With this attribute, we force the developer to give a route name to pass HttpGet
attribute. This route name is used by IUrlHelper.Link
method to create previous and next page links in pagination metadata.
public class PaginatedHttpGetAttribute : HttpGetAttribute
{
public PaginatedHttpGetAttribute(string name)
{
Name = name;
}
}
PagedList
is the most important model that handles pagination logic.
public class PagedList<T> : List<T>
{
public int PageSize { get; private set; }
public int CurrentPage { get; private set; }
public int TotalItemCount { get; private set; }
public int TotalPageCount { get; private set; }
public bool HasPrevious => (CurrentPage > 1);
public bool HasNext => (CurrentPage < TotalPageCount);
private PagedList(List<T> items, int pageSize, int currentPage, int totalItemCount)
{
PageSize = pageSize;
CurrentPage = currentPage;
TotalItemCount = totalItemCount;
TotalPageCount = (int)Math.Ceiling(TotalItemCount / (double)PageSize);
AddRange(items);
}
public static async Task<PagedList<T>> CreateAsync(IQueryable<T> items, int pageIndex, int pageSize)
{
var count = await items.CountAsync();
// PAGINATION LOGIC HERE, Skip and Take !!
var pagedItems = await items.Skip(pageSize * (pageIndex - 1)).Take(pageSize).ToListAsync();
return new PagedList<T>(pagedItems, pageSize, pageIndex, count);
}
}
DbContext & Repository
Add DbContext with initial data seed for testing pagination. We will test pagination using in-memory database.
public class ItemDbContext : DbContext
{
public DbSet<Item> Items { get; set; }
public ItemDbContext(DbContextOptions<ItemDbContext> options) : base(options)
{
Database.EnsureCreated();
}
protected override void OnModelCreating(ModelBuilder builder)
{
// Seed database with 100 items.
var items = Enumerable.Range(1, 100).Select(index => new Item
{
Id = index,
Name = $"Item at index = {index}"
}).ToList();
builder.Entity<Item>().HasData(items);
base.OnModelCreating(builder);
}
}
A basic repository that references our PagedList
model.
public class ItemRepository
{
private readonly ItemDbContext _context;
public ItemRepository(ItemDbContext context)
{
_context = context;
}
public async Task<PagedList<Item>> GetItems(int pageIndex, int pageSize)
{
return await PagedList<Item>.CreateAsync(_context.Items, pageIndex, pageSize);
}
}
Don’t forget the inject them at the composition root Program.cs
:
builder.Services.AddDbContext<ItemDbContext>(opt =>
{
opt.UseInMemoryDatabase("PaginationTestDB");
});
builder.Services.AddScoped<ItemRepository>();
Controller and Extension Method
We will create an extension method for adding pagination metadata to response headers.
public static class AddPaginationMetadataExtension
{
public static void AddPaginationMetadata<T>(this ControllerBase controller, PagedList<T> pagedItems,
PaginationQueryParameters queryParameters)
{
string? previousPageUrl = null;
string? nextPageUrl = null;
var paginationAttribute = (PaginatedHttpGetAttribute?)controller.ControllerContext.ActionDescriptor.MethodInfo
.GetCustomAttributes(false).FirstOrDefault(obj => obj is PaginatedHttpGetAttribute);
Debug.Assert(paginationAttribute is not null,
"You should define PaginatedHttpGet attribute in paginated actions.");
var routeName = paginationAttribute.Name;
if (pagedItems.HasPrevious)
{
// If we have previous page, include link to that page
previousPageUrl = controller.Url.Link(routeName, queryParameters with
{
PageIndex = queryParameters.PageIndex - 1
});
}
if (pagedItems.HasNext)
{
// If we have next page include, link to that page
nextPageUrl = controller.Url.Link(routeName, queryParameters with
{
PageIndex = queryParameters.PageIndex + 1
});
}
// Add header
controller.Response.Headers.Add("X-Pagination", JsonSerializer.Serialize(
new
{
pagedItems.HasNext,
pagedItems.HasPrevious,
pagedItems.TotalPageCount,
pagedItems.TotalItemCount,
pagedItems.CurrentPage,
pagedItems.PageSize,
previousPageUrl,
nextPageUrl
}));
}
}
And finally, add our paginated ItemController
:
[ApiController]
[Route("[controller]")]
public class ItemController : ControllerBase
{
private readonly ItemRepository _itemRepository;
public ItemController(ItemRepository itemRepository)
{
_itemRepository = itemRepository;
}
[PaginatedHttpGet("GetItems")] // This attribute is required!
public async Task<ActionResult<List<Item>>> GetItems([FromQuery] PaginationQueryParameters paginationData)
{
// Should valiidate maximum pageSize !
// Get items with pagination
var items = await _itemRepository.GetItems(paginationData.PageIndex, paginationData.PageSize);
// Add pagination metadata to headers
this.AddPaginationMetadata(items, paginationData);
return items.ToList();
}
}
Conclusion
We implemented simple pagination with pagination metadata. You can use the SwaggerUI to test it (http://localhost:xxxx/swagger/index.html
). Further improvements can be made to the PaginatedHttpGetAttribute
and AddPaginationMetadataExtension
and we should add validation for query parameters.
Example Query
curl -X 'GET' 'http://localhost:5209/Item?PageIndex=2&PageSize=2' -H 'accept: text/plain'
Response body
[
{
"id": 3,
"name": "Item at index = 3"
},
{
"id": 4,
"name": "Item at index = 4"
}
]
Response header (x-pagination)
{
"HasNext": true,
"HasPrevious": true,
"TotalPageCount": 50,
"TotalItemCount": 100,
"CurrentPage": 2,
"PageSize": 2,
"previousPageUrl": "http://localhost:5209/Item?PageIndex=1&PageSize=2",
"nextPageUrl": "http://localhost:5209/Item?PageIndex=3&PageSize=2"
}
Thanks for reading. For source code: GitHub