The minimal-API glue for DKNet-based web APIs: host-populated request members, discovered and
versioned endpoint groups, one-line verb-to-command mappers, generic list/read/delete endpoints, and
FluentResults-to-IResult conversion.
DKNet.AspCore.Extensions covers five distinct jobs that show up in almost every DKNet host:
IEndpointConfig) without hand-wiring
MapGroup/WithApiVersionSet boilerplate per feature;DKNet.EfCore.Specifications;ListQueryRequest, ListFilter,
PagedResponse<T>) shared by every list endpoint; andFluentResults result into the IResult/ProblemDetails shape minimal APIs and
OpenAPI both expect.Reach for it whenever you are building minimal-API endpoints on top of DKNet’s SlimBus/CQRS and EF Core Specifications packages — it is what turns a command/query class, or a bare entity, into a routed, versioned, documented HTTP endpoint with almost no repeated code.
dotnet add package DKNet.AspCore.Extensions
Minimum wiring in Program.cs — AddApiVersioning() is required because UseEndpointConfigs
defaults to versioned routes, and AddContextualRequestPopulation() is required the moment any
request declares a [FromClaim], [FromRequestHeader] (or other IContextualSource) member:
using DKNet.AspCore.Extensions.Endpoints; // UseEndpointConfigs
using DKNet.AspCore.Extensions.ModelBinding; // AddContextualRequestPopulation
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthorization();
builder.Services.AddApiVersioning();
builder.Services.AddContextualRequestPopulation();
builder.Services.AddOpenApi(); // optional, needed for the published API description
var app = builder.Build();
app.UseEndpointConfigs(); // discovers every IEndpointConfig across the loaded assemblies
app.MapOpenApi();
app.Run();
Every sample below assumes the using that owns the type it shows:
| Namespace | Types |
|---|---|
DKNet.AspCore.Extensions |
IEndpointConfig |
DKNet.AspCore.Extensions.ModelBinding |
FromClaimAttribute, FromRequestHeaderAttribute, IContextualSource, IContextualValueResolver, AddContextualRequestPopulation() |
DKNet.AspCore.Extensions.Endpoints |
EndpointRegistrationOptions, UseEndpointConfigs(), the Map* mappers, ListQueryRequest, ListQueryOptions, AddListQueryOptions(), ListFilter, ListFilterJsonConverter, CrudMapOptions, CrudOp |
DKNet.AspCore.Extensions.Responses |
PagedResponse<T>, ResultResponseExtensions, ProblemDetailsExtensions, ErrorResponseOptions, AddErrorResponses(), ErrorResponseContext, ErrorItem, ErrorSource |
[FromClaim], [FromRequestHeader] and IContextualSourceA request DTO often needs a value the caller must never control — who created it, which tenant it
belongs to. IContextualSource marks a property as populated by the host instead of the caller;
FromClaimAttribute is the built-in implementation, resolving the property from a named claim on
the authenticated user.
FromClaimAttribute has exactly one form — a single required constructor argument, no named
properties, AttributeTargets.Property, AllowMultiple = false, Inherited = false. What you
write, and what the request instance actually carries by the time your handler sees it:
| You declare | The handler receives |
|---|---|
[FromClaim(ClaimTypes.NameIdentifier)]public string? CreatedBy { get; set; } |
CreatedBy = "8f0c…" — the value of the caller’s nameidentifier claim, whatever the request body said |
| same, caller authenticated but the claim is absent | CreatedBy = null — the property’s type default, never the caller’s value |
[FromClaim("tenant_id")]public Guid TenantId { get; set; } |
TenantId = Guid.Parse(claim) via TypeDescriptor conversion; an unconvertible claim yields Guid.Empty, not a 400 |
[FromClaim("tenant_id")]public Guid TenantId { get; } (no setter) |
Nothing — InvalidOperationException at startup naming '{Type}.{Property}' declares a contextual source but has no setter |
using System.Security.Claims;
using DKNet.AspCore.Extensions.ModelBinding;
using DKNet.SlimBus.Extensions;
public sealed record CreateProductCommand : Fluents.Requests.IWitResponse<ProductModel>
{
public string Name { get; init; } = string.Empty;
[FromClaim(ClaimTypes.NameIdentifier)]
public string? CreatedBy { get; set; } // always overwritten — never trust a caller-supplied value here
}
Register the mechanism once:
builder.Services.AddContextualRequestPopulation();
That single call registers ClaimValueResolver and the population service as scoped, and adds
the two OpenAPI transformers through ConfigureAll<OpenApiOptions>. Endpoint groups mapped by
UseEndpointConfigs then populate declared members before validation and before the handler
runs — for both JSON-body binding and [AsParameters]/query binding — and the declared members
are removed from the published OpenAPI description (ContextualSourceSchemaTransformer for JSON
bodies, ContextualSourceOperationTransformer for query/[AsParameters] parameters), since the
caller can never actually supply them — with one exception, the header parameter a
[FromRequestHeader] member publishes, described next.
A declared member the registered resolvers cannot resolve always holds its type’s default —
there is no built-in substitute value. CanResolve keys on the declaration’s attribute type,
not on the individual member and not on whether a value is actually available, and population
consults exactly the first resolver whose CanResolve matches — it never chains to a second one.
Registering a resolver ahead of AddContextualRequestPopulation() therefore replaces the
built-in resolver for that attribute type outright, for every member declaring it — including an
authenticated caller who has the real claim. A host that wants a value of its own, for example a
system-account identity on an anonymous group, must perform the built-in lookup itself before
substituting, or its resolver silently displaces ClaimValueResolver for every [FromClaim]
member on every request:
public sealed class SystemAccountValueResolver : IContextualValueResolver
{
public bool CanResolve(IContextualSource source) => source is FromClaimAttribute;
public string? Resolve(IContextualSource source, HttpContext httpContext) =>
httpContext.User.FindFirst(((FromClaimAttribute)source).ClaimType)?.Value
?? "system-account"; // only the caller who truly has no claim gets the substitute
}
builder.Services.AddScoped<IContextualValueResolver, SystemAccountValueResolver>();
builder.Services.AddContextualRequestPopulation();
[FromRequestHeader]FromRequestHeaderAttribute is the second built-in source, and it takes the same shape as
FromClaimAttribute: one required constructor argument — the header name, exposed as
HeaderName — on a property that must have a set or init. It needs no registration of its
own: the AddContextualRequestPopulation() call that already powers [FromClaim] powers it too,
and no route-level code is involved.
using DKNet.AspCore.Extensions.ModelBinding;
using DKNet.SlimBus.Extensions;
public sealed record TransferCommand : Fluents.Requests.IWitResponse<TransferModel>
{
public decimal Amount { get; init; }
[FromRequestHeader("Idempotency-Key")]
public string? IdempotencyKey { get; set; } // filled from the header, never from the body
}
| You declare | The handler receives |
|---|---|
[FromRequestHeader("Idempotency-Key")]public string? IdempotencyKey { get; set; }, caller sends Idempotency-Key: k-77 |
IdempotencyKey = "k-77" — whatever the request body said for that member |
same, caller sends idempotency-key: k-77 |
IdempotencyKey = "k-77" — header-name matching is case-insensitive |
same, header sent twice (k-77, then k-88) |
IdempotencyKey = "k-77" — the first value sent, never a joined "k-77,k-88" |
| same, header absent | IdempotencyKey = null — the property’s type default, and the request is still dispatched |
Three points worth stating plainly before you build on it:
IContextualValueResolver (see Contextual request binding
above) to fill that gap should account for the same implication a claim-filled member has: if
every omitted-header caller resolves to one shared value, a service using the member as an
idempotency key ends up sharing one key across those callers.The published API description also differs from [FromClaim]’s. A claim-filled member is hidden
entirely, because the caller can never supply it. A header-filled member can only be supplied by
the caller, so the operation declares the header as an in: header parameter — the caller has to
know to send it. The member itself is still absent from the published request body, so it is
advertised in exactly one place.
A new source kind needs only its own attribute plus a matching resolver — no change to this
package. The mechanism dispatches on IContextualValueResolver.CanResolve, never on a concrete
attribute type. A plain request header no longer needs one of these — [FromRequestHeader] covers
it — so read the example below as the shape any other source kind takes:
public sealed class FromTenantHeaderAttribute : Attribute, IContextualSource;
public sealed class TenantHeaderResolver : IContextualValueResolver
{
public bool CanResolve(IContextualSource source) => source is FromTenantHeaderAttribute;
public string? Resolve(IContextualSource source, HttpContext httpContext) =>
httpContext.Request.Headers["X-Tenant-Id"];
}
// builder.Services.AddScoped<IContextualValueResolver, TenantHeaderResolver>();
IEndpointConfig / UseEndpointConfigsImplement IEndpointConfig per feature area instead of wiring MapGroup calls by hand. Only
GroupEndpoint and Map are abstract; the other three members are default interface
implementations you override when the default is wrong:
| Member | Default | What it controls |
|---|---|---|
string GroupEndpoint { get; } |
none — you must supply it | Route segment appended after the version prefix, e.g. "/products". |
void Map(RouteGroupBuilder group) |
none — you must supply it | Where the group’s endpoints are registered. Runs last, after tags, filters and authorization. |
string Tag { get; } |
GroupEndpoint with / replaced by - and leading - trimmed — so "/products" becomes "products" |
The OpenAPI tag. Resolving to an empty string falls back to EndpointRegistrationOptions.DefaultTag. |
int Version { get; } |
1 |
API version the group is mapped to, and the v{n} in the route and group name. |
using DKNet.AspCore.Extensions;
public sealed class ProductsEndpointConfig : IEndpointConfig
{
public string GroupEndpoint => "/products"; // Tag defaults to "products"
public int Version => 1; // optional; defaults to 1
public void Map(RouteGroupBuilder group)
{
group.MapGetById<Product, ProductModel>("/{id:guid}");
group.MapGetList<Product, ProductModel>("/");
group.MapPost<CreateProductCommand, ProductModel>("/");
}
}
app.UseEndpointConfigs(...) scans the given assemblies (or every loaded assembly by default — so a
consuming application’s own IEndpointConfig types are picked up automatically), builds one
versioned RouteGroupBuilder per config via Asp.Versioning, tags it, requires authorization by
default, and calls Map. It returns the created groups as an IReadOnlyList<RouteGroupBuilder>,
or an empty list when nothing was discovered. Every default reproduces the DKNet template’s original
hardcoded behaviour — a caller who supplies no options gets that behaviour unchanged:
app.UseEndpointConfigs(o =>
{
o.EnableVersioning = false; // drop the "/v{version}" prefix entirely
o.RequireAuthorization = false; // explicit host opt-out; the host owns this decision
o.DefaultTag = "Root"; // used when a config resolves an empty Tag
o.RouteTemplate = c => $"/api{c.GroupEndpoint}"; // override the generated route pattern
o.ConfigureGroup = (group, config) =>
group.AddEndpointFilter(async (ctx, next) => await next(ctx)); // per-group host setup
},
typeof(Program).Assembly); // optional: restrict the scan to named assemblies
ConfigureGroup is the hook for host-specific setup that used to be built into this package —
request validation (e.g. AddFluentValidationAutoValidation()), custom filters, and so on. It
always runs after the contextual-population filter and before RequireAuthorization, so population
can never be bypassed by a host filter, while real ASP.NET Core authorization middleware still runs
ahead of every endpoint filter at request time.
[EndpointGroupScope][EndpointGroupScope] declares which authorization scope an IEndpointConfig group requires,
above the class. It is the only way a package user requires a scope on a group — there is no
group-wide policy member; the interface has never grown one back after AuthPolicy was removed
(see Migrating off AuthPolicy below).
Two forms, both the same attribute:
[EndpointGroupScope("accounts.read")], naming no HTTP method, requires that
scope on every method the group serves. This is the group-wide declaration.[EndpointGroupScope("accounts.write", EndpointHttpMethods.Post, EndpointHttpMethods.Put, EndpointHttpMethods.Delete)]
requires the scope only for the named methods. Stack one attribute per scope, and let a single
declaration cover several methods with EndpointHttpMethods’ constants.A mixed group — a scope-only default plus one or more per-method declarations — is the common shape once you have more than one scope:
using DKNet.AspCore.Extensions;
using DKNet.AspCore.Extensions.Endpoints;
[EndpointGroupScope("accounts.read")] // default: every method
[EndpointGroupScope("accounts.write", EndpointHttpMethods.Post, EndpointHttpMethods.Put, EndpointHttpMethods.Delete)] // wins for these methods
public sealed class AccountsEndpointConfig : IEndpointConfig
{
public string GroupEndpoint => "/accounts";
public void Map(RouteGroupBuilder group)
{
group.MapGet("/{id:guid}", (Guid id) => Results.Ok()); // needs accounts.read (the default)
group.MapPost("/", () => Results.Ok()); // needs accounts.write
group.MapPut("/{id:guid}", (Guid id) => Results.Ok()); // needs accounts.write
group.MapDelete("/{id:guid}", (Guid id) => Results.Ok()); // needs accounts.write
}
}
GET /accounts/{id} falls through to the accounts.read default; POST, PUT and DELETE each
have their own declaration, so they need accounts.write instead. A group carrying no
[EndpointGroupScope] at all is unchanged — no attribute read, no policy applied beyond the plain
RequireAuthorization() every group already gets.
One route inside the group can ask for its own scope instead of the group’s, with
RequireAuthorization on that route:
group.MapGet("/{id:guid}/audit-trail", (Guid id) => Results.Ok())
.RequireAuthorization("postings.read"); // wins over the group's declared "accounts.read"
Or open it to anonymous callers with AllowAnonymous:
group.MapGet("/health", () => Results.Ok())
.AllowAnonymous(); // no token required, regardless of the group's declared scopes
Precedence when more than one rule could apply, most specific first:
RequireAuthorization(...) or AllowAnonymous() — always wins, for that
method only.[EndpointGroupScope] declaration naming that method.[EndpointGroupScope] declaration on the same group — the default.A method the group serves but that no per-method declaration, no scope-only default, and no
route-level rule covers fails the host at startup — app.StartAsync() throws an
InvalidOperationException naming the route pattern and the served, undeclared HTTP method. A
group carrying a scope-only default is never refused this way: the default covers every method the
group serves, the verb-less case included (a route with no IHttpMethodMetadata, named * in the
refusal message, is covered by the default exactly like a named method). Clear the refusal on a
group with no default by adding an [EndpointGroupScope] declaration covering the missing method,
by giving the route its own RequireAuthorization(...) or AllowAnonymous(), or by no longer
serving the method.
AuthPolicyIEndpointConfig.AuthPolicy — one policy name for the whole group — is removed; this is a
breaking change for any IEndpointConfig implementation that set it. Its replacement is the
scope-only [EndpointGroupScope] form above the class:
// Before
public sealed class ProductsEndpointConfig : IEndpointConfig
{
public string? AuthPolicy => "products:write";
// ...
}
// After
[EndpointGroupScope("products:write")]
public sealed class ProductsEndpointConfig : IEndpointConfig
{
// ...
}
A group that set AuthPolicy => null (the default) needs no replacement — a group carrying no
[EndpointGroupScope] already keeps plain RequireAuthorization() with no policy, exactly what
AuthPolicy == null gave before.
The whole mechanism is inert on a host running with EndpointRegistrationOptions.RequireAuthorization
set to false: no attribute is read, no scope is enforced, and a method with no declared scope never
triggers the startup refusal.
FluentsEndpointMapperExtensions maps an HTTP verb straight onto a SlimMessageBus fluent
request/query (from DKNet.SlimBus.Extensions), dispatching through IMessageBus and turning the
FluentResults outcome into the right IResult automatically. Every one of them is an extension on
RouteGroupBuilder and every one calls .ProducesCommons():
| Mapper | Command constraint | Binding | Success status |
|---|---|---|---|
MapGet<TCommand, TResponse>(endpoint) |
Fluents.Queries.IWitResponse<TResponse> |
[AsParameters] |
200, or 404 when the query returns null |
MapGetPage<TCommand, TResponse>(endpoint) |
Fluents.Queries.IWitPageResponse<TResponse> |
[AsParameters] |
200 with PagedResponse<TResponse> |
MapPost<TCommand, TResponse>(endpoint) |
Fluents.Requests.IWitResponse<TResponse> |
inferred body | 201 when the command’s type name contains "Create" (case-insensitive), otherwise 200 |
MapPost<TCommand>(endpoint) |
Fluents.Requests.INoResponse |
inferred body | same 201/200 rule, no body |
MapPut<TCommand, TResponse>(endpoint) / MapPut<TCommand>(endpoint) |
IWitResponse<TResponse> / INoResponse |
inferred body | 200 |
MapPatch<TCommand, TResponse>(endpoint) / MapPatch<TCommand>(endpoint) |
IWitResponse<TResponse> / INoResponse |
inferred body | 200 |
MapDelete<TCommand, TResponse>(endpoint) |
IWitResponse<TResponse> |
explicit [FromBody] |
200 |
MapDelete<TCommand>(endpoint) |
INoResponse |
[AsParameters] |
200 |
MapPutById<TCommand, TKey, TResponse>(endpoint = "{id}") |
IWitResponse<TResponse> and Fluents.Requests.IWithKey<TKey> |
route id + inferred body |
200 |
MapActionById<TCommand, TKey, TResponse>(endpoint, httpMethod) |
IWitResponse<TResponse> and IWithKey<TKey> |
route id + inferred body |
200 |
MapParameterlessActionById<TCommand, TKey, TResponse>(endpoint, httpMethod) |
IWitResponse<TResponse> and IWithKey<TKey>, plus new() |
route id only — nothing is bound from the body |
200 |
group.MapPost<CreateProductCommand, ProductModel>("/"); // 201 Created — type name contains "Create"
group.MapPost<RenameProductCommand, ProductModel>("/{id:guid}/rename"); // 200 Ok otherwise
group.MapPut<UpdateProductCommand, ProductModel>("/{id:guid}");
group.MapPutById<UpdateProductCommand, Guid, ProductModel>(); // binds route {id} into request.Id
group.MapActionById<ApproveOrderCommand, Guid, OrderModel>("{id}/approval", "POST");
group.MapParameterlessActionById<ArchiveOrderCommand, Guid, OrderModel>("{id}/archive", "PATCH"); // no body required
group.MapPatch<AdjustStockCommand>("/{id:guid}/stock"); // INoResponse overload — 200/no body
group.MapDelete<DeactivateProductCommand>("/{id:guid}"); // INoResponse — [AsParameters] binding
group.MapGet<FindProductQuery, ProductModel>("/find"); // Fluents.Queries.IWitResponse<T> -> 200 or 404
group.MapGetPage<ListProductsPageQuery, ProductModel>("/page"); // Fluents.Queries.IWitPageResponse<T>
Both action mappers register exactly the one verb they are handed, and both take the target id from the route. They differ only in what the request body is for:
MapActionById binds TCommand from the body, which ASP.NET Core treats as required — a call with no body
is answered 400 before the handler runs. Use it whenever the command carries anything besides Id.MapParameterlessActionById binds nothing from the body and constructs TCommand itself, which is why it
adds the new() constraint. The route dispatches on a call with no body and no Content-Type at all, still
accepts (and ignores) a body if one is sent, and its OpenAPI operation declares no requestBody. Use it when
the command’s only member is Id — a command with other members would never have them populated.MapPutById/MapActionById assign the route key onto the command before dispatch
(request.Id = id), so the command never has to re-read it from the route:
public sealed record ApproveOrderCommand
: Fluents.Requests.IWitResponse<OrderModel>, Fluents.Requests.IWithKey<Guid>
{
public Guid Id { get; set; } // assigned from the route by the mapper
public string Approver { get; init; } = string.Empty;
}
ProducesCommons() adds two things: the shared 400/401/403/404/409/429/500 response
metadata, so the published OpenAPI description is consistent across endpoints, and the endpoint
filter that answers an unhandled exception with the standard error body (see
One error-response setting). It is
public — call it yourself on a hand-written RouteHandlerBuilder to get both:
app.MapGet("/health", () => "ok").ProducesCommons();
FluentsEntityEndpointMapperExtensions skips SlimMessageBus entirely and goes straight to a
DKNet.EfCore.Specifications IRepositorySpec. Each mapper comes in two shapes: an explicit-TKey
form for any key type, and a Guid shorthand that forwards to it.
| Mapper | Default route | Constraints | Result |
|---|---|---|---|
MapGetById<TEntity, TKey, TModel>(endpoint = "{id}") |
{id} |
TEntity : class, IEntity<TKey>, TKey : IEquatable<TKey>, TModel : class |
200 with the projected model, 404 when no row matches |
MapGetById<TEntity, TModel>(endpoint = "{id}") |
{id} |
TEntity : class, IEntity<Guid> |
forwards to the TKey form with TKey = Guid |
MapDeleteById<TEntity, TKey>(endpoint = "{id}") |
{id} |
TEntity : class, IEntity<TKey>, TKey : IEquatable<TKey> |
204, 404 when no row matches, 409 when SaveChangesAsync throws DbUpdateException |
MapDeleteById<TEntity, TKey, TRequest>(endpoint = "{id}") |
{id} |
as above, plus TRequest : class, Fluents.Requests.IWithKey<TKey> |
identical 204/404/409 — TRequest is bound [AsParameters] so a group filter can validate it |
MapDeleteById<TEntity>(endpoint = "{id}") |
{id} |
TEntity : class, IEntity<Guid> |
forwards to the TKey form |
MapGetList<TEntity, TKey, TModel>(endpoint = "/") |
/ |
TEntity : class, IEntity<TKey>, TKey : IEquatable<TKey>, TModel : class |
200 with PagedResponse<TModel>, 400 on an unusable filter/search/orderBy |
MapGetList<TEntity, TModel>(endpoint = "/") |
/ |
TEntity : class, IEntity<Guid> |
forwards to the TKey form |
group.MapGetById<Product, ProductModel>("/{id:guid}"); // Guid-keyed shorthand
group.MapGetById<Sprocket, int, SprocketModel>("/{id}"); // int key
group.MapGetById<Coupon, string, CouponModel>("/{id}"); // string key
group.MapDeleteById<Sprocket, int>("/{id}");
group.MapDeleteById<Product, Guid, DeleteProductRequest>(); // same route, but a rule can refuse the delete
group.MapGetList<Product, ProductModel>("/");
MapDeleteById<TEntity, TKey, TRequest> exists only so a delete can be refused. TRequest carries nothing but
the key and is bound with [AsParameters], so a group-level validation filter — AddFluentValidationAutoValidation()
via ConfigureGroup, for instance — sees one validatable argument
even though the route still never reads a request body. The key is still bound from the route template, and the
route address, HTTP verb and every status code are the same as the two-type-argument form:
public sealed record DeleteProductRequest : Fluents.Requests.IWithKey<Guid>
{
public Guid Id { get; set; }
}
public sealed class DeleteProductRequestValidator : AbstractValidator<DeleteProductRequest>
{
public DeleteProductRequestValidator(AppDbContext db) =>
RuleFor(x => x.Id)
.MustAsync(async (id, ct) => !await db.OrderLines.AnyAsync(l => l.ProductId == id, ct))
.WithMessage("Product is still on an order and cannot be deleted.");
}
A refused delete is answered by whatever failure filter the group already registers — with the
AddFluentValidationAutoValidation() pattern above that is a 400 ProblemDetails — and never reaches
SaveChangesAsync, so the row survives and no audit entry or domain event is raised for it.
MapDeleteById<TEntity, TKey> and the Guid shorthand are unchanged: same signatures, same behaviour, same
status codes. Both forms route through one shared delete helper, so a caller that registers no rule sees
nothing different. DKNet.SlimBus.Generators emits a Delete{Entity}Request per entity and calls this
overload from Map{Entity}Crud — see
the generator’s docs.
TKey is constrained to IEquatable<TKey> rather than IParsable<TSelf> on purpose: the looser
constraint keeps string keys usable, and minimal APIs bind those natively. The cost is that a key
type the framework cannot bind fails when the route is built rather than at compile time.
All three require IRepositorySpec to be registered (services.AddSpecRepo<TDbContext>(), from
DKNet.EfCore.Specifications). MapDeleteById hard-deletes through the repository’s save pipeline,
so audit-log and domain-event hooks fire exactly as for any other removal.
Default ordering for MapGetList, when the caller supplies no orderBy: CreatedOn descending
with Id descending as tie-break when the entity implements IAuditedEntity<TKey>, or Id
descending alone otherwise. A caller-supplied orderBy replaces that default outright, and Id
descending is appended as a tie-break unless the caller already ordered by Id.
What a bare GET against a MapGetList endpoint returns is decided by two more defaults, both
host-configurable: it is served a full page of up to 1,000 items
(Page-size defaults and ceiling) and — where the listed records
carry audit timestamps — only the last three months of activity
(Default recent-activity window).
ListQueryRequest and ListFilterMapGetList binds ListQueryRequest with [AsParameters], so its properties are the endpoint’s
query string. Every property is nullable, so an absent parameter is distinguishable from a supplied
one:
| Query parameter | Property | Type | Default | Effect |
|---|---|---|---|---|
pageNumber |
PageNumber |
int? |
null → page 1 |
One-based page. null or any value below 1 is treated as the first page. |
pageSize |
PageSize |
int? |
null → 1,000 |
Items per page. null or below 1 becomes ListQueryOptions.DefaultPageSize (1,000); anything above ListQueryOptions.MaxPageSize (1,000) is clamped to it. Both are host-configurable — see Page-size defaults and ceiling. |
filter |
Filter |
ListFilter[]? |
null |
Repeatable field:operation:value conditions, AND-combined. At most 20 per request. |
search |
Search |
string? |
null |
Free-text LIKE '%…%' across the model’s text fields, OR-combined, then AND-ed onto filter. Minimum 2 characters after trimming; blank is treated as absent. |
orderBy |
OrderBy |
string? |
null |
Field to sort by, replacing the endpoint’s default ordering. |
desc |
Desc |
bool? |
null → false |
Sort descending. Ignored without orderBy. |
fromDate |
FromDate |
DateTimeOffset? |
null → last 3 months |
Lower bound on when a record was last active. null on both bounds applies ListQueryOptions.DefaultActivityWindowMonths — see Default recent-activity window. |
toDate |
ToDate |
DateTimeOffset? |
null → open-ended |
Upper bound on when a record was last active. |
ListFilter is a readonly record struct (string Field, Ops Operation, string Value) implementing
IParsable<ListFilter>, which is what lets minimal APIs bind a repeated ?filter=…&filter=…
straight into an array. Its textual form is its representation — ListFilterJsonConverter (public,
applied via [JsonConverter]) serialises it as the same colon-separated string, which is also why
OpenAPI describes filter as an array of strings rather than an object:
GET /v1/products?filter=name:Contains:widget&filter=price:GreaterThan:100&orderBy=price&desc=true
GET /v1/products?filter=discontinuedOn:IsNull&search=blue&pageSize=50
// Composing the same conditions in code rather than formatting strings:
var conditions = new[]
{
new ListFilter("Name", Ops.Contains, "widget"),
new ListFilter("Price", Ops.GreaterThan, "100"),
};
Rules the parser and validator enforce, all traceable to ListFilter.TryParse and
ListQuery.TryValidate:
Ops (DKNet.EfCore.Specifications.Dynamics): Equal, NotEqual,
GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, Contains, NotContains,
StartsWith, EndsWith, In, NotIn, IsNull, IsNotNull. Matched case-insensitively.In/NotIn take a comma-separated value list; empty entries are dropped and entries trimmed.IsNull/IsNotNull take no value, so the two-part form field:IsNull is accepted. Every
other operation requires the third segment.snake_case/kebab-case spellings are
accepted; they are normalised to PascalCase before lookup.400, never silently dropped — dropping a condition would answer a filtered query
with unfiltered data.400.fromDate/toDate are not filter conditions. They bound the listing by activity rather than by
a named field, so they work even when the returned model hides its audit timestamps, and they
AND onto whatever filter conditions the caller also sent instead of replacing them. A
fromDate later than toDate is a 400.PagedResponse<T>PagedResponse<TResult> is the envelope MapGetList/MapGetPage return. It has a parameterless
constructor (empty page) and one taking an X.PagedList.IPagedList<TResult>; build one directly
when a handler already has a paged list (e.g. a Fluents.Queries.IPageHandler<TQuery, TResponse>
result):
return Results.Ok(new PagedResponse<ProductModel>(pagedList));
| Property | Type | Populated from |
|---|---|---|
Items |
IList<TResult> |
the page’s items; [] from the parameterless constructor |
PageNumber |
int |
IPagedList.PageNumber (1-based) |
PageSize |
int |
IPagedList.PageSize |
PageCount |
int |
IPagedList.PageCount |
TotalItemCount |
int |
IPagedList.TotalItemCount |
HasNextPage |
bool |
derived: PageNumber < PageCount |
HasPreviousPage |
bool |
derived: PageNumber > 1 |
IResult / ProblemDetails conversionResultResponseExtensions.Response()/Response<T>() convert a FluentResults IResultBase/
IResult<T> — the same result type DKNet’s SlimBus handlers already return — into the right
minimal-API IResult. This is what the fluent mappers above call internally, and it is available
standalone for any hand-written endpoint:
app.MapPost("/products", async (IMessageBus bus, CreateProductCommand cmd) =>
(await bus.Send(cmd)).Response(isCreated: true));
| Input | isCreated |
Output |
|---|---|---|
IResult<T> success, non-null value |
false |
TypedResults.Json(value) |
IResult<T> success, null value |
false |
TypedResults.Ok() |
IResult<T> success |
true |
TypedResults.Created("/", value) — the location is a literal "/" placeholder |
IResultBase success |
false / true |
TypedResults.Ok() / TypedResults.Created() |
| either, failure | any | TypedResults.Problem(problemDetails), with a host’s registered error-response setting applied — resolved from the container when the response executes, so the endpoint never has to name it |
Response(ErrorResponseOptions?, …) is retired (it let an endpoint skip the registered setting by
passing null); the overloads above read the registered setting on their own.
Response()/Response<T>() are the only public path onto the standard body. The
ProblemDetailsExtensions.ToProblemDetails() that builds it is internal — a caller can no longer
answer a failure while skipping the registered setting, which is the whole point of having one
setting. ToProblemDetails(this IResultBase, HttpStatusCode) and
ToProblemDetails(this ModelStateDictionary) are retired outright.
The status a failure gets before any callback: 400, promoted to 404 when any error is a
NotFoundError. On failure the body is the one shape every failure kind in this package answers
with (see the next section) — Title is always "Error", Detail is never set, and errors is an
ErrorItem[], never a flat string list or a field → messages map.
AddErrorResponsesA DKNet host can refuse or fail a request in three different places: a SlimBus handler returns a
failed FluentResults result, FluentValidation refuses the input before the handler ever runs, or an
exception goes unhandled. AddErrorResponses is the one registration that shapes all three — call it
once; no second call configures the validation path or wires exception handling separately (it swaps
in the result factory that applies the setting, and registers the IExceptionHandler plus
UseExceptionHandler() for you). The call is idempotent — a host that reaches it from two
composition roots registers one setting, not two, and the first call’s configure wins:
using DKNet.AspCore.Extensions.Responses;
builder.Services.AddErrorResponses(o =>
{
o.StatusCode = ctx => ctx.Errors.Any(e => e.Code == "business-refusal") ? 422 : null;
o.Customize = (problem, ctx) => problem.Extensions["error-code"] = ctx.Errors.FirstOrDefault()?.Code;
});
A handler that fails with a business-refusal code and a validator that refuses the same rule with
that ErrorCode now both answer 422 application/problem+json, each carrying the error-code
member. One callback maps as many codes as you need — this one answers 409 Conflict for anything
carrying a precondition code and leaves every other failure at the status it would have had:
builder.Services.AddErrorResponses(o =>
o.StatusCode = ctx => ctx.Errors.Any(e => e.Code == "precondition") ? 409 : null);
The body that answers then reads "status": 409, "type": "Conflict" — type follows the status the
callback chose, not the 400 the failure started at. Every endpoint the fluent mappers registered picks the setting up on its own — they resolve
it from the container when the response executes, so registering it is the whole wiring step and
leaving it unregistered is not an error.
The standard body. Every failure kind — a failed command, refused input, or an unhandled exception — answers with the same shape:
{
"title": "Error",
"status": 422,
"type": "UnprocessableEntity",
"traceId": "00-...-00",
"errors": [ { "message": "...", "code": "business-refusal", "field": null } ]
}
type is always the final response status’ HttpStatusCode name — recomputed after StatusCode
runs, never left at the status the failure would have had before the callback. traceId is
Activity.Current?.Id, falling back to HttpContext.TraceIdentifier wherever a request is available.
There is no Detail member.
An unhandled exception’s body says nothing about the exception. Outside the Development
environment the single ErrorItem carries one fixed message — "An unexpected error occurred. Quote
the trace-id when reporting this." — and nothing the exception carried: no exception message, no
type name, no stack trace. Inside Development that one entry carries the exception’s own message
instead, so a local run is still debuggable. type is the response status’ name (InternalServerError
for the default 500) in every environment; it never names the exception type. traceId is the one
member a caller quotes to get the real exception out of your logs.
Where an unhandled exception is caught. Two places, both building the body through the same factory, so a caller cannot tell them apart:
| Endpoint | Caught by |
|---|---|
Anything the fluent mappers registered — every MapPost/MapPut/MapPatch/MapDelete/MapGet/MapGetPage and the generic MapGetById/MapGetList/MapDeleteById |
The single endpoint filter ProducesCommons() adds. |
| Everything else in the host | The IExceptionHandler that AddErrorResponses registers. |
The endpoint filter exists because ASP.NET Core’s Development-only developer exception page sits
closer to the endpoint than any IExceptionHandler, and would otherwise answer first with an HTML
page instead of the standard body. A filter sits closer still, so it wins.
Known boundary. An endpoint that does not go through
ProducesCommons()— a rawapp.MapGet(...), or anything registered outside the fluent mappers — has no filter, so inDevelopmentan exception it raises is answered by the developer exception page rather than the unified body.Productionis unaffected: theIExceptionHandlercovers those endpoints, and the developer exception page is not registered there. CallProducesCommons()on a hand-written endpoint to put it on the same path as the mapped ones.
The status comes from the failure, never from the route. StatusCode receives an
ErrorResponseContext and nothing else:
| Member | Type | What it carries |
|---|---|---|
Source |
ErrorSource |
Command for a failed FluentResults handler, Validation for input FluentValidation refused, Unhandled for an exception. |
Errors |
IReadOnlyList<ErrorItem> |
ErrorItem(Message, Code?, Field?). Code is the FluentResults error’s "Code" metadata entry (Command) or the validation failure’s ErrorCode (Validation); Field names the refused input member and is always null otherwise. An Unhandled context carries exactly one ErrorItem — a fixed message outside Development, the exception’s own message inside it. |
Exception |
Exception? |
The exception that was raised, for ErrorSource.Unhandled only; always null for Command and Validation. |
The context deliberately carries no HttpContext, request path or HTTP method, so the same failure
maps to the same status wherever it is raised — two routes cannot disagree about what a
business-refusal means. Returning null keeps the status that failure would have had anyway, which
is how one callback can map several error codes (or exception types) and leave everything else alone.
Customize applies to every failure kind, command, validation and unhandled alike. It runs after
the status is chosen, against the ProblemDetails about to be written — a member added there cannot
appear on one failure kind only. Use ctx.Source if the value should differ; the member itself is
always present on all three.
UnhandledError replaces the built-in body for an unhandled exception only. Leaving it null, or
returning null from it, keeps the library’s own body (the fixed/exception message shown above).
StatusCode still wins over the status this callback sets when it also returns non-null, and
Customize still runs afterwards:
o.UnhandledError = ctx => new ProblemDetails
{
Status = StatusCodes.Status503ServiceUnavailable,
Title = "Error",
Extensions = { ["retryDelaySeconds"] = 30 }
};
⚠️ The setting is host-wide. It applies to every route in the host, so anything
Customizeadds appears on every error response the API returns — including endpoints you were not thinking about when you wrote the callback. That is why nothing is added for you: name each member you add, and add only what every caller of every endpoint is allowed to see.
What a host that registers nothing still gets. AddErrorResponses is optional. Without it — and,
member by member, wherever it is called but left unset — the responses are:
| Failure | Status | Body |
|---|---|---|
| Command handler returns a failed result | 400 |
application/problem+json, the standard shape above (errors an ErrorItem[]) — Response()/Response<T>() answer this way whether or not a setting is registered. |
| Validator refuses the input | 400 |
application/problem+json, errors a field → messages map (FluentValidation auto-validation’s own default factory — only replaced once AddErrorResponses is called). |
Failure carries a NotFoundError |
404 |
application/problem+json. Needs no setting, and survives a StatusCode callback that returns null for it. |
An unhandled exception, AddErrorResponses never called |
Framework default | Neither the standard body nor UnhandledError apply — nothing in this package handles the exception. |
CrudMapOptions and CrudOpCrudMapOptions and CrudOp are this package’s half of the vertical-slice CRUD generator. You
never write a Map{Entity}Crud method: DKNet.SlimBus.Generators emits it from the
[CrudCreate]/[CrudUpdate]/[CrudAction] markers on your entity (see
DKNet.EfCore.Abstractions), composing the mappers above.
The generated file is skipped entirely when the compilation does not reference this package.
What you write:
using DKNet.EfCore.Abstractions.Attributes;
using DKNet.EfCore.Abstractions.Entities;
using DKNet.EfCore.DtoGenerator;
public class Product : IEntity<Guid>
{
[CrudCreate]
public Product(string name, decimal price) { Name = name; Price = price; }
[CrudUpdate] public void UpdatePrice(decimal price) => Price = price;
[CrudUpdate] public void UpdateName(string name) => Name = name;
[CrudAction("approval")] public void Approve(string approver) => Approver = approver;
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public decimal Price { get; private set; }
public string? Approver { get; private set; }
}
[GenerateDto(typeof(Product))]
public partial record ProductDto;
What the generator emits into ProductCrudEndpoints.g.cs, verbatim in shape:
// <auto-generated by DKNet.SlimBus.Generators />
#nullable enable
using DKNet.AspCore.Extensions.Endpoints;
namespace MyApi.Crud; // always {AssemblyName}.Crud
/// <summary>Registers the generated CRUD endpoints for Product.</summary>
public static class ProductCrudEndpointExtensions
{
/// <summary>Maps GET {id}, GET /, POST /, PUT {id} (per update request), DELETE {id} and each generated domain-action endpoint for Product.</summary>
public static global::Microsoft.AspNetCore.Routing.RouteGroupBuilder MapProductCrud(
this global::Microsoft.AspNetCore.Routing.RouteGroupBuilder group,
global::System.Action<global::DKNet.AspCore.Extensions.Endpoints.CrudMapOptions>? configure = null)
{
var options = new global::DKNet.AspCore.Extensions.Endpoints.CrudMapOptions();
configure?.Invoke(options);
options.ValidateRouteNames("Product", "GetById", "GetList", "Create", "Delete", "UpdatePrice", "UpdateName", "Approve");
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetById) && !options.IsExcluded("GetById"))
{
var routeBuilder = group.MapGetById<global::MyDomain.Product, global::System.Guid, global::MyApi.ProductDto>();
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetById, "GetById", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetList) && !options.IsExcluded("GetList"))
{
var routeBuilder = group.MapGetList<global::MyDomain.Product, global::System.Guid, global::MyApi.ProductDto>();
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.GetList, "GetList", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Delete) && !options.IsExcluded("Delete"))
{
var routeBuilder = group.MapDeleteById<global::MyDomain.Product, global::System.Guid, DeleteProductRequest>();
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Delete, "Delete", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Create) && !options.IsExcluded("Create"))
{
var routeBuilder = group.MapPost<CreateProductRequest, global::MyApi.ProductDto>("/");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Create, "Create", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update) && !options.IsExcluded("UpdatePrice"))
{
var routeBuilder = group.MapPutById<UpdatePriceProductRequest, global::System.Guid, global::MyApi.ProductDto>("{id}");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update, "UpdatePrice", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update) && !options.IsExcluded("UpdateName"))
{
var routeBuilder = group.MapPutById<UpdateNameProductRequest, global::System.Guid, global::MyApi.ProductDto>("{id}/update-name");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Update, "UpdateName", routeBuilder);
}
if (!options.IsExcluded(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action) && !options.IsExcluded("Approve"))
{
var routeBuilder = group.MapActionById<ApproveProductRequest, global::System.Guid, global::MyApi.ProductDto>("{id}/approval", "POST");
options.Apply(global::DKNet.AspCore.Extensions.Endpoints.CrudOp.Action, "Approve", routeBuilder);
}
return group;
}
}
Every registration passes two guards — its CrudOp and its own route name — so Exclude(CrudOp.Update)
drops both PUTs while Exclude("UpdateName") drops only the second. ValidateRouteNames runs before any
mapping, with every name the entity has, which is what turns an unmatched Exclude/Configure name into an
ArgumentException instead of a silent no-op.
Note the routing rules that fall out of that emission: registration order is GetById, GetList,
Delete, Create, then each [CrudUpdate] in declaration order, then each [CrudAction]. The first
[CrudUpdate] claims the plain {id} route and each additional one gets {id}/{kebab-cased-method};
an action never claims the plain {id} route, whatever verb it uses, and defaults its segment to the
kebab-cased method name when [CrudAction] carries no explicit route.
CrudMapOptions is the only knob on the generated method — it excludes whole operation kinds or individual
named routes, and it attaches RouteHandlerBuilder settings to the routes that survive:
// By operation kind — drops every delete route and every action route.
group.MapProductCrud(o => o.Exclude(CrudOp.Delete, CrudOp.Action));
// By route name — drops only the UpdateName route. UpdatePrice keeps `{id}` and
// Approve keeps `{id}/approval`; neither moves.
group.MapProductCrud(o => o.Exclude("UpdateName"));
group.MapProductCrud(o => o
.Configure(CrudOp.Update, b => b.RequireAuthorization("product.write"))
.Configure("UpdateName", b => b.RequireAuthorization("product.rename")));
| Member | Signature | Behaviour |
|---|---|---|
Exclude |
CrudMapOptions Exclude(params CrudOp[] operations) |
Adds each operation to the exclusion set and returns this for chaining. Nothing is excluded by default. |
Exclude |
CrudMapOptions Exclude(params string[] routeNames) |
Adds each route name to the exclusion set and returns this. Drops the one route carrying that name and leaves the entity’s other routes of the same kind published, at the addresses they already had. A name the entity has no route for throws ArgumentException at registration — the same check a misspelt Configure(string, …) name hits, never a silent no-op. Nothing is excluded by default. |
IsExcluded |
bool IsExcluded(CrudOp operation) |
What the generated code calls per registration, for the route’s operation kind. |
IsExcluded |
bool IsExcluded(string routeName) |
What the generated code calls per registration, for the route’s own name. A route is registered only when neither check excludes it. |
Configure |
CrudMapOptions Configure(CrudOp operation, Action<RouteHandlerBuilder> configure) |
Runs the setting against every generated route of that operation kind. Additive — several calls for one operation all run, in call order — and returns this. |
Configure |
CrudMapOptions Configure(string routeName, Action<RouteHandlerBuilder> configure) |
Runs the setting against the one route carrying that name. Additive and chainable in the same way. |
CrudOp |
enum | GetById, GetList, Create, Update, Delete, Action. Excluding Update or Action drops every route of that kind at once; naming a [CrudUpdate]/[CrudAction] member excludes that one route alone. |
Route names come from the generator, not from this package: GetById, GetList, Create and Delete for
the four fixed operations, and each [CrudUpdate]/[CrudAction] member’s own C# method name for the rest —
not the kebab-cased segment. The rule and a worked example live in
DKNet.SlimBus.Generators.
The generated method validates every name given to Exclude(string, …) and to Configure(string, …) before it
maps anything, so a name the entity does not have throws ArgumentException and the group publishes nothing —
a misspelt name fails loudly instead of dropping a RequireAuthorization on the floor or leaving a route you
meant to withdraw published. For each route, operation-kind settings run first and name settings after. A
setting naming a route whose operation was excluded is still validated, then dropped with the route; that
combination is not an error.
AddContextualRequestPopulation() takes no configure delegate and has no options of its own — a
declared member the registered resolvers cannot resolve always holds its type’s default. See
Contextual request binding
above for supplying a value of your own via a custom IContextualValueResolver.
EndpointRegistrationOptions — via UseEndpointConfigs(Action<EndpointRegistrationOptions>?, params Assembly[]):
| Option | Type | Default | Effect |
|---|---|---|---|
RouteTemplate |
Func<IEndpointConfig, string>? |
null |
null uses /v{version:apiVersion}{GroupEndpoint} when versioning is enabled, or {GroupEndpoint} otherwise. |
DefaultTag |
string |
"Root" |
Used when an IEndpointConfig.Tag resolves to an empty string. |
RequireAuthorization |
bool |
true |
When true, applies plain RequireAuthorization() to every group, then reads any [EndpointGroupScope] declared above the IEndpointConfig to add per-method (or group-default) scopes. Disabling it is an explicit per-host opt-out — it makes [EndpointGroupScope] inert (no attribute read, no scope enforced, no startup refusal). |
EnableVersioning |
bool |
true |
Adds the version prefix and API-version metadata. Requires AddApiVersioning() to be registered, or UseEndpointConfigs throws at startup — even with zero discovered configs. |
ConfigureGroup |
Action<RouteGroupBuilder, IEndpointConfig>? |
null |
Runs after mapping/tags/version metadata, before authorization is applied and before IEndpointConfig.Map. |
UseEndpointConfigs’s own parameters:
| Parameter | Type | Default | Effect |
|---|---|---|---|
configureOptions |
Action<EndpointRegistrationOptions>? |
null |
Leave null to keep every default above. |
assemblies |
params Assembly[] |
empty → AppDomain.CurrentDomain.GetAssemblies() |
Assemblies scanned for IEndpointConfig implementations. |
ErrorResponseOptions — via AddErrorResponses(Action<ErrorResponseOptions>?). Registered once and
host-wide: every knob applies to every route, and to a failed command handler, refused validation
input and an unhandled exception alike. See One error-response setting:
| Option | Type | Default | Effect |
|---|---|---|---|
StatusCode |
Func<ErrorResponseContext, int?>? |
null |
Chooses the status from the failure’s own errors. The context carries no HttpContext, path or HTTP method, so the route cannot influence it. Returning null, or leaving this unset, keeps the status that failure would have had anyway — 400, 404 when it carries a NotFoundError, or 500 for an unhandled exception. Still wins over the status UnhandledError set, when both are configured and both return non-null. |
Customize |
Action<ProblemDetails, ErrorResponseContext>? |
null |
Adds members to the ProblemDetails after its status is chosen, for ErrorSource.Command, ErrorSource.Validation and ErrorSource.Unhandled alike. Whatever it adds appears on every error response the host returns. |
UnhandledError |
Func<ErrorResponseContext, ProblemDetails?>? |
null |
Supplies the response body for an unhandled exception in place of the library’s own body. Leaving it null, or returning null from it, keeps the library’s own body. Has no effect on ErrorSource.Command/Validation. |
AddErrorResponses’s own parameter:
| Parameter | Type | Default | Effect |
|---|---|---|---|
configure |
Action<ErrorResponseOptions>? |
null |
Leave null to keep both knobs unset — each unset knob is a no-op, so the responses stay today’s. Skipping the call entirely leaves the setting unregistered, which the mappers resolve as an optional service and fall back the same way. |
ListQueryOptions (namespace DKNet.AspCore.Extensions.Endpoints) holds the page size every
MapGetList endpoint actually pages by. It is global to the host, not per endpoint:
| Option | Type | Default | Effect |
|---|---|---|---|
DefaultPageSize |
int, [Range(1, int.MaxValue)] |
1000 |
Page size used when pageSize is absent, null or below 1. |
MaxPageSize |
int, [Range(1, int.MaxValue)] |
1000 |
Ceiling every page is subject to. Still a clamp — an oversized request is served trimmed, never rejected with 400. |
DefaultActivityWindowMonths |
int, minimum 0 |
3 |
How many months back a listing of audited records reaches when the caller names neither fromDate nor toDate. 0 switches the default window off. See Default recent-activity window. |
ConfigSectionName |
const string |
"DKNet:ListQuery" |
The configuration section the options are meant to bind from. |
MaxPageSize is a hard ceiling on every path, the default included: a caller who omits pageSize
receives min(DefaultPageSize, MaxPageSize), so a MaxPageSize configured below DefaultPageSize
lowers the default page too rather than being bypassed by it.
Both DefaultPageSize and DefaultActivityWindowMonths are the values used when the host
configures nothing: a host that already sets either one keeps its own value untouched.
Raise or lower it from configuration:
{
"DKNet": {
"ListQuery": {
"DefaultPageSize": 1000,
"MaxPageSize": 5000,
"DefaultActivityWindowMonths": 3
}
}
}
builder.Services.Configure<ListQueryOptions>(
builder.Configuration.GetSection(ListQueryOptions.ConfigSectionName));
…or in code:
builder.Services.AddListQueryOptions(o => o.MaxPageSize = 5000);
To keep the behaviour a host had before these defaults changed — 20 items for a bare request, and no default time window — set both explicitly:
builder.Services.AddListQueryOptions(o =>
{
o.DefaultPageSize = 20;
o.DefaultActivityWindowMonths = 0;
});
AddListQueryOptions binds no configuration of its own — it registers the options with
ValidateDataAnnotations().ValidateOnStart(), so a DefaultPageSize or MaxPageSize below 1 fails
the host at start-up rather than silently degrading a live endpoint. Because that validation runs
against the final computed options, calling it alongside Configure<ListQueryOptions>(…) extends the
same start-up check to configuration-supplied values. Both calls are optional: a host that makes
neither still resolves IOptions<ListQueryOptions> and pages by the defaults above.
ListQueryRequest’s remaining query-string defaults are listed under
The list-endpoint query contract.
A MapGetList endpoint over records that carry audit timestamps answers a bare request — no
fromDate, no toDate — with the last 3 months of activity rather than the whole table. The
length of that window is ListQueryOptions.DefaultActivityWindowMonths, host-configurable through
the same DKNet:ListQuery section:
{
"DKNet": {
"ListQuery": {
"DefaultActivityWindowMonths": 6
}
}
}
builder.Services.AddListQueryOptions(o => o.DefaultActivityWindowMonths = 0); // no default window
How the window is decided, in order:
| The caller sends | The listing covers |
|---|---|
| neither bound | now minus DefaultActivityWindowMonths up to now |
neither bound, with DefaultActivityWindowMonths = 0 |
all history — the window is switched off |
fromDate only |
fromDate onwards, open-ended at the top |
toDate only |
everything up to toDate, open-ended at the bottom |
| both | exactly the range named |
fromDate=0001-01-01T00:00:00Z |
all history — the documented way for a caller to opt out |
Naming either bound replaces the default rather than narrowing it, which is why a single
fromDate widens the listing rather than restricting it further:
GET /v1/products # last 3 months of activity
GET /v1/products?fromDate=2026-01-01T00:00:00Z # 2026-01-01 onwards, no upper bound
GET /v1/products?fromDate=0001-01-01T00:00:00Z # all history
GET /v1/products?fromDate=2026-01-01T00:00:00Z&toDate=2026-03-31T23:59:59Z
What “last active” means: a record is in range when either its CreatedOn or its UpdatedOn
moment falls inside the bounds. A record never updated since creation is matched on CreatedOn
alone and is never dropped for lacking an update; a record created years ago but edited yesterday is
in a 3-month window, which is the intended reading of recently active.
The rest of the contract:
PagedResponse<T>.TotalItemCount as well as
the returned items — the page and the reported total always agree.fromDate later than toDate is refused with a 400, not answered with an empty page: an
impossible window is a caller mistake, and this package refuses an unusable condition rather than
dropping it silently.filter, search and orderBy — a caller who already bounds the listing with
a filter condition over a timestamp field keeps that condition, combined with the window.The window applies on the strength of the audit timestamps alone — any TEntity assignable to
IAuditedProperties (CreatedOn/UpdatedOn) — which is a wider set of types than the
newest-first default ordering recognises: that check is against IAuditedEntity<TKey>. A type that
carries the timestamps without implementing IAuditedEntity<TKey> is therefore windowed but not
reordered, until the two are aligned.
The two early exits on the left of that diagram are the failures worth internalising: a missing
AddApiVersioning() throws before discovery even runs, and a request type that declares a
contextual source without AddContextualRequestPopulation() throws while endpoint metadata is
built. Neither is a runtime surprise — both happen at startup.
DKNet.SlimBus.Extensions — Fluents.Requests/Fluents.Queries are the command/query
contracts the fluent mappers dispatch through IMessageBus; handlers return FluentResults
(IResult<T>/IResultBase), the same result type ResultResponseExtensions converts.DKNet.SlimBus.Generators — emits the Map{Entity}Crud extension that composes those mappers
and consults this package’s CrudMapOptions.DKNet.EfCore.Specifications — MapGetById/MapGetList/MapDeleteById run through
IRepositorySpec and internal ModelSpecification<TEntity,TModel> types, and ListFilter’s
Ops and dynamic predicate building come from its Dynamics namespace.DKNet.EfCore.Abstractions — the entity mappers constrain TEntity to IEntity<TKey>, and
MapGetList special-cases IAuditedEntity<TKey> for its default ordering. The CRUD markers live
there too.Microsoft.AspNetCore.OpenApi — AddContextualRequestPopulation() registers
its schema/operation transformers through ConfigureAll<OpenApiOptions>, so they apply
automatically to whatever AddOpenApi() document(s) the host already configures; versioned
routing is Asp.Versioning.Http’s IApiVersionParser/ApiVersionSet.EnableVersioning = true (the default) requires AddApiVersioning() on the service
collection; UseEndpointConfigs fails fast on that check before discovery runs, so it throws
even when zero IEndpointConfig implementations exist in the scanned assemblies.IEndpointConfig implementations are instantiated with Activator.CreateInstance, so each
one needs a public parameterless constructor — there is no DI for the config object itself.
Inject services into the endpoint handlers inside Map, not into the config.[FromClaim]-declared property with no setter throws InvalidOperationException the first
time its type is scanned — add a set or init.AddContextualRequestPopulation() throws InvalidOperationException at endpoint-build time
(startup), naming the offending type — it does not silently pass the caller’s value through.UseEndpointConfigs. A hand-written
app.MapPost(...) outside a discovered group never gets the filter, so a [FromClaim] property
there keeps whatever the caller sent.Guid string into a Guid property) silently becomes that type’s default — it never
rejects the request. Pair it with your own validator if a missing/unresolvable value must block
the request.[FromRequestHeader] member is not a required header. An absent header leaves the
member’s type default and the request is dispatched anyway — the mechanism never turns a missing
header into a 400. Add a filter or a validation rule if the header must be present.[FromRequestHeader] member is not an authorization signal. A claim comes from an
authenticated identity; a header comes from the caller, who can send any value. Treat the
declaration as binding convenience, never as proof of who is calling.IContextualValueResolver ahead of the built-in ones to supply a shared value instead,
every caller who omits the header gets that same value, which collapses a member used as an
idempotency key into one shared key across callers.[FromRequestHeader]
adds an in: header parameter to the operation (the member is still absent from the request
body), because the caller does have to send it; [FromClaim] is hidden entirely.orderBy is validated against the entity as well as the model, but the 400 message only
names the model. A field that exists on TModel and not on TEntity is rejected with
“no such field on TModel“, which reads as wrong until you check the entity.string property cannot match a search — the predicate matches nothing and
the endpoint answers with an empty page rather than an error. Search walks at most two property
hops (Name, Merchant.Name; not Merchant.Address.City).pageSize is silently clamped, not rejected. Asking for 5,000 rows returns
ListQueryOptions.MaxPageSize rows — 1,000 unless the host raised it — without any indication
that the request was trimmed.pageSize serves up
to DefaultPageSize = 1,000 rows, not 20. On a large table over a slow link that is a much heavier
response than before; lower DefaultPageSize (and, if you want a hard cap, MaxPageSize) on a
host where that matters.TotalItemCount with no field in the response saying a window was applied. It is always
overridable per request (fromDate/toDate) and switchable off per host
(DefaultActivityWindowMonths = 0).fromDate/toDate replace the default window, they do not intersect it. A caller who sends
only toDate gets an open-ended lower bound — the whole history up to that date — which is wider
than the bare request they started from.IAuditedProperties; the default ordering only to IAuditedEntity<TKey>. A type that
has the timestamps but not the entity interface is windowed without being reordered.MapDeleteById performs a hard delete and does no ownership or tenancy check of its own;
authorization is whatever the enclosing route group requires.ConfigureGroup runs, so it can never be defeated by a host filter’s registration order — but it
still executes after ASP.NET Core’s authorization middleware at request time.[EndpointGroupScope] checks coverage while endpoints are being built, so a gap surfaces
as app.StartAsync() throwing, never as a runtime 403 on the first call.[EndpointGroupScope("scope")], no HTTP methods named)
never hits that startup refusal. The default covers every method the group serves, including a
route with no IHttpMethodMetadata at all — the same route the refusal would otherwise name as
*. Only a group with no default, and a served method no per-method declaration covers, can be
refused.MapMethods(["GET", "POST"]) route (or any route naming more than one HTTP method)
still carries one IHttpMethodMetadata entry per method, and picks up one Authorize policy per
declared method it serves — ASP.NET Core requires every policy on an endpoint, so a caller
holding only one of the scopes is refused. Declare a multi-method route under a single scope
unless every caller is meant to hold all of them. The * placeholder in the startup refusal is
reserved for a route that carries no IHttpMethodMetadata at all — such a route serves every
method and can never be covered — never for a MapMethods route, which always names its methods
explicitly.[EndpointGroupScope] and RequireAuthorization = false (§EndpointRegistrationOptions) don’t
combine. Turning host authorization off makes the attribute inert rather than optional-but-checked
— no scope is enforced and no startup refusal fires, on any group, declared or not, default or
per-method.IEntity<TKey>/IAuditedEntity<TKey> contracts and the [CrudCreate]/[CrudUpdate]/
[CrudAction] markers the generated endpoints are built from.