Files
git.stella-ops.org/src/JobEngine/StellaOps.TaskRunner/StellaOps.TaskRunner.Client/Pagination/Paginator.cs

172 lines
5.1 KiB
C#

using System.Runtime.CompilerServices;
namespace StellaOps.TaskRunner.Client.Pagination;
/// <summary>
/// Generic paginator for API responses.
/// </summary>
/// <typeparam name="T">Type of items being paginated.</typeparam>
public sealed class Paginator<T>
{
private readonly Func<int, int, CancellationToken, Task<PagedResponse<T>>> _fetchPage;
private readonly int _pageSize;
/// <summary>
/// Initializes a new paginator.
/// </summary>
/// <param name="fetchPage">Function to fetch a page (offset, limit, cancellationToken) -> page.</param>
/// <param name="pageSize">Number of items per page (default: 50).</param>
public Paginator(
Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
int pageSize = 50)
{
_fetchPage = fetchPage ?? throw new ArgumentNullException(nameof(fetchPage));
_pageSize = pageSize > 0 ? pageSize : throw new ArgumentOutOfRangeException(nameof(pageSize));
}
/// <summary>
/// Iterates through all pages asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Async enumerable of items.</returns>
public async IAsyncEnumerable<T> GetAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var offset = 0;
while (true)
{
var page = await _fetchPage(offset, _pageSize, cancellationToken).ConfigureAwait(false);
foreach (var item in page.Items)
{
yield return item;
}
if (!page.HasMore || page.Items.Count == 0)
{
break;
}
offset += page.Items.Count;
}
}
/// <summary>
/// Collects all items into a list.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of all items.</returns>
public async Task<IReadOnlyList<T>> CollectAsync(CancellationToken cancellationToken = default)
{
var items = new List<T>();
await foreach (var item in GetAllAsync(cancellationToken).ConfigureAwait(false))
{
items.Add(item);
}
return items;
}
/// <summary>
/// Gets a single page.
/// </summary>
/// <param name="pageNumber">Page number (1-based).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Single page response.</returns>
public Task<PagedResponse<T>> GetPageAsync(int pageNumber, CancellationToken cancellationToken = default)
{
if (pageNumber < 1)
{
throw new ArgumentOutOfRangeException(nameof(pageNumber), "Page number must be >= 1.");
}
var offset = (pageNumber - 1) * _pageSize;
return _fetchPage(offset, _pageSize, cancellationToken);
}
}
/// <summary>
/// Paginated response wrapper.
/// </summary>
/// <typeparam name="T">Type of items.</typeparam>
public sealed record PagedResponse<T>(
IReadOnlyList<T> Items,
int TotalCount,
bool HasMore)
{
/// <summary>
/// Creates an empty page.
/// </summary>
public static PagedResponse<T> Empty { get; } = new([], 0, false);
/// <summary>
/// Current page number (1-based) based on offset and page size.
/// </summary>
public int PageNumber(int offset, int pageSize)
=> pageSize > 0 ? (offset / pageSize) + 1 : 1;
}
/// <summary>
/// Extension methods for creating paginators.
/// </summary>
public static class PaginatorExtensions
{
/// <summary>
/// Creates a paginator from a fetch function.
/// </summary>
public static Paginator<T> Paginate<T>(
this Func<int, int, CancellationToken, Task<PagedResponse<T>>> fetchPage,
int pageSize = 50)
=> new(fetchPage, pageSize);
/// <summary>
/// Takes the first N items from an async enumerable.
/// </summary>
public static async IAsyncEnumerable<T> TakeAsync<T>(
this IAsyncEnumerable<T> source,
int count,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
if (count <= 0)
{
yield break;
}
var taken = 0;
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
yield return item;
taken++;
if (taken >= count)
{
break;
}
}
}
/// <summary>
/// Skips the first N items from an async enumerable.
/// </summary>
public static async IAsyncEnumerable<T> SkipAsync<T>(
this IAsyncEnumerable<T> source,
int count,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
var skipped = 0;
await foreach (var item in source.WithCancellation(cancellationToken).ConfigureAwait(false))
{
if (skipped < count)
{
skipped++;
continue;
}
yield return item;
}
}
}