Zooper.Ant.AspNetCore 1.0.0

dotnet add package Zooper.Ant.AspNetCore --version 1.0.0
                    
NuGet\Install-Package Zooper.Ant.AspNetCore -Version 1.0.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Zooper.Ant.AspNetCore" Version="1.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Zooper.Ant.AspNetCore" Version="1.0.0" />
                    
Directory.Packages.props
<PackageReference Include="Zooper.Ant.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Zooper.Ant.AspNetCore --version 1.0.0
                    
#r "nuget: Zooper.Ant.AspNetCore, 1.0.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Zooper.Ant.AspNetCore@1.0.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Zooper.Ant.AspNetCore&version=1.0.0
                    
Install as a Cake Addin
#tool nuget:?package=Zooper.Ant.AspNetCore&version=1.0.0
                    
Install as a Cake Tool

Zooper.Ant

<img src="icon.png" alt="Zooper.Ant Logo" width="120" align="right"/>

NuGet Version License: MIT GitHub

Caller-selected paging for .NET: continuable, counted and capped pages over any store, with keyset continuation behind encrypted tokens.

Overview

Every list endpoint needs paging, and every team writes it again: offsets that skip rows when data changes, totals nobody asked for, cursors that leak database values, tokens that can be replayed against another query. Zooper.Ant writes it once.

A read is written once and serves three page kinds, chosen by the caller per request. Continuation is a keyset seek, never an offset, so it stays correct while rows are inserted and deleted, and costs the same on page 1000 as on page 2. Tokens are encrypted with ASP.NET Core Data Protection, bound to the read and its filters, and never expire. Any store can be paged: LINQ stores supply two methods, every other store implements one small interface.

Zooper.Ant depends on Microsoft.AspNetCore.DataProtection.Abstractions and Microsoft.Extensions.Options only. The companion package Zooper.Ant.AspNetCore adds the HTTP edge: query binding, 400 problem responses and the OpenAPI description of pages.

Key Concepts

Term Description
Paging The caller's instruction: which kind of page and how far. Built with Paging.Continuable, Paging.Counted or Paging.Capped, which apply the limits.
Page The result: ContinuablePage<T>, CountedPage<T> or CappedPage<T>, all deriving from Page<T>.
PageToken The opaque, encrypted continuation of a continuable page. Callers pass it back unchanged.
Order The order of a read, declared once as typed keys ending in a unique tie-breaker. It sorts the page and drives the seek.
PageScope The read's name plus every argument that decides its result set. A token is only accepted under the same scope.
Pager The one service you inject. It runs the page kinds, issues tokens and refuses foreign ones.
PagingLimits Default size, maximum size and counted depth, configured once per host.
IPageSource The interface a store implements to be paged. LINQ stores get one for free through IQueryExecutor.

Installation

dotnet add package Zooper.Ant
dotnet add package Zooper.Ant.AspNetCore   # ASP.NET Core hosts: binding, problem responses, OpenAPI

Getting Started

Register Ant once. The host owns Data Protection; Ant uses it for tokens.

builder.Services.AddDataProtection();
builder.Services.AddAnt();

Inject Pager and end a LINQ query with ToPageAsync:

public sealed class PlayerQueries(PlayerStore store, Pager pager)
{
    public async Task<Page<PlayerView>> ListAsync(string? team, Paging paging, CancellationToken ct)
    {
        var page = await store.Players
            .Where(p => team == null || p.Team == team)
            .ToPageAsync(
                paging,
                Order<Player>.ByDescending(p => p.Rating).ThenBy(p => p.Id),   // order, unique Id last
                PageScope.For(nameof(ListAsync), team),                         // the read and its filters
                pager,
                ct);

        return page.Map(PlayerView.From);                                       // keeps the page kind
    }
}

The caller picks the kind:

await queries.ListAsync("red", Paging.Continuable(pageToken: null, size: 20), ct); // first page + next token
await queries.ListAsync("red", Paging.Continuable(nextToken, size: 20), ct);     // the page after
await queries.ListAsync("red", Paging.Counted(page: 3, size: 20), ct);           // page 3 of N, with the total
await queries.ListAsync("red", Paging.Capped(cap: 5), ct);                       // top 5, and whether more matched

Example Project

Zooper.Ant.Example plays a narrated tour of every feature over twelve players, printing each call and what came back:

dotnet run --project Zooper.Ant.Example
1. Continuable pages, the default kind
   > queries.ListAsync(new PlayerFilter(), Paging.Continuable(null, size: 3))
   < page 1: continuable, size 3: Eve (blue, 95), Ada (red, 91), Ivy (green, 91)
     next token: ...ZVCLsuw0xZLyoILU (219 chars, encrypted, different every page)

With -- --serve it serves the same reads over HTTP through Zooper.Ant.AspNetCore (the ?paging= parameters, 400 problem responses, /openapi/v1.json); Zooper.Ant.Example.http walks through them. See Zooper.Ant.Example/README.md.

Page Kinds

Kind The caller gets Cost Use for
Continuable (default) items, and a NextPageToken while more exists the page plus one row infinite scroll, "load more", sync jobs
Counted items, Page, Size and TotalCount a count of every match, plus an offset "page 3 of 12" with jumps
Capped items, Cap and Truncated the cap plus one row a top-N widget

Continuable

var page = (ContinuablePage<PlayerView>)await queries.ListAsync(null, Paging.Continuable(token, 20), ct);

page.ItemList;        // the players
page.NextPageToken;   // null on the last page; the only end signal

The token marks a position in the order ("after Ivy, rating 91, id 9"), so a row deleted before it does not shift the next page and a row added after it shows up later. The page size may change between pages.

Counted

var page = (CountedPage<PlayerView>)await queries.ListAsync(null, Paging.Counted(page: 2, size: 20), ct);

page.Page;         // 2
page.TotalCount;   // every match, across all pages

A page deeper than MaximumDepth rows is refused with a PagingException, never replaced by another page. Use continuable paging to go further.

Capped

var page = (CappedPage<PlayerView>)await queries.ListAsync("red", Paging.Capped(cap: 3), ct);

page.Truncated;   // true when more than 3 matched

Mapping and Empty Pages

page.Map(...) maps the items and keeps the kind with its page number, total or token. A filter known to match nothing can answer without reading:

if (minRating > 100)
    return paging.ToEmptyPage<PlayerView>();

Limits

builder.Services.AddAnt(options =>
    options.Limits = new PagingLimits(DefaultSize: 20, MaximumSize: 50, MaximumDepth: 1000));   // the defaults

Paging.Continuable(token, size, pager.Limits);   // build Paging from the configured limits
Request Result
no size, or a size below 1 DefaultSize
a size or cap above MaximumSize clamped to MaximumSize and served
counted page below 1 PagingException
counted page × size above MaximumDepth PagingException
inconsistent limits the host fails at startup

Ordering

Order<Player>.ByDescending(p => p.Rating)           // nullable: unrated players sort last
    .ThenBy(p => p.Name)
    .ThenBy(p => p.Id);                              // unique tie-breaker, always last

Order<Player>.By(p => p.Rating, nullsFirst: true);  // nulls first instead
order.Reverse();                                     // every direction and nulls placement flipped
  • End with a unique key. Rows with equal values are then still in a fixed order, so a page boundary between them can never skip or repeat one.
  • Keys are typed lambdas. No strings: renaming a property is a compile error.
  • Mixed directions work. Each key seeks in its own direction.
  • Timestamps are truncated to microseconds in the token, the precision of PostgreSQL and most stores.

Tokens

PageScope.For(nameof(ListAsync), team, minRating)

The scope names the read and lists every argument that decides its result set. A token is refused when:

  • it is presented to another read,
  • any scope value differs (team=red token used with team=blue),
  • the order changed,
  • a character was altered, or the keys that encrypted it are gone.

Never put the page size in the scope; it may change between pages. Pass scalars and records.

Tokens have no lifetime. A host with several instances must share one Data Protection key ring (PersistKeysToFileSystem, a database, Redis), otherwise a token from one instance is refused by another. The purpose root defaults to Zooper.Ant.PageToken; changing it invalidates every token in flight:

builder.Services.AddAnt(options => options.PurposeRoot = "MyService.PageToken");

To replace the encryption, register your own IPageTokenProtector before AddAnt.

PageToken implements IParsable<PageToken> and carries JSON and type converters, so it binds from a query string and serialises as a plain string.

Reading Everything

TraverseAsync follows the tokens for you. It reads the next page only when you reach it, keeps no snapshot, and ends with the error of a failed page:

await foreach (var player in pager.TraverseAsync<PlayerView>(
    (token, ct) => queries.ListAsync("red", Paging.Continuable(token, 100), ct), ct))
{
    await Sync(player);
}

Data is live: a row whose ordering value changes during the traversal may be skipped or seen twice. Order by an immutable key when that matters.

Error Handling

Exception Raised when
PagingException page below 1, depth beyond the limit, malformed token, counted kind on a store that cannot count
InvalidPageTokenException a token from another read, other arguments, another order, altered, or unreadable. Derives from PagingException.

Map one type to one response. An ASP.NET Core host gets that mapping from Zooper.Ant.AspNetCore, below.

Hosting in ASP.NET Core

Zooper.Ant.AspNetCore is the HTTP edge, so no host writes it again. Every Ant host speaks the same five query parameters:

Parameter Value Kinds
paging continuable, counted or capped, any case; absent means continuable all
pageToken the previous page's nextPageToken continuable
page one-based page number counted
size page size, defaulted and clamped by the limits continuable, counted
cap cap, defaulted and clamped like a size capped

An argument of another kind (?paging=capped&cap=5&page=2) is refused, never ignored. An unknown paging value or a malformed pageToken is a 400 before the endpoint runs. The names are fixed: a host whose API already uses other names binds its own parameters and calls the Paging factories with pager.Limits.

Bind PagingRequest beside your own filter and pass the host's limits:

app.MapGet("/players", ([AsParameters] PlayerFilter filter, [AsParameters] PagingRequest paging, PlayerQueries queries, Pager pager, CancellationToken ct) =>
    queries.ListAsync(filter, paging.ToPaging(pager.Limits), ct));

// In an [ApiController]:
public Task<Page<PlayerView>> List([FromQuery] PlayerFilter filter, [FromQuery] PagingRequest paging, CancellationToken ct) =>
    queries.ListAsync(filter, paging.ToPaging(pager.Limits), ct);

Two independent one-liners do the rest; the binding needs none:

builder.Services.AddAntExceptionHandler();                               // PagingException → 400 problem response
builder.Services.AddOpenApi(options => options.AddAntPageResponses());   // Page<T> → oneOf the three page shapes

app.UseExceptionHandler();                                               // required, as for every IExceptionHandler
app.MapOpenApi();

The problem's type is urn:zooper:ant:invalid-page-token for a refused token (a client restarts from the first page) and urn:zooper:ant:invalid-paging for every other refusal; the detail is the refusal's message. A host with its own error convention skips AddAntExceptionHandler and maps PagingException itself. Controllers need [ApiController], which turns a failed parse into a 400; without it an unknown paging would bind as absent.

Adding a Store

A LINQ provider implements IQueryExecutor, two methods. QueryablePageSource<T> builds the ORDER BY and the seek from the Order<T>:

public sealed class MyProviderExecutor : IQueryExecutor
{
    public Task<IReadOnlyList<T>> ToListAsync<T>(IQueryable<T> query, CancellationToken ct) => ...;
    public Task<int> CountAsync<T>(IQueryable<T> query, CancellationToken ct) => ...;
}

var page = await pager.ReadAsync(new QueryablePageSource<Player>(query, order, new MyProviderExecutor()), paging, scope, ct);

Pass timestampPrecision when the store keeps timestamps more precisely than microseconds.

Any other store implements IPageSource<T>. The Pager builds every kind from it and owns the tokens; the source never sees one:

public interface IPageSource<TItem>
{
    string OrderSignature { get; }                                                    // changes when the order does
    Task<int> CountAsync(CancellationToken ct);                                       // counted kind
    Task<IReadOnlyList<TItem>> ReadAsync(int offset, int limit, CancellationToken ct);
    Task<IReadOnlyList<TItem>> ReadAfterAsync(PagePosition position, int limit, CancellationToken ct);
    PagePosition PositionOf(TItem item);                                              // goes into the token
    PagePosition ParsePosition(IReadOnlyList<JsonElement> payload);                   // comes out of it
}

A store with its own cursors (Elasticsearch search_after, Cosmos DB continuations, Dapr state queries) puts its cursor in the position and throws NotSupportedException from CountAsync and from ReadAsync with an offset. Continuable and capped pages work; the counted kind is refused with a PagingException. ActivityLogPageSource in the example project is a complete one.

API Quick Reference

Member Purpose
services.AddAnt(options => ...) Registers Pager and the token protector; Limits, PurposeRoot
services.AddAntExceptionHandler() Zooper.Ant.AspNetCore: paging refusals as 400 problem responses; needs app.UseExceptionHandler()
openApiOptions.AddAntPageResponses() Zooper.Ant.AspNetCore: Page<T> documented as oneOf the three page shapes
[AsParameters] / [FromQuery] PagingRequest + ToPaging(pager.Limits) Zooper.Ant.AspNetCore: the five query parameters as a Paging
Paging.Continuable(token, size, limits) First or next continuable page
Paging.Counted(page, size, limits) Page by number, with total
Paging.Capped(cap, limits) Top N, with truncation flag
paging.ToEmptyPage<T>() Empty page of the requested kind
query.ToPageAsync(paging, order, scope, pager, ct) Pages an in-memory IQueryable<T> or IEnumerable<T>
pager.ReadAsync(source, paging, scope, ct) Pages any IPageSource<T>
pager.TraverseAsync(readPage, ct) Every item of a read, page by page
pager.Limits The configured PagingLimits
page.Map(selector) Maps items, keeps the kind
Order<T>.By / ByDescending / ThenBy / ThenByDescending / Reverse Declares the order
PageScope.For(readName, values...) Binds tokens to a read and its filters
PageToken.Parse / TryParse Validates a token's format

More from Zooper

Check out the other libraries on GitHub.

Contributing

Contributions are welcome. Open an issue or submit a pull request.


License

This project is licensed under the MIT License - see the LICENSE file for details.

Made with ❤️ by the Zooper team

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.0.0 47 10/21/2026