DKNet.Templates

Generic List Endpoint (filter · search · order · page)

Every generator-driven CRUD slice gets a GET / list route for free. It is a single, uniform query surface — pagination, multi-field filtering, free-text search, and ordering — driven entirely by the query string, with no per-feature code to write. This page is the full contract for that route.

This is the automated path. Hand-written slices (ManualSample/PurchaseOrder) instead expose a bespoke ListPurchaseOrdersQuery with its own hand-picked parameters — see Querying and Specifications. The two do not share a contract; everything below applies only to routes mapped through the generator.

Where it comes from

The route is MapGetList<TEntity, TKey, TModel>(), part of the DKNet.AspCore.Extensions NuGet package (version pinned in src/Directory.Packages.props). You never call it directly in this template. The DKNet.SlimBus.Generators source generator emits the call for you inside the generated Map<Entity>Crud() extension whenever an entity carries [CrudCreate]/[CrudUpdate].

Worked instance — the automated Product sample:

// src/ApiEndpoints/Minimal.Api/ApiEndpoints/AutomatedSample/ProductV1Endpoint.cs
public void Map(RouteGroupBuilder group)
{
    // generated → group.MapGetList<Product, Guid, ProductDto>(); its products.read scope comes from
    // the class-level [EndpointGroupScope(ProductScopes.Read, EndpointHttpMethods.Get)] declaration
    group.MapProductCrud();
}

So GET /v1/products is a fully capable list endpoint even though no list handler, validator, or query object was hand-written anywhere in the slice.

Signature (from the package):

public RouteHandlerBuilder MapGetList<TEntity, TKey, TModel>(string endpoint = "/")
    where TEntity : class, IEntity<TKey>
    where TKey    : IEquatable<TKey>
    where TModel  : class;

// Guid-key shorthand
public RouteHandlerBuilder MapGetList<TEntity, TModel>(string endpoint = "/")
    where TEntity : class, IEntity<Guid>
    where TModel  : class;

TModel is the projected DTO (ProductDto for the sample). It is central to the whole contract: you can only filter, search, and order by fields that exist on the DTO, never on the raw entity. See The DTO is the boundary below.

Query parameters

Parameter Type Default Notes
pageNumber int 1 1-based. A value < 1 is silently clamped to 1.
pageSize int 1000 A value < 1 falls back to 1000. Max 1000 — larger is clamped down, not rejected. Both figures are host-configurable — see Configuring the defaults.
filter repeatable none field:operation:value. Repeat the param to AND multiple conditions. Max 20.
search string none Free-text OR across all string DTO fields. Min 2 characters.
orderBy string none A single DTO field name to sort by.
desc bool false Reverses the orderBy direction.
fromDate ISO-8601 none Inclusive lower bound on when a record was last active — see Recent-activity window.
toDate ISO-8601 none Inclusive upper bound on when a record was last active. Omitting both bounds does not mean “all history” — see the same section.

Paging is always applied. Filter, search, and order are each optional and independent; when present they are ANDed together (search is one OR-group, then ANDed with the filter predicate). Over records that carry audit timestamps a recent-activity window is also always applied — the fromDate/toDate you named, or a default one if you named neither — and it is ANDed with everything else.

Filtering

Each filter value is a colon-delimited triple parsed into a ListFilter(Field, Operation, Value). Repeat the parameter to combine conditions with AND:

GET /v1/products?filter=Price:GreaterThan:100&filter=IsDiscontinued:Equal:false

Operations

Operation Value form Meaning
Equal scalar field == value
NotEqual scalar field != value
GreaterThan scalar field > value
GreaterThanOrEqual scalar field >= value
LessThan scalar field < value
LessThanOrEqual scalar field <= value
Contains string field.Contains(value)
NotContains string !field.Contains(value)
StartsWith string field.StartsWith(value)
EndsWith string field.EndsWith(value)
In comma-separated value.Contains(field) — any of the listed values
NotIn comma-separated none of the listed values
IsNull (omit value) field == null — two-part form field:IsNull
IsNotNull (omit value) field != null — two-part form field:IsNotNull

Field naming

Field is normalised to PascalCase, so unit_price, unit-price, and UnitPrice all resolve to the same DTO property, matched case-insensitively. The resolved name must be a public property on TModel. If it is not, the request fails with 400 Bad Request — unknown fields are never silently dropped, because dropping a condition would answer a filtered query with unfiltered data.

Only the first two colons split the triple, so a value may contain colons of its own — an ISO-8601 timestamp being the case that matters: filter=CreatedOn:GreaterThan:2026-01-31T00:00:00Z.

Limits

Ordering

orderBy names one DTO field (PascalCased, same rules as filter fields; it must exist on both the DTO and the entity). desc=true sorts descending; omitted or false sorts ascending.

GET /v1/products?orderBy=Price&desc=true

search is a single free-text term matched with Contains across every string property of the DTO, OR’d together, then ANDed with any filter/default predicate:

GET /v1/products?search=widget

For ProductDto the string fields are Name plus the mapped audit columns CreatedBy / UpdatedBy, so a search hits any of those. (Every searched field must map to a real column — see the trap below.)

Recent-activity window (fromDate / toDate)

fromDate and toDate are inclusive ISO-8601 bounds on when a record was last active. A record is in range when either the moment it was created or the moment it was last updated falls inside the bounds — so a record never updated since creation is matched on its creation moment alone, and is never dropped for lacking an update.

GET /v1/products?fromDate=2026-06-01T00:00:00Z&toDate=2026-06-30T23:59:59Z

The automated sample’s Product is an AggregateRoot and therefore audited (src/ApiEndpoints/Minimal.Domains/Features/AutomatedSample/Entities/Product.cs), so a bare GET /v1/products is subject to the default window: the last three months of product activity, not every product ever created.

Configuring the defaults

The page size and the window length are host settings on ListQueryOptions, bound from the DKNet:ListQuery configuration section:

Option Config key Type Default Notes
DefaultPageSize DKNet:ListQuery:DefaultPageSize int 1000 Used when pageSize is omitted or < 1.
MaxPageSize DKNet:ListQuery:MaxPageSize int 1000 Ceiling an explicit pageSize is clamped to. It also caps the default, so an unspecified request is served the lower of the two.
DefaultActivityWindowMonths DKNet:ListQuery:DefaultActivityWindowMonths int 3 Length of the default activity window. Minimum 0; 0 switches the default window off and leaves a bare listing unbounded in time.

A service that configures its own values keeps them — the figures above are only the built-in fallbacks used when a service configures nothing.

Response envelope

The route returns 200 OK with a PagedResponse<TModel>:

public sealed record PagedResponse<TResult>
{
    public IList<TResult> Items { get; init; } = [];
    public int  PageCount       { get; init; }
    public int  PageNumber      { get; init; }
    public int  PageSize        { get; init; }
    public int  TotalItemCount  { get; init; }
    public bool HasNextPage     { get; init; }
    public bool HasPreviousPage { get; init; }
}

Items holds the projected DTOs for the current page; the rest is paging metadata. It is built from X.PagedList via the repository’s ToPagedListAsync(...), so TotalItemCount is the full unpaged count.

Error behavior

Every malformed input is a 400 Bad Request with a reason (via Results.Problem), never a silently-ignored parameter:

Out-of-range paging is the one exception: pageNumber < 1 and pageSize outside 1..1000 are clamped, not rejected. The ceiling is MaxPageSize — see Configuring the defaults.

The DTO is the boundary

Filter, search, and order fields are resolved against TModel (the returned DTO), never the raw entity. This is a deliberate security boundary: a column the DTO doesn’t expose cannot be sorted on, filtered on, or searched — no hidden column leaks through the query surface. Widen or narrow the query surface by changing what the DTO exposes ([GenerateDto(... Exclude/Include ...)]), not the endpoint.

The Product sample DTO is declared as:

[GenerateDto(typeof(Product),
    Exclude = [nameof(Product.OwnedBy), nameof(AuditedEntity<Guid>.LastModifiedBy), nameof(AuditedEntity<Guid>.LastModifiedOn)])]
public sealed partial record ProductDto
{
    [SensitiveData("pricing")]
    public decimal? GrossMargin { get; init; }
}

OwnedBy is excluded, so it is unqueryable through this route by construction — you cannot filter or sort products by their ownership key over HTTP, even though the column exists on the entity.

GrossMargin is the opposite case: a property written by hand onto the generated partial record and mapped from two columns (Price minus SupplierCostPrice) by a Mapster IRegister, so it is on the response but has no entity counterpart of its own. A derived DTO field cannot be filtered, searched or ordered on, and asking for one is refused rather than attempted — ?orderBy=grossMargin and ?filter=grossMargin:GreaterThan:0 each answer 400 naming the field, alongside the other unsupported-field errors listed above (Minimal.App.Tests/Integration/AutomatedSample/V1/ProductGrossMarginTests.cs:181-210). The rest of the query surface is unaffected: filtering and searching on the convention-mapped fields behaves as before, with the margin reported on each item that survives. How the property is put on the response: the automated sample.

Trap: a DTO field must map to a real column

Because filter/search/order build EF predicates against the entity by property name, every queryable DTO field has to resolve to a mapped entity column. A DTO property whose entity counterpart is computed or [NotMapped] makes the whole query fail to translate — a 500, not a 400, and it fires the moment such a field is touched (search touches every string field, so it breaks on the first search).

This is the narrower case, and it is why it bites: the field has to be declared on the entity but not mapped to a column. A DTO field with no entity counterpart at all — GrossMargin above — is caught by the field check and refused with 400. One that the entity declares and EF does not map passes that check and fails later, in the database provider.

This is exactly why LastModifiedBy / LastModifiedOn are excluded above. On AuditedEntity<TKey> they are computed conveniences (“the updated value, or the created one if never modified”), not columns — [GenerateDto] would otherwise surface them and every ?search= would 500. The mapped UpdatedBy / UpdatedOn stay and cover the same intent. When you point [GenerateDto] at an entity with computed or unmapped members, Exclude them or the free list route inherits a latent 500.

Worked example

GET /v1/products
  ?search=widget
  &filter=Price:GreaterThanOrEqual:100
  &filter=IsDiscontinued:Equal:false
  &orderBy=Price
  &desc=true
  &fromDate=2026-06-01T00:00:00Z
  &pageNumber=2
  &pageSize=50

Reads as: products whose Name/CreatedBy/UpdatedBy contains “widget”, priced at 100 or more, not discontinued, last active on or after 1 June 2026 (with no upper bound, because toDate was omitted), sorted by price descending (with Id as tie-break), returning the second page of 50.

Drop the fromDate line and the listing falls back to the default three-month window rather than to all history; to get all history, name fromDate=0001-01-01T00:00:00Z instead.