Wiaoj.Pagination.AspNetCore
0.2.0-alpha.3
dotnet add package Wiaoj.Pagination.AspNetCore --version 0.2.0-alpha.3
NuGet\Install-Package Wiaoj.Pagination.AspNetCore -Version 0.2.0-alpha.3
<PackageReference Include="Wiaoj.Pagination.AspNetCore" Version="0.2.0-alpha.3" />
<PackageVersion Include="Wiaoj.Pagination.AspNetCore" Version="0.2.0-alpha.3" />
<PackageReference Include="Wiaoj.Pagination.AspNetCore" />
paket add Wiaoj.Pagination.AspNetCore --version 0.2.0-alpha.3
#r "nuget: Wiaoj.Pagination.AspNetCore, 0.2.0-alpha.3"
#:package Wiaoj.Pagination.AspNetCore@0.2.0-alpha.3
#addin nuget:?package=Wiaoj.Pagination.AspNetCore&version=0.2.0-alpha.3&prerelease
#tool nuget:?package=Wiaoj.Pagination.AspNetCore&version=0.2.0-alpha.3&prerelease
Wiaoj.Pagination.AspNetCore
ASP.NET Core integration for the Wiaoj Pagination ecosystem, providing RFC 8288 Web Linking and SIMD-accelerated XxHash3 ETag evaluation with HTTP 304 Not Modified handling.
Extension methods are exposed directly under the Microsoft.AspNetCore.Builder namespace for fluent Minimal API routing.
Features
- RFC 8288 Web Linking: Automatically formats and appends standard HTTP
Linkheaders (rel="first",rel="prev",rel="next",rel="last") for both offset and keyset pagination. - SIMD-Accelerated ETag Caching: Generates high-throughput weak ETags (
W/"...") usingXxHash3(30+ GB/s) and cryptographic strong ETags usingSha256Hash. - Automatic 304 Not Modified Handling: Evaluates client
If-None-Matchheaders and short-circuits responses to304 Not Modifiedwithout transferring payload bodies. - Endpoint Filter: Provides a pre-allocated singleton instance for default
.WithPagination()routes.
Note: Pagination state (
totalCount,pageNumber,pageSize,totalPages,hasPrevious,hasNext) is exposed exclusively via the response body'smetadataobject, not via a separate header. This avoids duplicating the same data across two channels — seePagedResult<T>andPageMetadata.
Installation
dotnet add package Wiaoj.Pagination.AspNetCore
Usage Examples
1. Minimal API Integration
Add .WithPagination() to any endpoint returning PagedResult<T> or CursorResult<T>:
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
using Wiaoj.Pagination;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Offset pagination with automatic headers and ETag
app.MapGet("/api/products", async (AppDbContext db, [AsParameters] PageRequest request, CancellationToken ct) =>
{
return await db.Products
.AsNoTracking()
.OrderBy(p => p.Id)
.ToPagedResultAsync(request, ct);
})
.WithPagination();
// Keyset pagination with automatic headers and ETag
app.MapGet("/api/orders", async (AppDbContext db, CursorParameters paging, CancellationToken ct) =>
{
return await db.Orders
.AsNoTracking()
.OrderByDescending(o => o.Id)
.ToCursorResultAsync(paging, o => o.Id, ct);
})
.WithPagination();
app.Run();
How the paging parameters are bound is not stylistic.
For keyset, take
CursorParameters. It bindscursor,limitanddirectionfrom the query string and converts implicitly toCursorRequest, so it goes straight intoToCursorResultAsync.Neither alternative works, in opposite directions:
- A bare
CursorRequest requestbinds from a single composite value —?request=cursor:limit:direction— because the type isISpanParsable<T>. It rejects the?cursor=…&direction=…form with 400, which is exactly the form this package's ownLinkheaders emit. The first page works, thenrel="next"returns 400.[AsParameters] CursorRequestbinds through the record's constructor, where the cursor has no default — so the cursor becomes a required query value and the request for the first page, the one that carries no cursor yet, returns 400. Giving it a default does not fix it: minimal APIs cannot express an optional parameter of a custom struct type, and the endpoint fails to build at all.
PageRequesthas no such trap — every one of its constructor parameters has a default — so[AsParameters] PageRequestis correct for offset paging.
2. Configuration
Set the defaults once for the application:
builder.Services.AddPagination(options => options.EnableETag = false);
Every .WithPagination() endpoint then uses them. An endpoint that differs states only the difference — its callback applies on top of the application's settings, not on top of fresh defaults:
app.MapGet("/api/logs", ...)
.WithPagination(options => options.EnableLinkHeaders = false); // ETag stays off, from the application
Settings layer: library defaults → AddPagination → the endpoint. AddPagination is optional; an application that never calls it keeps the library defaults. The OpenAPI document is resolved the same way from the same metadata, so it never advertises an ETag the application turned off.
3. Responses that carry more than the page
A paged response is often an envelope — a workspace view with summaries beside the rows — so it cannot be a PagedResult<T>. Say at the endpoint where its metadata is:
internal sealed record WorkspaceResponse(
ProjectSummary Project,
IReadOnlyList<KeyRow> Items,
PageMetadata Metadata);
app.MapGet("api/v1/applications/{applicationId}/workspace", Handle)
.WithPagination<WorkspaceResponse>(response => response.Metadata);
The response stays a plain record and implements nothing from this library — how an endpoint is paginated is a fact about the endpoint, not about the contract type. Use a CursorMetadata accessor for a keyset envelope.
The declaration is checked when the endpoint is built: a TResponse that does not appear in the handler's return type throws there, rather than leaving an endpoint that silently sends no Link header. Union return types (Results<Ok<WorkspaceResponse>, ProblemHttpResult>) are seen through, and the problem branch is left alone.
Without this,
.WithPagination()on an envelope endpoint does nothing — the filter only acts on a result it recognises as a page.
Handlers returning IResult
The paging style is read from the handler's return type. IResult says nothing, so the OpenAPI document would describe no paging at all. State the style instead:
app.MapGet("api/v1/assets", async Task<IResult> (...) => TypedResults.Ok(window))
.WithPagination(PaginationStyle.Cursor);
Prefer a typed return (Results<Ok<CursorResult<T>>, ProblemHttpResult>) where one is available — it needs no statement. Where one is given, it is checked rather than trusted, so it cannot drift from what the endpoint serves:
| Where | What fails |
|---|---|
| Endpoint build | A readable return type that serves the other style, or no page at all |
| Each response | An opaque handler returning a page of the other style — InvalidOperationException. A non-page response (a problem, a redirect) passes. |
| OpenAPI document | A Produces<T>() response type that serves the other style |
4. Standalone RFC 8288 Link Header Generation
Use Rfc8288LinkHeaderBuilder directly in custom middlewares or controllers:
using Wiaoj.Pagination.AspNetCore.Linking;
// Offset Pagination Linking
string offsetLinkHeader = Rfc8288LinkHeaderBuilder.Build(
metadata: pagedResult.Metadata,
pageUriFactory: page => $"https://api.example.com/items?pageNumber={page}&pageSize=20");
// Keyset Pagination Linking
string keysetLinkHeader = Rfc8288LinkHeaderBuilder.Build(
metadata: cursorResult.Metadata,
cursorUriFactory: (cursor, direction) =>
$"https://api.example.com/items?cursor={cursor.Value}&direction={direction}");
// Set to response
httpContext.Response.Headers.Link = offsetLinkHeader;
5. Standalone ETag Generation & Verification
using Wiaoj.Pagination.AspNetCore.Caching;
// 1. Generate ETag from response bytes
byte[] utf8Payload = "{\"items\":[...]}"u8.ToArray();
string etag = ETagGenerator.GenerateWeakETag(utf8Payload); // W/"3fa85f64ac28d019"
// 2. Evaluate incoming If-None-Match header
string? ifNoneMatch = httpContext.Request.Headers.IfNoneMatch;
if (ETagGenerator.IsNotModified(ifNoneMatch, etag))
{
// Return 304 Not Modified
return Results.StatusCode(StatusCodes.Status304NotModified);
}
HTTP Response Headers Output
When calling an endpoint configured with .WithPagination(), the response includes:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
ETag: W/"5f8a92cb14e03d7a"
Link: <https://api.example.com/items?pageNumber=1&pageSize=20>; rel="first", <https://api.example.com/items?pageNumber=1&pageSize=20>; rel="prev", <https://api.example.com/items?pageNumber=3&pageSize=20>; rel="next", <https://api.example.com/items?pageNumber=5&pageSize=20>; rel="last"
{
"items": [...],
"metadata": {
"totalCount": 100,
"pageNumber": 2,
"pageSize": 20,
"totalPages": 5,
"hasPrevious": true,
"hasNext": true
}
}
When a subsequent request is sent with If-None-Match: W/"5f8a92cb14e03d7a", the server returns:
HTTP/1.1 304 Not Modified
ETag: W/"5f8a92cb14e03d7a"
License
This project is licensed under the MIT License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Wiaoj.Pagination (>= 0.2.0-alpha.3)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Wiaoj.Pagination.AspNetCore:
| Package | Downloads |
|---|---|
|
Wiaoj.Pagination.OpenApi
Package Description |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0-alpha.3 | 55 | 9/24/2026 |
| 0.2.0-alpha.2 | 48 | 9/24/2026 |
| 0.2.0-alpha.1 | 49 | 9/24/2026 |
| 0.1.0-alpha.9 | 142 | 9/21/2026 |
| 0.1.0-alpha.8 | 52 | 9/21/2026 |
| 0.1.0-alpha.7 | 54 | 9/18/2026 |
| 0.1.0-alpha.6 | 53 | 9/16/2026 |
| 0.1.0-alpha.5 | 55 | 9/16/2026 |
| 0.1.0-alpha.4 | 61 | 9/16/2026 |
| 0.1.0-alpha.3 | 50 | 9/15/2026 |
| 0.1.0-alpha.2 | 95 | 9/15/2026 |
| 0.1.0-alpha.1 | 86 | 9/14/2026 |
| 0.0.1-alpha.112-preview | 58 | 9/13/2026 |
| 0.0.1-alpha.111-preview | 53 | 9/13/2026 |
| 0.0.1-alpha.110-preview | 60 | 9/12/2026 |
| 0.0.1-alpha.109-preview | 78 | 9/11/2026 |
| 0.0.1-alpha.108-preview | 78 | 9/8/2026 |
| 0.0.1-alpha.107-preview | 74 | 9/8/2026 |
| 0.0.1-alpha.106-preview | 67 | 9/8/2026 |
| 0.0.1-alpha.105-preview | 63 | 9/8/2026 |