Skip to content

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

GET /api/orders?page=2&pageSize=25
Parameter Default Description
page 1 Current page number (1-based)
pageSize Endpoint default Items per page

Sorting

GET /api/orders?sort=-createdAt
GET /api/orders?sort=name
GET /api/orders?sort=-priority,createdAt
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

GET /api/orders

Returns first page with default page size.

Filtered and Sorted

GET /api/orders?filter[status]=completed&sort=-createdAt&page=1&pageSize=10

Returns completed orders, newest first, 10 per page.

Multiple Filters

GET /api/orders?filter[status]=pending&filter[priority]=high&filter[region]=west

All filters are AND'd together.

Defining REST Lenses

Basic Lens

Basic Lens

[RestLens(Route = "/api/orders")]
public interface IOrderLens : ILensQuery<OrderReadModel> { }

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);
}