REST Filtering¶
Verified by tests
LensRequestTests, LensResponseTests, LensEndpointBaseTests, RestLensAttributeTests — library CI run #31657041675 (2026-08-13)
Whizbang REST endpoints support filtering, sorting, and paging through standard query parameters using the LensRequest model.
Overview¶
REST filtering provides:
- Query Parameter Binding - Standard URL patterns for filters
- Sort Expressions - Ascending/descending with multiple fields
- Pagination - Page-based navigation with configurable limits
- Extensible Hooks -
LensEndpointBase<TModel>hooks and parsing helpers for custom endpoints
Updated
Shipped behavior at this commit: generated lens endpoints bind page, pageSize, sort, and filter[...] parameters into LensRequest and fully implement paging (with a default OrderBy(x => x.Id) for stable pagination). Applying Filter and Sort to the query inside generated endpoints is not yet wired up — use the LensRequest values with the ParseSortExpression/CalculatePaging helpers in a custom endpoint to apply them today.
LensRequest Model¶
The LensRequest class captures filtering, sorting, and paging parameters:
LensRequest Model
public class LensRequest {
public int Page { get; set; } = 1;
public int? PageSize { get; set; }
public string? Sort { get; set; }
public Dictionary<string, string>? Filter { get; set; }
}
Query Parameter Syntax¶
Paging¶
| Parameter | Default | Description |
|---|---|---|
page |
1 | Current page number (1-based) |
pageSize |
Endpoint default | Items per page |
Sorting¶
| Prefix | Direction |
|---|---|
- |
Descending |
+ or none |
Ascending |
Multiple fields are comma-separated and applied in order.
Filtering¶
GET /api/orders?filter[status]=active
GET /api/orders?filter[status]=active&filter[priority]=high
GET /api/orders?filter[customerName]=Acme
Filters are key-value pairs using bracket notation.
Complete URL Examples¶
Basic Query¶
Returns first page with default page size.
Filtered and Sorted¶
Returns completed orders, newest first, 10 per page.
Multiple Filters¶
All filters are AND'd together.
Defining REST Lenses¶
Basic Lens¶
Basic Lens
With Custom Paging¶
With Custom Paging
[RestLens(
Route = "/api/orders",
DefaultPageSize = 25,
MaxPageSize = 100)]
public interface IOrderLens : ILensQuery<OrderReadModel> { }
Filtering Only (No Sorting)¶
Filtering Only (No Sorting)
[RestLens(
Route = "/api/statuses",
EnableSorting = false,
EnablePaging = false)]
public interface IStatusLens : ILensQuery<StatusReadModel> { }
RestLensAttribute Properties¶
| Property | Type | Default | Description |
|---|---|---|---|
Route |
string? |
Model-based | REST route pattern |
EnableFiltering |
bool |
true |
Accept filter parameters |
EnableSorting |
bool |
true |
Accept sort parameters |
EnablePaging |
bool |
true |
Accept page/pageSize |
DefaultPageSize |
int |
10 |
Default items per page |
MaxPageSize |
int |
100 |
Maximum allowed page size |
Response Format¶
LensResponse¶
LensResponse
public class LensResponse<TModel> where TModel : class {
public IReadOnlyList<TModel> Data { get; set; } = [];
public int TotalCount { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 10;
// Computed from TotalCount, PageSize, and Page
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling((double)TotalCount / PageSize) : 0;
public bool HasNextPage => Page < TotalPages;
public bool HasPreviousPage => Page > 1;
}
Example Response¶
Example Response
{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"customerName": "Acme Corp",
"status": "Completed",
"totalAmount": 150.00,
"createdAt": "2024-01-15T10:30:00Z"
}
],
"page": 1,
"pageSize": 10,
"totalCount": 42,
"totalPages": 5,
"hasNextPage": true,
"hasPreviousPage": false
}
Sort Expression Parsing¶
The LensEndpointBase<TModel> class provides a static helper for parsing sort strings:
Sort Expression Parsing
protected static IReadOnlyList<SortExpression> ParseSortExpression(string? sort);
public readonly record struct SortExpression(string Field, bool Descending);
Example¶
Example
var sorts = ParseSortExpression("-createdAt,name,+status");
// Returns:
// [
// { Field: "createdAt", Descending: true },
// { Field: "name", Descending: false },
// { Field: "status", Descending: false }
// ]
Paging Calculation¶
The base class provides bounds-checked paging:
Paging Calculation
protected static (int skip, int take) CalculatePaging(
LensRequest request,
int defaultPageSize,
int maxPageSize);
Example¶
Example (2)
// With request: page=3, pageSize=50, max=100
var (skip, take) = CalculatePaging(request, 10, 100);
// skip = 100 (page 3, 0-indexed: 2 * 50)
// take = 50
Customizing Filter Behavior¶
LensEndpointBase<TModel> exposes OnBeforeQueryAsync and OnAfterQueryAsync hooks for endpoints that inherit from it:
Customizing Filter Behavior
public class OrderLensEndpoint : LensEndpointBase<OrderReadModel> {
protected override ValueTask OnBeforeQueryAsync(LensRequest request, CancellationToken ct) {
// Add default filters
request.Filter ??= new Dictionary<string, string>();
// Only show active orders by default
if (!request.Filter.ContainsKey("isDeleted")) {
request.Filter["isDeleted"] = "false";
}
return ValueTask.CompletedTask;
}
protected override ValueTask OnAfterQueryAsync(
LensRequest request,
LensResponse<OrderReadModel> response,
CancellationToken ct) {
// Post-process the response
return ValueTask.CompletedTask;
}
}
Updated
Endpoints generated from [RestLens] currently inherit FastEndpoints' Endpoint<LensRequest, LensResponse<TModel>> directly, not LensEndpointBase<TModel>, so these hooks are not available on generated endpoints yet. Generated endpoints are emitted as partial classes for extension.
Client Examples¶
JavaScript/TypeScript¶
JavaScript/TypeScript
async function getOrders(params: {
page?: number;
pageSize?: number;
sort?: string;
filters?: Record<string, string>;
}) {
const url = new URL('/api/orders', baseUrl);
if (params.page) url.searchParams.set('page', params.page.toString());
if (params.pageSize) url.searchParams.set('pageSize', params.pageSize.toString());
if (params.sort) url.searchParams.set('sort', params.sort);
if (params.filters) {
for (const [key, value] of Object.entries(params.filters)) {
url.searchParams.set(`filter[${key}]`, value);
}
}
const response = await fetch(url.toString());
return response.json();
}
// Usage
const orders = await getOrders({
page: 1,
pageSize: 25,
sort: '-createdAt',
filters: { status: 'pending' }
});
C# HttpClient¶
C# HttpClient
public async Task<LensResponse<OrderReadModel>> GetOrdersAsync(
int page = 1,
int pageSize = 10,
string? sort = null,
Dictionary<string, string>? filters = null) {
var query = new List<string> {
$"page={page}",
$"pageSize={pageSize}"
};
if (!string.IsNullOrEmpty(sort)) {
query.Add($"sort={Uri.EscapeDataString(sort)}");
}
if (filters != null) {
foreach (var (key, value) in filters) {
query.Add($"filter[{key}]={Uri.EscapeDataString(value)}");
}
}
var url = $"/api/orders?{string.Join("&", query)}";
return await _httpClient.GetFromJsonAsync<LensResponse<OrderReadModel>>(url);
}
Related Documentation¶
- REST Setup - Installation and configuration
- REST Mutations - Command endpoints
- GraphQL Filtering - Comparison with GraphQL approach