Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/Dexpace.Sdk.Core/Pagination/AsyncPageable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

namespace Dexpace.Sdk.Core.Pagination;

/// <summary>
/// An async-enumerable sequence of items that is backed by a series of HTTP pages.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
/// <remarks>
/// <para>
/// Consumers can iterate items directly (<c>await foreach (var item in pageable)</c>) or iterate
/// pages via <see cref="AsPages"/> for access to per-page metadata such as status and headers.
/// </para>
/// <para>
/// Enumeration is lazy: the next page is fetched only when the consumer advances past the last
/// item of the current page. Each page send is an independent pipeline invocation.
/// </para>
/// </remarks>
public abstract class AsyncPageable<T> : IAsyncEnumerable<T>
{
/// <summary>Returns an async sequence of <see cref="Page{T}"/> instances.</summary>
/// <param name="pageSizeHint">
/// An optional hint for the number of items per page. How (or whether) this is used depends
/// on the concrete implementation.
/// </param>
/// <returns>An async sequence of pages.</returns>
public abstract IAsyncEnumerable<Page<T>> AsPages(int? pageSizeHint = null);

/// <summary>
/// Returns an enumerator that iterates items from all pages in sequence.
/// </summary>
/// <param name="cancellationToken">A token to cancel enumeration.</param>
/// <returns>An <see cref="IAsyncEnumerator{T}"/> over all items across all pages.</returns>
public abstract IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default);
}
43 changes: 43 additions & 0 deletions src/Dexpace.Sdk.Core/Pagination/Page.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

using Dexpace.Sdk.Core.Http.Common;
using Dexpace.Sdk.Core.Http.Response;

namespace Dexpace.Sdk.Core.Pagination;

/// <summary>
/// A single page of results returned by a paginated operation.
/// </summary>
/// <typeparam name="T">The item type.</typeparam>
/// <remarks>
/// <para>
/// <see cref="Values"/> contains the deserialized items for this page.
/// <see cref="Status"/> and <see cref="Headers"/> are the HTTP metadata captured from the
/// response before it was disposed; they are immutable and safe to retain after iteration.
/// </para>
/// </remarks>
public sealed class Page<T>
{
/// <summary>Creates a page.</summary>
/// <param name="values">The items on this page.</param>
/// <param name="status">The HTTP status of the response that produced this page.</param>
/// <param name="headers">The HTTP response headers for this page.</param>
public Page(IReadOnlyList<T> values, Status status, Headers headers)
{
ArgumentNullException.ThrowIfNull(values);
ArgumentNullException.ThrowIfNull(headers);
Values = values;
Status = status;
Headers = headers;
}

/// <summary>The items on this page.</summary>
public IReadOnlyList<T> Values { get; }

/// <summary>The HTTP status code of the response that produced this page.</summary>
public Status Status { get; }

/// <summary>The HTTP response headers for this page.</summary>
public Headers Headers { get; }
}
156 changes: 156 additions & 0 deletions src/Dexpace.Sdk.Core/Pagination/Pageable.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 dexpace and Omar Aljarrah.
// Licensed under the MIT License. See LICENSE in the repository root for details.

using System.Runtime.CompilerServices;
using Dexpace.Sdk.Core.Configuration;
using Dexpace.Sdk.Core.Errors;
using Dexpace.Sdk.Core.Http.Common;
using Dexpace.Sdk.Core.Http.Request;
using Dexpace.Sdk.Core.Http.Response;
using Dexpace.Sdk.Core.Pipeline;
using Dexpace.Sdk.Core.Serialization;

namespace Dexpace.Sdk.Core.Pagination;

/// <summary>
/// Factory methods for creating <see cref="AsyncPageable{T}"/> instances.
/// </summary>
public static class Pageable
{
/// <summary>
/// Creates an <see cref="AsyncPageable{T}"/> that fetches pages through
/// <paramref name="pipeline"/>, starting with <paramref name="first"/>.
/// </summary>
/// <typeparam name="TPage">The deserialized page-envelope type.</typeparam>
/// <typeparam name="T">The item type extracted from each page.</typeparam>
/// <param name="pipeline">The pipeline used for each page request.</param>
/// <param name="first">The initial request to send.</param>
/// <param name="serde">The serde used to deserialize each <typeparamref name="TPage"/>.</param>
/// <param name="options">Client options forwarded to each pipeline call.</param>
/// <param name="selectItems">
/// Extracts the ordered item list from a deserialized page envelope.
/// </param>
/// <param name="nextRequest">
/// Given the deserialized page, the raw response (before disposal), and the current request,
/// returns the next request to send, or <see langword="null"/> to end iteration.
/// </param>
/// <param name="maxPages">
/// Maximum number of pages to fetch. <see langword="null"/> means no limit.
/// </param>
/// <returns>
/// A lazy <see cref="AsyncPageable{T}"/> that fetches exactly one page per consumer advance.
/// </returns>
public static AsyncPageable<T> Create<TPage, T>(
HttpPipeline pipeline,
Request first,
ISerde serde,
DexpaceClientOptions options,
Func<TPage, IReadOnlyList<T>> selectItems,
Func<TPage, Response, Request, Request?> nextRequest,
int? maxPages = null)
{
ArgumentNullException.ThrowIfNull(pipeline);
ArgumentNullException.ThrowIfNull(first);
ArgumentNullException.ThrowIfNull(serde);
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(selectItems);
ArgumentNullException.ThrowIfNull(nextRequest);

return new PipelinePageable<TPage, T>(pipeline, first, serde, options, selectItems, nextRequest, maxPages);
}

// ── internal implementation ────────────────────────────────────────────────────────────────

private sealed class PipelinePageable<TPage, T>(
HttpPipeline pipeline,
Request first,
ISerde serde,
DexpaceClientOptions options,
Func<TPage, IReadOnlyList<T>> selectItems,
Func<TPage, Response, Request, Request?> nextRequest,
int? maxPages) : AsyncPageable<T>
{
/// <inheritdoc/>
/// <remarks>
/// <paramref name="pageSizeHint"/> is not plumbed into the outgoing request in v1; cancel
/// the pages path via <c>.WithCancellation(token)</c> on the returned sequence.
/// </remarks>
public override IAsyncEnumerable<Page<T>> AsPages(int? pageSizeHint = null) =>
PagesCore(CancellationToken.None);

/// <inheritdoc/>
public override IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default) =>
ItemsCore(cancellationToken).GetAsyncEnumerator(cancellationToken);

// Page iterator — fetches one HTTP page per yield.
private async IAsyncEnumerable<Page<T>> PagesCore(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
var current = first;
var fetched = 0;

while (true)
{
cancellationToken.ThrowIfCancellationRequested();

if (maxPages.HasValue && fetched >= maxPages.Value)
{
yield break;
}

var response = await pipeline.SendAsync(current, options, cancellationToken)
.ConfigureAwait(false);

TPage page;
Status status;
Headers headers;
Request? next;

try
{
page = await response.Body
.ReadValueAsync<TPage>(serde, cancellationToken)
.ConfigureAwait(false)
?? throw new InvalidOperationException(
$"Serde returned null when deserializing page type '{typeof(TPage).FullName}'. " +
"The page deserialization must produce a non-null value.");

status = response.Status;
headers = response.Headers;

// Capture next while the response (and its headers) are still alive.
next = nextRequest(page, response, current);
}
finally
{
await response.DisposeAsync().ConfigureAwait(false);
}

fetched++;
yield return new Page<T>(selectItems(page), status, headers);

if (next is null)
{
yield break;
}

current = next;
}
}

// Item iterator — flattens PagesCore.
private async IAsyncEnumerable<T> ItemsCore(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await foreach (var page in PagesCore(cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
foreach (var item in page.Values)
{
yield return item;
}
}
}
}
}
Loading
Loading