A C# source generator library that automatically creates async enumeration methods for cursor-based paginated APIs.
$ dotnet add package CursorA C# source generator library that automatically creates async enumeration methods for cursor and offset-based paginated APIs, making it effortless to iterate through paginated results.
await foreachdotnet add package Cursor
Implement the ICursorPage<T> interface for your paginated response:
using Cursor;
public class CursorPage<T> : ICursorPage<T>
{
public List<T> Items { get; set; } = new();
public string? NextCursor { get; set; }
}
Add the [GenerateEnumerator] attribute to methods that return paginated results:
using Cursor;
using Refit;
public interface IExampleApi
{
[Get("/items")]
[GenerateEnumerator]
Task<CursorPage<Item>> ListItemsAsync(
int? limit = null,
string? cursor = null,
CancellationToken cancellationToken = default
);
}
The source generator automatically creates extension methods for you:
var api = RestService.For<IExampleApi>("https://api.example.com");
// Enumerate all items across all pages
await foreach (var item in api.EnumerateItemsAsync())
{
Console.WriteLine(item);
}
// Or collect all items at once
var allItems = await api.EnumerateItemsAsync().ToListAsync();
// Enumerate pages instead of individual items
await foreach (var page in api.EnumerateItemsPagesAsync())
{
Console.WriteLine($"Page with {page.Items.Count} items");
}
Your API methods must:
Task<TPage> where TPage implements ICursorPage<T>List*Async (e.g., ListItemsAsync, ListUsersAsync)limit (or custom name) - page sizecursor (or custom name) - pagination tokencancellationToken - for async cancellationIf your API uses different parameter names, you can customize them:
[Get("/items")]
[GenerateEnumerator(
LimitParameterName = "pageSize",
CursorParameterName = "nextToken"
)]
Task<CursorPage<Item>> ListItemsAsync(
int? pageSize,
string? nextToken,
CancellationToken cancellationToken
);
[Get("/items")]
[GenerateEnumerator(
CursorParameterName = "offset"
)]
Task<CursorPage<Item>> ListItemsAsync(
int? offset,
int? limit,
CancellationToken cancellationToken
);
You can use any class that implements ICursorPage<T>:
public record CustomCursorPage<T> : ICursorPage<T>
{
public required List<T> Data { get; init; }
public string? Cursor { get; init; }
List<T> ICursorPage<T>.Items => Data;
string? ICursorPage<T>.NextCursor => Cursor;
}
[Get("/items")]
[GenerateEnumerator]
Task<CustomCursorPage<Item>> ListItemsAsync(
int? limit,
string? cursor = null,
CancellationToken cancellationToken = default
);
The generator preserves any additional parameters in the generated methods:
[Get("/items")]
[GenerateEnumerator]
Task<CursorPage<Item>> ListItemsAsync(
string category, // Additional parameter
int? limit = null,
string? cursor = null,
CancellationToken cancellationToken = default
);
// Usage
await foreach (var item in api.EnumerateItemsAsync("electronics"))
{
// Process items from "electronics" category
}
You can create your own attribute that inherits from GenerateEnumeratorAttribute:
[AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)]
public class MyCustomEnumeratorAttribute : GenerateEnumeratorAttribute
{
public MyCustomEnumeratorAttribute()
{
LimitParameterName = "pageSize";
CursorParameterName = "nextToken";
}
}
// Usage
[Get("/items")]
[MyCustomEnumerator]
Task<CursorPage<Item>> ListItemsAsync(
int? pageSize,
string? nextToken,
CancellationToken cancellationToken
);
For a method named ListItemsAsync, the generator creates:
EnumerateItemsAsync() - Returns IAsyncEnumerable<T> for iterating individual itemsEnumerateItemsPagesAsync() - Returns IAsyncEnumerable<ICursorPage<T>> for iterating pagesBoth methods:
NextCursorCancellationToken for graceful cancellationpageSize parameterThe library uses Roslyn source generators to analyze your code at compile-time:
[GenerateEnumerator] or derived attributesNo reflection or runtime code generation is involved, making it AOT-compatible and performant.
using Cursor;
using Refit;
public interface IGitHubApi
{
[Get("/users/{user}/repos")]
[GenerateEnumerator]
Task<CursorPage<Repository>> ListRepositoriesAsync(
string user,
int? limit = null,
string? cursor = null,
CancellationToken cancellationToken = default
);
}
// Usage
var api = RestService.For<IGitHubApi>("https://api.github.com");
await foreach (var repo in api.EnumerateRepositoriesAsync("octocat"))
{
Console.WriteLine($"{repo.Name}: {repo.Description}");
}
await foreach (var page in api.EnumerateRepositoriesPagesAsync("octocat", limit: 50))
{
Console.WriteLine($"Processing page with {page.Items.Count} repositories");
// Process entire page at once
foreach (var repo in page.Items)
{
// ...
}
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
try
{
await foreach (var item in api.EnumerateItemsAsync(cancellationToken: cts.Token))
{
// Process item
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Operation timed out");
}
MIT
Contributions are welcome! Please feel free to submit a Pull Request.