Skip to content

feat: add lazy pagination (AsyncPageable, Page, and strategy helpers)#9

Merged
OmarAlJarrah merged 4 commits into
mainfrom
feat/pagination
Jun 16, 2026
Merged

feat: add lazy pagination (AsyncPageable, Page, and strategy helpers)#9
OmarAlJarrah merged 4 commits into
mainfrom
feat/pagination

Conversation

@OmarAlJarrah

Copy link
Copy Markdown
Member

Summary

Adds lazy, pipeline-driven pagination — item- and page-level async iteration over paginated operations.

  • AsyncPageable<T> : IAsyncEnumerable<T> with AsPages(pageSizeHint?) — iterate items flat or page-by-page. Fully lazy (exactly one request per page, fetched only as the consumer advances), disposes each response on every path (normal, exception, early break), threads cancellation, and is safely re-enumerable.
  • Page<T>Values + Status + Headers.
  • Pageable.Create<TPage, T>(pipeline, first, serde, options, selectItems, nextRequest, maxPages?) — typed selectors (strongly typed, AOT-friendly; no dynamic JSON), runs each page request through the HttpPipeline, with a maxPages safety cap.
  • PaginationStrategies — ready-made nextRequest builders: Cursor (body cursor → query param), PageNumber (increment a query param while more pages remain), and LinkHeader (RFC 8288 Link with rel="next", quote-aware parsing, multi-valued rel, and an http/https scheme guard on the resolved target).

Core stays BCL-only here; no new dependencies. Deferred to a follow-up (v1 omissions, per the slice spec): Page.ContinuationToken / token-based resumption and pageSizeHint plumbing.

Test plan

  • dotnet build -c Release clean on net8.0 + net10.0 (warnings-as-errors)
  • dotnet format --verify-no-changes clean
  • dotnet test -c Release passes (301 tests: 284 core, 17 serialization)

🤖 Generated with Claude Code

OmarAlJarrah and others added 4 commits June 16, 2026 18:22
Adds core pagination support to Dexpace.Sdk.Core:

- `Page<T>` — immutable carrier of Values, Status, and Headers for one
  HTTP page.
- `AsyncPageable<T>` — abstract base implementing IAsyncEnumerable<T>
  with an AsPages() view for per-page metadata.
- `Pageable.Create<TPage,T>` — factory that builds a lazy pipeline-driven
  pageable; each page is fetched on-demand through the full HttpPipeline
  (retry, auth, telemetry apply per page). nextRequest drives iteration;
  maxPages caps it. Responses are disposed inside a try/finally before
  yielding each Page<T> so headers are safely captured first.

19 new tests in Dexpace.Sdk.Core.Tests/Pagination/ covering: item
flattening, AsPages count/values/metadata, laziness (single send after
first-page consume, second send on advance), maxPages cap, null-nextRequest
termination, response body disposal, Page<T> constructor guards, and
Pageable.Create null-arg guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add four test scenarios to PageableTests that were missing coverage:
early-break disposal (first body disposed, transport called once),
exception-path disposal (serde throws but body is still disposed),
re-enumeration (each iteration restarts from first, independent call
count), and already-cancelled token (items + pages paths both surface
OperationCanceledException before the transport is driven).

The last scenario required a small correctness fix to PagesCore: add
ThrowIfCancellationRequested() at the top of each iteration so a
pre-cancelled token is observed before SendAsync is called.

Also adds a one-line XML remarks note on the AsPages override stating
that pageSizeHint is not plumbed in v1 and that cancellation for the
pages path is passed via WithCancellation(token).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add public static class PaginationStrategies with three factory methods:

- Cursor<TPage>: reads a cursor string from the deserialized page and
  sets it as a URL query parameter on the next request; returns null
  when the cursor is absent or empty to end iteration.

- PageNumber<TPage>: reads the current integer value of a query
  parameter (defaults to 1 if absent), increments it, and returns the
  next request while hasMore(page) is true.

- LinkHeader<TPage>: parses the Link response header per RFC 8288
  (comma-separated <url>; rel="…" entries, handles top-level commas
  inside angle brackets), resolves the matching URL against the current
  request URL via Uri.TryCreate, and stops on parse failure or missing
  rel. Default rel is "next".

All helpers are BCL-only (no new package references). Internal query
manipulation uses a zero-allocation ref-struct QueryPairEnumerator and
UriBuilder. Each strategy produces current with { Url = newUrl },
preserving method, headers, and body.

Covered by PaginationStrategiesTests: cursor set/stop/replace, cursor
preserves method+headers, page-number increment/stop/absent-default,
link-header next/stop/relative/missing/multi-entry/malformed/default-rel,
and one end-to-end Pageable.Create + cursor strategy two-page walk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…eme guard)

Three correctness issues in PaginationStrategies.LinkHeader:

1. Quote-aware splitting — the top-level comma split (SplitLinkEntries) and the
   per-entry semicolon split (new SplitLinkParams) now suppress splitting while
   inside a double-quoted run, so a comma or semicolon inside a param value like
   title=\"Page 3, final\" no longer breaks the entry and stops pagination early.

2. Multi-valued rel — RFC 8288 specifies rel as a space-separated token list.
   The rel value is now split on whitespace and any matching token (case-insensitive)
   is accepted, so rel=\"prev next\" correctly matches a \"next\" lookup.

3. Scheme guard — after resolving the link URL against the current request URL,
   returns null unless the resolved URI is absolute with an http or https scheme.
   This prevents mailto:, ftp:, javascript:, etc. from reaching the request layer,
   where current with { Url = ... } would bypass Request's constructor validation.

Nine new tests added covering all three fixes plus fragment/sibling-param
preservation and body carryover for both Cursor and PageNumber strategies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@OmarAlJarrah OmarAlJarrah merged commit d45e64b into main Jun 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant