Trellis.Asp — API Reference
Package: Trellis.Asp (bundles the AOT-friendly Trellis.AspSourceGenerator.dll at analyzers/dotnet/cs/ — installing Trellis.Asp attaches the generator automatically — and contains the ASP.NET actor providers formerly published as Trellis.Asp.Authorization).
Namespaces: Trellis.Asp, Trellis.Asp.Authorization, Trellis.Asp.Idempotency, Trellis.Asp.ModelBinding, Trellis.Asp.Routing, Trellis.Asp.Validation
Purpose: ASP.NET Core integration for parsing pagination query input; mapping Trellis Result/Result<T>/WriteOutcome<T>/Page<T> values and rate-limit middleware rejections to HTTP responses; evaluating HTTP preconditions and Prefer preferences; hydrating actors from JWT claims; validating scalar value objects in MVC and Minimal APIs; and emitting AOT-friendly JsonConverters for Trellis scalar values.
The single supported response verb is result.ToHttpResponse(...). It returns Microsoft.AspNetCore.Http.IResult and works in both Minimal API and MVC hosts (.NET 7+ executes IResult natively in MVC). For typed ActionResult<T> signatures, chain .AsActionResult<T>(). Configure protocol semantics via the fluent HttpResponseOptionsBuilder<T> (WithETag, WithLastModified, Vary, WithCacheControl, Created/CreatedAtRoute/CreatedAtAction, EvaluatePreconditions, HonorPrefer, WithErrorMapping, …).
See also: trellis-start-here.md — recipes using this package.
Use this file when
- You are wiring ASP.NET Core endpoints/controllers that return Trellis
Result,Result<T>,WriteOutcome<T>, orPage<T>. - You need to parse raw cursor/limit query parameters without losing the distinction between a missing cursor and a present empty cursor.
- You need the exact response-mapping verb, status-code behavior, Problem Details mapping, ETag / preference handling, actor-provider setup, scalar value-object binding, or route constraints.
- You are implementing API surface polish: failure response metadata, versioned
Locationheaders, or tests provingError.InvalidInputmaps to 422.
Patterns Index
| Goal | Canonical API / action | See |
|---|---|---|
| Enable Trellis Result-to-HTTP mapping | Call builder.Services.AddTrellisAsp() or services.AddTrellis(o => o.UseAsp()) in the composition root. Exception middleware is only a 500 fallback; it does not map Result failures. |
ServiceCollectionExtensions, ServiceDefaults |
| Return a Minimal API result | return result.ToHttpResponse(...) |
HttpResponseExtensions |
| Return an MVC typed action result | Convert first, then adapt: return result.ToHttpResponse(...).AsActionResult<T>() or return await result.ToHttpResponseAsync(...).AsActionResultAsync<T>() |
ActionResultAdapterExtensions |
| Configure 201 Created | .ToHttpResponse(o => o.Created(...)), .CreatedAtRoute(...), or .CreatedAtAction(...) |
HttpResponseOptionsBuilder<TDomain> |
Generate versioned Location headers |
Chain .WithVersionedRoute() from Trellis.Asp.ApiVersioning on CreatedAtRoute, CreatedAtAction, or WithLocation. It validates the final destination and writes its mapped query/segment version; neutral/unversioned destinations lose supplied api-version entries. |
HttpResponseOptionsBuilder<TDomain> |
| Map failure codes globally | Call TrellisAspOptions.MapError<TError>(statusCode) through AddTrellisAsp(...) |
TrellisAspOptions |
| Override failure mapping for one endpoint | .WithErrorMapping(...) / .WithErrorMapping<TError>(statusCode) |
HttpResponseOptionsBuilder<TDomain> |
| Document endpoint failure codes | Add ASP.NET response metadata for every spec-listed failure status (422, 409, 403, 404, etc.) in addition to happy-path metadata. |
Code examples |
| Add ETag / conditional GET | .WithETag(...), .WithLastModified(...), .EvaluatePreconditions() |
HttpResponseOptionsBuilder<TDomain>, ETagHelper |
Add Cache-Control directive (per endpoint) |
.WithCacheControl(CacheControl.NoStore()) / .WithCacheControl(CacheControl.Public(TimeSpan.FromMinutes(5))) / .WithCacheControl(t => …) |
HttpResponseOptionsBuilder<TDomain>, CacheControl |
Honor Prefer: return=minimal |
.HonorPrefer() on write responses |
HttpResponseOptionsBuilder<TDomain> |
| Parse pagination query input in MVC or Minimal APIs | Request.TryCreatePageRequest(); bind its Result<PageRequest> before dispatching the query |
HttpRequestPaginationExtensions |
| Return paginated list responses | Result<Page<T>>.ToHttpResponse(urlBuilder, bodySelector, ...) with (cursor, PageDirection, appliedLimit); the two-argument nextUrlBuilder convenience remains available |
PagedResponse<TResponse>, PageDirection |
| Build pagination links with or without API versioning | HttpContext.PageUrl(routeName, routeValues); configure the optional version-aware policy once when using Trellis.Asp.ApiVersioning |
HttpContextPaginationExtensions |
| Resolve actors from requests | AddClaimsActorProvider, AddNestedJsonPathClaimsActorProvider, AddEntraActorProvider, or AddDevelopmentActorProvider. For microservices consuming gateway-minted internal JWTs, see Trellis.Microservices.AspNetCore (the TrellisInternalJwtActorProvider types moved out of this repo in v3 cleanup). |
Trellis.Asp.Authorization |
| Compose a system actor for background workers | AddTrellisWorkerActor |
Trellis.Asp.Authorization |
| Bind scalar value objects from routes/query/body | AddTrellisAspWithScalarValidation() (or AddTrellisAsp() + AddScalarValueValidation()), plus route constraints / validation middleware as needed |
Trellis.Asp.ModelBinding, Trellis.Asp.Validation |
Add Trellis ProblemDetails recipe (trace id from Activity.Current, friendly 500 detail, allow extension on 405) |
services.AddTrellisProblemDetails() plus app.UseTrellisProblemDetails() (or options.UseProblemDetails() via Trellis.ServiceDefaults) |
ServiceCollectionExtensions, ApplicationBuilderExtensions |
| Render ASP.NET Core rate-limit rejections as Trellis Problem Details | Inside services.AddRateLimiter(options => ...), call options.UseTrellisRejectionHandler(); keep algorithms, policies, and partition keys in application configuration. |
RateLimiterOptionsExtensions |
Add the IETF Idempotency-Key middleware to opted-in POST / PATCH endpoints |
services.AddTrellisIdempotency(...) (or options.UseIdempotency(...)), services.AddInMemoryIdempotencyStore(), app.UseTrellisIdempotency(), and mark each opted-in endpoint with [Idempotent] |
Trellis.Asp.Idempotency, Cookbook Recipe 29 |
Endpoint checklist for generated APIs
- Composition root calls
AddTrellisAsp()orUseAsp(). - Every endpoint that returns a Trellis
Resultultimately callsToHttpResponse/AsActionResult. - Pagination endpoints parse raw query values with
Request.TryCreatePageRequest()when missing-vs-empty cursor semantics matter, and declarecursor/limitexplicitly in OpenAPI because the parser adds no endpoint metadata. - OpenAPI metadata includes the success code and every failure code listed by the product spec.
- Supply a usable
Locationfor a newly created resource when its URI differs from the request URL; a PUT that creates at the request URL can returnWriteOutcome.Created(value)without a Location. Prefer.WithVersionedRoute()fromTrellis.Asp.ApiVersioningon route/action locations to resolve the destination's mapped query/segment version. If using manual query values, include["api-version"]only for a versioned target and ensure it accepts that version. Test dereferencing emitted links, not just the response status. [Consumes("application/json")]is not safe at the controller level when the controller has trigger-style POSTs without bodies (e.g.,POST /orders/{id}/submission). ASP.NET Core returns415 Unsupported Media Typefor any request without aContent-Typeheader. Apply[Consumes]per-action on body-bearing endpoints only, or scope it to a route convention.- Integration tests include at least one business-validation failure that asserts
422Problem Details; do not rely on exception middleware to prove Result mapping.
Cross-package preflight for endpoint changes
| If the endpoint change includes... | Also read | Why |
|---|---|---|
| Sending commands or queries through Mediator | trellis-api-mediator.md |
ASP maps the response, but validation/authorization/logging/commit behavior belongs to the Mediator pipeline. |
| EF-backed writes | trellis-api-efcore.md, trellis-api-servicedefaults.md |
Handlers stage changes; TransactionalCommandBehavior commits only when registered in the correct order. |
| Actor resolution or authorization failures | trellis-api-authorization.md, trellis-api-mediator.md |
ASP provides actor providers; authorization contracts and behaviors live outside the response mapper. |
Integration tests or .http examples |
trellis-api-testing-aspnetcore.md |
Failure-path status/header expectations should be executable, not only documented in OpenAPI. |
Types
Namespace Trellis.Asp
HttpResponseExtensions
Declaration
public static class HttpResponseExtensions
The single Trellis verb for converting Result / Result<T> / Result<WriteOutcome<T>> / Result<Page<T>> to ASP.NET Core HTTP responses.
| Signature | Returns | Description |
|---|---|---|
public static IResult ToHttpResponse(this Error error, Action<HttpResponseOptionsBuilder>? configure = null) |
IResult |
Maps a standalone Error to a Problem Details response (for endpoints that produce a deterministic error). |
public static IResult ToHttpResponse<T>(this Result<T> result, Action<HttpResponseOptionsBuilder<T>>? configure = null) |
IResult |
Maps Result<T> to 200 OK with the value as body, or 201 Created + Location when Created / CreatedAtRoute / CreatedAtAction is configured. For Result<Unit> (the no-payload result returned by Result.Ok() / Result.Fail(error)), success emits 204 No Content. Failures go through Problem Details. |
public static IResult ToHttpResponse<TDomain, TBody>(this Result<TDomain> result, Func<TDomain, TBody> body, Action<HttpResponseOptionsBuilder<TDomain>>? configure = null) |
IResult |
Same as the Result<T> overload, but projects the response body via body. Selectors in the options builder still run against the domain value. |
public static IResult ToHttpResponse<T>(this Result<WriteOutcome<T>> result, Action<HttpResponseOptionsBuilder<T>>? configure = null) |
IResult |
Maps Result<WriteOutcome<T>> per RFC 9110: Created → 201 with optional Location (nonblank outcome Location wins; otherwise use the builder fallback, or omit the header), Updated → 200 (or 204 with Prefer: return=minimal when HonorPrefer() is configured), UpdatedNoContent → 204, Accepted → 202 (+ Retry-After when RetryAfter != null, + Location when MonitorUri != null), AcceptedNoContent → 202 (+ Retry-After/Location under the same conditions). |
public static IResult ToHttpResponse<TDomain, TBody>(this Result<WriteOutcome<TDomain>> result, Func<TDomain, TBody> body, Action<HttpResponseOptionsBuilder<TDomain>>? configure = null) |
IResult |
WriteOutcome overload with body projection. |
public static IResult ToHttpResponse<T, TBody>(this Result<Page<T>> result, Func<Cursor, int, string> nextUrlBuilder, Func<T, TBody> body, Action<HttpResponseOptionsBuilder<Page<T>>>? configure = null) |
IResult |
Maps Result<Page<T>> to a paginated JSON envelope (PagedResponse<TBody>) plus an RFC 8288 Link header. Convenience overload: the same nextUrlBuilder(cursor, appliedLimit) callback builds both next and previous links, without direction information. |
public static IResult ToHttpResponse<T, TBody>(this Result<Page<T>> result, Func<Cursor, PageDirection, int, string> urlBuilder, Func<T, TBody> body, Action<HttpResponseOptionsBuilder<Page<T>>>? configure = null) |
IResult |
Direction-aware pagination. Calls urlBuilder(cursor, PageDirection.Next, appliedLimit) for Next and urlBuilder(cursor, PageDirection.Previous, appliedLimit) for Previous, only when the corresponding cursor exists. Each generated URL is shared by the envelope and Link header. |
Each overload above except Error.ToHttpResponse(...) also exposes async variants named ToHttpResponseAsync. The signatures are not identical: the receiver is wrapped, so this Result<T> becomes this Task<Result<T>> or this ValueTask<Result<T>>. The remaining parameters are unchanged. Error has no async variant, because an Error is already a materialised value with nothing to await.
Direction-aware asynchronous pagination signatures:
public static Task<IResult> ToHttpResponseAsync<T, TBody>(
this Task<Result<Page<T>>> resultTask,
Func<Cursor, PageDirection, int, string> urlBuilder,
Func<T, TBody> body,
Action<HttpResponseOptionsBuilder<Page<T>>>? configure = null);
public static ValueTask<IResult> ToHttpResponseAsync<T, TBody>(
this ValueTask<Result<Page<T>>> resultTask,
Func<Cursor, PageDirection, int, string> urlBuilder,
Func<T, TBody> body,
Action<HttpResponseOptionsBuilder<Page<T>>>? configure = null);
HttpContextPaginationExtensions
HttpContext.PageUrl builds absolute named-route pagination links without a versioning
dependency. The two signatures return Func<Cursor, int, string> and
Func<Cursor, PageDirection, int, string> respectively:
public static Func<Cursor, int, string> PageUrl(this HttpContext httpContext, string routeName,
Func<Cursor, int, RouteValueDictionary> routeValues,
Func<PageUrlRouteContext, Endpoint>? routeResolver = null);
public static Func<Cursor, PageDirection, int, string> PageUrl(this HttpContext httpContext, string routeName,
Func<Cursor, PageDirection, int, RouteValueDictionary> routeValues,
Func<PageUrlRouteContext, Endpoint>? routeResolver = null);
PageUrlRouteContext exposes HttpContext, RouteName, Candidates, and the cloned,
mutable RouteValues. TrellisAspOptions.PageUrlRouteResolver configures a host-local
destination/version policy through AddTrellisAsp or UseAsp; a per-builder resolver wins.
Resolvers must return one candidate or the link-enabled active endpoint with the same
name, never null. Unversioned hosts need no resolver. Versioned hosts must configure the
optional package's version-aware policy. Missing destinations, incompatible layouts,
ambiguous cross-route destinations, invalid resolver results, null callback dictionaries,
and failed link generation throw. Links preserve scheme, host, and PathBase; shared
callback dictionaries are never mutated. Builders are request-scoped.
Endpoint discovery is cached by EndpointDataSource instance, using weak keys so retired
sources are not kept alive. Link-enabled named endpoints are indexed once per stable
change-token generation, and compatible route groups are validated on first use.
Subsequent links reuse the immutable candidates without rescanning endpoints or
rechecking compatibility. A signaled change invalidates the index and validated groups;
a change during indexing causes a retry before publication. Custom data sources must
signal their change token when endpoints or URL-generation metadata change.
Previously, candidate discovery reread Endpoints for each link and could observe
unsignaled mutations. Cached discovery intentionally does not support those mutations;
custom sources must publish a new change-token generation instead.
Callbacks, cloned route values, active-endpoint selection, and host/per-builder policies
still run for every link; requested versions and selected destinations are not cached.
Shared names require matching route templates, defaults, required values, and parameter
policies so named link generation cannot fall through to a differently constrained
destination. Only MVC selector keys controller, action, and area may differ when
they are not template parameters and each endpoint has a matching default/required value
for that key. Differently named attribute-routed controllers can therefore share a
pagination name; conventional {controller}/{action} differences still throw.
Other non-template defaults remain checked: a cursor default can suppress an explicit
query value and break continuation. Inline policies compare by content; out-of-line
policy objects must compare equal (normally the same instance). Otherwise give the
destinations distinct route names.
// Same expression in unversioned and versioned endpoints.
nextUrlBuilder: HttpContext.PageUrl(
"Orders_List",
(cursor, applied) => new RouteValueDictionary
{
["cursor"] = cursor.Token,
["limit"] = applied,
})
For API versioning, reference the optional package and configure
UseVersionedPageUrls
through AddTrellisAsp or UseAsp. Without a policy, this helper performs unversioned
routing; it does not inspect optional SDK metadata or infer versioning configuration.
Versioned hosts must enable the policy even for self-pagination. Typed ApiVersion pins
remain extensions in the optional package and override the host policy per builder.
HttpRequestPaginationExtensions
Declaration
public static class HttpRequestPaginationExtensions
| Signature | Returns | Description |
|---|---|---|
public static Result<PageRequest> TryCreatePageRequest(this HttpRequest request, string cursorParameter = "cursor", string limitParameter = "limit", int max = PageSize.Max, int defaultSize = PageSize.Default, PageSizeLimitPolicy policy = PageSizeLimitPolicy.Clamp) |
Result<PageRequest> |
Reads one raw cursor and limit value from HttpRequest.Query, parses the limit invariantly, and delegates page-size/cursor validation to PageRequest.TryCreate. Failures identify the configured query parameter with InputPointer.ForQuery. |
TryCreatePageRequest is the shared MVC/Minimal API boundary for cursor pagination. It uses
IQueryCollection.TryGetValue and indexes the single raw value; it never calls
StringValues.ToString(), which would join repeated values with commas and erase cardinality.
| Raw query input | Result |
|---|---|
| Cursor parameter absent | PageRequest.Cursor is null (first page). |
| Cursor present but empty/whitespace | cursor.malformed, located at the cursor query parameter. |
| Cursor repeated, even with identical values | cursor.malformed; each parameter may occur at most once. |
| Limit absent | defaultSize is used. |
Limit empty/whitespace, malformed, outside Int32, or repeated |
format.integer, located at the limit query parameter. |
Limit parsed but non-positive, or above max under Reject |
page-size.out-of-range, relocated from Core to the limit query parameter. |
Limit above max under Clamp |
Success; PageSize.Requested preserves the supplied value and Applied is capped. |
Limit cardinality, parsing, and page-size validation run before cursor validation, preserving
PageRequest.TryCreate's size-first failure precedence. Parsing uses
NumberStyles.Integer with CultureInfo.InvariantCulture. Custom parameter names are
case-insensitively distinct and are treated as literal query-name tokens, so / and ~ are
escaped correctly in the underlying RFC 6901 path.
Invalid max, defaultSize, or policy values remain programmer/configuration errors and throw
the same ArgumentOutOfRangeException as Core. Client failures remain Result values; passing
them through ToHttpResponse() produces the standard 422 Problem Details in both hosting models.
For custom limit names, the query pointer and Core-generated range detail both use the configured
name.
PageRequest remains transport-neutral after parsing. A nonblank opaque cursor can therefore
pass this parser and fail later when Decode / ToPageAsync validates its encoding. On an
endpoint whose remaining inputs all come from the query string, chain
.WithInputOrigin(InputLocation.Query) on a Minimal API endpoint or apply
[InputOrigin(InputLocation.Query)] to an MVC action. That promotes an otherwise-unlocated
downstream /cursor failure to the same query location while preserving parser failures that are
already explicitly located.
Warning
TryCreatePageRequest is a parser, not a model binder or endpoint-metadata provider. It does
not add cursor or limit to ApiExplorer/OpenAPI. Declare those parameters in the endpoint's
OpenAPI metadata separately when they are part of the public contract; do not add bound
string? cursor / int? limit handler parameters merely for documentation, because host
binding can answer malformed input before Trellis or normalize ?cursor= to null.
HttpResponseOptionsBuilder<TDomain>
Declaration
public sealed class HttpResponseOptionsBuilder<TDomain>
Fluent options builder used by every generic ToHttpResponse overload. Selectors run against the TDomain value (not the projected response body). All methods return this for chaining.
| Signature | Returns | Description |
|---|---|---|
WithETag(Func<TDomain, string> selector) |
HttpResponseOptionsBuilder<TDomain> |
Sets a strong ETag (wraps the string in EntityTagValue.Strong). |
WithETag(Func<TDomain, EntityTagValue> selector) |
HttpResponseOptionsBuilder<TDomain> |
Sets a strong or weak ETag from a caller-built EntityTagValue. |
WithLastModified(Func<TDomain, DateTimeOffset> selector) |
HttpResponseOptionsBuilder<TDomain> |
Emits Last-Modified header in RFC 1123 format. |
Vary(params string[] headers) |
HttpResponseOptionsBuilder<TDomain> |
Appends headers to the response Vary header (existing values preserved; duplicates suppressed). |
VaryForActor() |
HttpResponseOptionsBuilder<TDomain> |
Appends the request header(s) that contribute to actor identity for the registered IActorProvider to the response Vary header and returns the builder; See Behavioral notes: VaryForActor below. |
WithContentLanguage(params string[] languages) |
HttpResponseOptionsBuilder<TDomain> |
Joins values into Content-Language. |
WithContentLocation(Func<TDomain, string> selector) |
HttpResponseOptionsBuilder<TDomain> |
Sets the Content-Location header. |
WithLink(string rel, string href) |
HttpResponseOptionsBuilder<TDomain> |
Adds an RFC 8288 Link relation to the response. Call repeatedly to advertise several relations; they are emitted as one Link field and are additive to the next / prev links on a paginated response rather than replacing them. Success path only — like Vary and Content-Language (and unlike static WithCacheControl), configured links are not emitted on failure responses. Covers plain success, the no-payload 204, paged success, and WriteOutcome. See Behavioral notes: link relations below for the accepted relation types and for why "schema", though accepted, is a poor choice. Throws ArgumentNullException on a null rel / href, and ArgumentException when rel is neither a valid relation token nor an absolute URI, or when href is blank. |
WithCacheControl(CacheControlHeaderValue value) |
HttpResponseOptionsBuilder<TDomain> |
Sets the Cache-Control response header to the supplied directive. Applies to success responses (200 / 201 / 204 / 304), WriteOutcome (Created / Updated / Accepted), paged responses, AND failure responses — so WithCacheControl(CacheControl.NoStore()) protects 404 / 403 / 412 / 422 from intermediate-cache leakage just as much as the 200. Throws ArgumentNullException on null. Use the CacheControl presets (NoStore(), NoCache(), Public(TimeSpan), Private(TimeSpan), Immutable(TimeSpan)) for common shapes; each call returns a fresh CacheControlHeaderValue so mutation cannot leak across responses. |
WithCacheControl(Func<TDomain, CacheControlHeaderValue?> selector) |
HttpResponseOptionsBuilder<TDomain> |
Sets Cache-Control from a selector run against the success-path domain value. Applies to the success path only (failures carry no domain value). Returning null from the selector omits the header on that response (falls back to the static-value overload if both are configured; otherwise no header is emitted). |
Created(string locationLiteral) |
HttpResponseOptionsBuilder<TDomain> |
Returns 201 Created with a literal Location header. |
Created(Func<TDomain, string> selector) |
HttpResponseOptionsBuilder<TDomain> |
Returns 201 Created with a Location derived from the value. |
CreatedAtRoute(string routeName, Func<TDomain, RouteValueDictionary> routeValues) |
HttpResponseOptionsBuilder<TDomain> |
Returns 201 Created with a relative Location path generated via LinkGenerator.GetPathByName (resolved from HttpContext.RequestServices at execute time). AOT-safe. For API versioning, prefer .WithVersionedRoute() from Trellis.Asp.ApiVersioning: it resolves the destination and supplies its mapped query/segment version. Manual query-style links to versioned targets need an accepted api-version value; neutral/unversioned targets do not. TRLS023 warns on bare CreatedAtRoute / CreatedAtAction / WithLocation calls inside [ApiVersion] controllers and offers a code fix that appends .WithVersionedRoute(). |
CreatedAtRoute(string routeName, Func<TDomain, object> idSelector, string idRouteKey = "id") |
HttpResponseOptionsBuilder<TDomain> |
Convenience overload for the common single-id route. Constructs a RouteValueDictionary with [idRouteKey] = idSelector(value) and chains the multi-key overload. |
WithLocation(string routeName, Func<TDomain, RouteValueDictionary> routeValues) |
HttpResponseOptionsBuilder<TDomain> |
Adds a relative Location via LinkGenerator.GetPathByName without changing the status (typically 200 for Result<T>). Also supplies a fallback for WriteOutcome.Created when its Location is null, empty, or whitespace; that outcome remains 201. Nonblank outcome locations win, and other outcome variants are unchanged. Chain .WithVersionedRoute() from Trellis.Asp.ApiVersioning for versioned destinations. |
WithLocation(string routeName, Func<TDomain, object> idSelector, string idRouteKey = "id") |
HttpResponseOptionsBuilder<TDomain> |
Single-id convenience overload for WithLocation. |
[RequiresUnreferencedCode] [RequiresDynamicCode] CreatedAtAction(string actionName, Func<TDomain, RouteValueDictionary> routeValues, string? controllerName = null) |
HttpResponseOptionsBuilder<TDomain> |
MVC equivalent of CreatedAtAction — emits a relative Location path via LinkGenerator.GetPathByAction. Not trim/AOT-safe; use CreatedAtRoute for AOT scenarios. Under query/header API versioning, it has the same api-version route-value requirement as CreatedAtRoute; chain .WithVersionedRoute() from Trellis.Asp.ApiVersioning to inject it automatically. |
WithRouteValueResolver(string key, Func<HttpContext, string?> resolver) |
HttpResponseOptionsBuilder<TDomain> |
Registers a per-request route-value callback after the domain routeValues selector and before WithLocationRouteResolver. Returning null skips injection and preserves an existing entry. Useful for tenant id, request culture, etc. Writes do not mutate a shared selector dictionary. All these callbacks run before the destination-aware callback, regardless of fluent configuration order. |
WithLocationRouteResolver(Action<LocationRouteContext> resolver) |
HttpResponseOptionsBuilder<TDomain> |
Registers one destination-aware callback at Location generation, after the domain selector and all WithRouteValueResolver callbacks. Receives the final named-route or MVC-action destination and a mutable per-execution copy of its route values; configuration order does not change the destination observed. Last registration wins; callback exceptions propagate. Underlies Trellis.Asp.ApiVersioning.WithVersionedRoute(...). Literal/selector Created(...) locations and WriteOutcome-owned URIs ignore this hook. |
EvaluatePreconditions() |
HttpResponseOptionsBuilder<TDomain> |
On GET/HEAD, evaluates RFC 9110 conditional headers (If-Match, If-Unmodified-Since, If-None-Match, If-Modified-Since) using the configured ETag/LastModified selectors and writes 304 Not Modified or 412 Precondition Failed accordingly. This hook runs only on GET/HEAD — it is not a mutation guard. On unsafe methods (PUT/PATCH/DELETE) it does nothing, and the precondition must be evaluated before the mutation by your own handler; see Recipe 23 for that pattern. |
HonorPrefer() |
HttpResponseOptionsBuilder<TDomain> |
Opt in to RFC 7240 Prefer: return=minimal / return=representation handling on WriteOutcome.Updated. When not called, the Prefer request header is completely ignored: the writer never emits Vary: Prefer or Preference-Applied, and return=minimal does not short-circuit the body. When called, always emits Vary: Prefer; emits Preference-Applied only when an honored preference was sent. |
WithErrorMapping(Func<Error, int> mapper) |
HttpResponseOptionsBuilder<TDomain> |
Per-call mapper for failure responses. Highest precedence. Return a value outside 100–599 (e.g. default) to decline an error and fall through to the rest of the precedence chain. |
WithErrorMapping<TError>(int statusCode) where TError : Error |
HttpResponseOptionsBuilder<TDomain> |
Per-call override for a single error type. Higher precedence than TrellisAspOptions. Throws ArgumentOutOfRangeException when statusCode is outside 100–599. |
LocationRouteContext
public sealed class LocationRouteContext
Namespace: Trellis.Asp. Constructed internally by the response builder; there is no public constructor. Passed to WithLocationRouteResolver for each builder-generated Location execution.
| Get-only property | Type | Meaning |
|---|---|---|
HttpContext |
HttpContext |
The executing HTTP request context. |
RouteName |
string? |
Final named-route destination; null for action destinations. |
ActionName |
string? |
Final MVC action name; null for named routes. |
ControllerName |
string? |
Explicit controller name, or null to use route values then the ambient current controller. Always null for named routes. |
RouteValues |
RouteValueDictionary |
Mutable per-execution copy after the domain selector and all legacy callbacks. Add, overwrite, or remove entries without changing the selector's shared dictionary. |
Only one destination-aware callback is stored. Repeated WithLocationRouteResolver calls replace it; WithVersionedRoute() and WithVersionedRoute(ApiVersion) use that same slot, so the last registration wins across those methods too. Configure a custom callback deliberately rather than assuming it composes with version injection.
The hook runs for route/action fallbacks on WriteOutcome.Created as well as ordinary
Result<T> locations. Outcome status is authoritative, so even WithLocation retains 201
on Created. It does not rewrite literal/selector Created(...) locations, nonblank outcome
locations, or Accepted monitor URIs. Named routes remain AOT-compatible; CreatedAtAction
retains [RequiresUnreferencedCode] / [RequiresDynamicCode]. No new registration or builder
slot is needed.
For destination resolution, mapped-version validation, segment parameters, and migration behavior of WithVersionedRoute, see Trellis.Asp.ApiVersioning.
Behavioral notes: VaryForActor
The provider must implement IProvideActorVaryHeaders; the bundled ClaimsActorProvider returns ["Authorization"], DevelopmentActorProvider returns [X-Test-Actor], and CachingActorProvider delegates to its inner provider. VaryForActor() throws InvalidOperationException at apply time when no provider is registered, the registered provider does not implement the capability, or the implementation returns an empty collection, failing closed against silent cache-poisoning across actors. Decorating providers such as CachingActorProvider are unwrapped via the internal IDecoratingActorProvider so diagnostics name the underlying provider that needs the implementation. The behavior applies uniformly to success, failure, WriteOutcome, and paginated paths.
Byte ranges are not in Trellis's scope. Trellis does not expose
WithRange/WithAcceptRanges. For binary downloads with RFC 9110 §14 byte-range semantics, callMicrosoft.AspNetCore.Http.Results.File(stream, enableRangeProcessing: true)from ASP.NET Core directly; for custom advisory headers likeAccept-Ranges: none, write the header onHttpContext.Response.Headersfrom middleware or the endpoint handler. The client-side typed-error vocabulary still surfaces inbound 416 asError.TransportFault(new HttpError.RangeNotSatisfiable(...))with the upstreamContent-Rangecompanion header preserved.
Behavioral notes: link relations
WithLink(rel, href) validates the relation at configuration time, so a malformed relation throws when the endpoint is wired rather than on every request. RFC 8288 §3.3 admits exactly two forms, and WithLink accepts both:
- a registered relation token — ASCII letters, digits,
.and-, starting with a letter (for exampledescribedby,service-desc). Registered tokens are case-insensitive per RFC 8288 §2.1 and are lowercased on the wire, soWithLink("DescribedBy", …)emitsrel="describedby". - an extension relation URI — any absolute URI, emitted verbatim (URI paths are case-sensitive, so it is not lowercased).
Only the shape is validated. Trellis does not check the token against the IANA link-relation registry, because that registry changes independently of the framework — an allow-list would reject a newly registered relation until Trellis shipped again. So WithLink("schema", …) succeeds and emits rel="schema", and it is still the wrong choice: schema is not registered, so RFC 8288 gives it no agreed meaning and generic clients ignore it. Picking a relation clients will actually understand is your decision; the table below covers the registered options.
On a paginated response, configured links are emitted as a second Link field line rather than being merged into the pagination one. RFC 9110 §5.3 makes repeated field lines of a list-typed header equivalent to a single comma-joined line, so this is on the wire what a client should already handle — but it means a client must read every value. Taking only the first, or calling something like response.Headers.GetValues("Link").Single(), silently drops half the relations (and Single() throws outright). The Examples/Showcase accounts endpoints demonstrate the two-line shape end to end.
Advertise only what you serve. A relation is a promise a client can follow, so pointing service-desc at a description document that is not mapped in the current environment is worse than emitting no link — the client gets a 404 instead of an absence it could have handled. There are two ways to keep that promise, and the simpler one is usually right: serve the document unconditionally, rather than making the advertisement conditional on the environment. The showcase maps its OpenAPI description in every environment for exactly this reason (keeping only the interactive UI development-only), and its executable api.http dereferences the advertised URL so a dangling link fails the build.
"schema" is not a registered link relation. It does not appear in the IANA link-relation registry, and a bare unregistered token is not a conformant relation type — generic clients ignore it. Use the registered spellings instead:
| Goal | Relation | Reference |
|---|---|---|
| Point at a schema describing this resource | describedby |
W3C POWDER (Protocol for Web Description Resources) |
| Point at an API description document (OpenAPI, etc.) | service-desc |
RFC 8631 |
If neither fits, mint your own extension relation as an absolute URI (WithLink("https://example.com/rels/my-rel", …)).
Rejecting the relation is a security boundary, not only a conformance one: the relation is emitted inside a quoted string, so an unvalidated relation containing a double quote would close it early and append attacker-chosen link-params. That is a distinct surface from the link target, which is separately percent-encoded for the US-ASCII characters RFC 3986 §2 excludes — controls and space (<= U+0020), DEL (U+007F), and the "delims"/"unwise" set < > " \ ^ ` { } |. Non-ASCII is deliberately left alone, because an IRI-style href is already the caller's encoding decision and rewriting it would corrupt legitimately percent-encoded UTF-8. This is the same encoding already applied to pagination cursors.
Trellis does not generate schema documents, and does not map an OPTIONS endpoint; href is whatever URL your application serves the document from.
WithLink is deliberately not offered on the non-generic HttpResponseOptionsBuilder. That builder is consumed only by Error.ToHttpResponse(...), which produces a pure failure response, and configured links are success-path headers — so the overload could never emit anything.
HttpResponseOptionsBuilder
Declaration
public sealed class HttpResponseOptionsBuilder
Non-generic builder consumed only by Error.ToHttpResponse(this Error error, Action<HttpResponseOptionsBuilder>?) — used to shape the ProblemDetails response for a standalone Error.
| Signature | Returns | Description |
|---|---|---|
Vary(params string[] headers) |
HttpResponseOptionsBuilder |
Appends headers to the standalone error response's Vary, preserving existing values and suppressing duplicates case-insensitively. |
VaryForActor() |
HttpResponseOptionsBuilder |
Same contract as the generic builder's VaryForActor(). Applied by Error.ToHttpResponse(...) (the only consumer of the non-generic builder), so standalone error responses emitted via this verb partition by actor too. |
WithCacheControl(CacheControlHeaderValue value) |
HttpResponseOptionsBuilder |
Same contract as the generic builder's static-value overload. The non-generic builder is consumed only by Error.ToHttpResponse(...), so this overload sets Cache-Control on the ProblemDetails failure response — useful for Error.ToHttpResponse(o => o.WithCacheControl(CacheControl.NoStore())) to keep deterministic-error responses out of intermediate caches. |
WithErrorMapping(Func<Error, int> mapper) |
HttpResponseOptionsBuilder |
Per-call mapper for failure responses. Return a value outside 100–599 (e.g. default) to decline an error and fall through to the rest of the precedence chain. |
WithErrorMapping<TError>(int statusCode) where TError : Error |
HttpResponseOptionsBuilder |
Per-call override for a single error type. Throws ArgumentOutOfRangeException when statusCode is outside 100–599. |
Breaking change: the non-generic error-only builder no longer exposes HonorPrefer(), which previously had no effect. Remove that call from Error.ToHttpResponse(...); use Vary("Prefer") only if your application actually varies an error representation by that header. HttpResponseOptionsBuilder<TDomain>.HonorPrefer() remains available for success responses, including Result<Unit>.
ActionResultAdapterExtensions
Declaration
public static class ActionResultAdapterExtensions
MVC adapter that wraps an IResult in an ActionResult<T> so MVC controllers can declare typed return signatures (e.g. Task<ActionResult<TodoResponse>>) for OpenAPI/ApiExplorer inference and [ProducesResponseType<T>] compatibility. Implementation forwards ActionResult.ExecuteResultAsync to IResult.ExecuteAsync(HttpContext) via an internal TrellisActionResult<T> (which also implements IConvertToActionResult).
| Signature | Returns | Description |
|---|---|---|
public static ActionResult<T> AsActionResult<T>(this IResult result) |
ActionResult<T> |
Wraps an IResult in a typed ActionResult<T>. |
public static Task<ActionResult<T>> AsActionResultAsync<T>(this Task<IResult> resultTask) |
Task<ActionResult<T>> |
Async Task overload. |
public static ValueTask<ActionResult<T>> AsActionResultAsync<T>(this ValueTask<IResult> resultTask) |
ValueTask<ActionResult<T>> |
Async ValueTask overload. |
TrellisAspOptions
Declaration
public sealed class TrellisAspOptions
Configuration registered via AddTrellisAsp(...) that maps domain Error types to HTTP status codes.
| Name | Type | Description |
|---|---|---|
SystemDefault |
static TrellisAspOptions (internal) |
Read-only default instance used when DI cannot resolve a configured TrellisAspOptions (e.g. the host did not call AddTrellisAsp). Internal — not callable from user code. Hosts customize the mappings by passing a configure delegate to AddTrellisAsp(o => o.MapError<...>(...)); raw AddSingleton(new TrellisAspOptions()) is unsupported and will be replaced by the bridge factory the next time AddTrellisAsp runs. |
PageUrlRouteResolver |
Func<PageUrlRouteContext, Endpoint>? |
Host-local pagination policy. Selects a candidate or the matching link-enabled active endpoint and may enrich cloned route values. Null means unversioned named routing. Per-builder policies override it. Configure through AddTrellisAsp / UseAsp; the optional versioning package supplies UseVersionedPageUrls. |
FailFastOnSilentVersionInjection |
bool |
When true, every .WithVersionedRoute() (or pinned overload) call that would silently skip api-version injection because the target endpoint has no ApiVersionMetadata throws InvalidOperationException instead of logging a single warning per endpoint. Defaults to false (warn-once-per-(endpoint, AppDomain) via the Trellis.Asp.ApiVersioning ILogger category). Intended for non-Production environments to surface mid-migration regressions where AddApiVersioning(...) was removed but .WithVersionedRoute() chains remain. |
ProblemContentLanguage |
string? |
The language tag emitted as Content-Language on problem responses, or null (the default) to emit none. Every problem response ships prose in title and detail; setting this declares what language that prose is in. The default is deliberately unset because detail is frequently application-supplied, so the framework cannot know its language and would otherwise assert something it has not checked. This is a single static value, not server-side negotiation: nothing reads Accept-Language, and no Vary header is emitted, because the response genuinely does not vary by it. It composes with rather than competes against reason codes and args — detail is negotiated prose where negotiation exists, while codes and args let the client hold the catalog, which is the only option when the server has no translations at all. |
SynthesizeProblemDetailsInstanceFromResourceRef |
bool |
When true (the default), ResponseFailureWriter populates ProblemDetails.Instance from the failing ResourceRef (/{collectionName}/{id}) when the request URL does not already identify the resource, and preserves the original request URL under Extensions["request"]. Applies to NotFound, Gone, Conflict, Forbidden, InvariantViolation, and TransportFault(HttpError.PreconditionFailed). Set to false to retain the historical request-URL-only Instance. Collection name defaults to {Type.ToLowerInvariant()}s; override via [ResourceCollectionName(name)] on the aggregate or services.AddResourceCollectionName<T>(name). |
| Signature | Returns | Description |
|---|---|---|
public TrellisAspOptions MapError<TError>(int statusCode) where TError : Error |
TrellisAspOptions |
Overrides or adds an error-type-to-status-code mapping. Throws ArgumentOutOfRangeException when statusCode is outside 100–599. |
internal int GetStatusCode(Error error) |
int |
Walks the error type hierarchy looking for a mapping; falls back to 500. Invoked by the response writer. |
Default mappings: Error.InvalidInput=422, Error.InvariantViolation=422, Error.AuthenticationRequired=401, Error.Forbidden=403, Error.NotFound=404, Error.Conflict=409, Error.Gone=410, Error.RateLimited=429, Error.Unexpected=500, and Error.Unavailable=503. Error.Unexpected { Code: FaultCodes.NotImplemented } is special-cased to 501. Error.TransportFault unwraps HttpError.MethodNotAllowed, HttpError.NotAcceptable, HttpError.PreconditionFailed, HttpError.ContentTooLarge, HttpError.UnsupportedMediaType, HttpError.RangeNotSatisfiable, and HttpError.PreconditionRequired to 405/406/412/413/415/416/428. Explicit MapError<Error.TransportFault>(...) overrides all wrapped transport faults at once.
The Error.InvalidInput mapping also governs binder- and JSON-body value-validation failures (ScalarValueValidationMiddleware, ScalarValueValidationFilter, and ScalarValueValidationEndpointFilter), so a single MapError<Error.InvalidInput>(status) applies uniformly to scalar/value-object validation at the route/query binder, the JSON request body, and domain handlers (default 422). Syntactically malformed JSON is exempt — it stays 400 per RFC 9110 §15.5.1.
JSON-body value-object validation reports an index-precise field key for a value object nested inside a collection, string-keyed dictionary, or another object — e.g. members[0].email (RFC 6901 pointer /members/0/email) rather than the bare leaf email — at parity with the FluentValidation integration. A direct scalar value-object element or dictionary value targets the entry itself: allergens[0] (/allergens/0) or prices["USD"] (/prices/USD), without a synthetic type-name leaf such as /allergens/0/allergen. This applies to invalid values and null entries in both the reflection-mode pipeline and Native AOT (List<T>, arrays, collection interfaces, Dictionary<string, T> and its supported interfaces, and nested objects whose graph transitively contains a value object).
Under Native AOT the runtime cannot construct the closed generic path-tracking converters itself (Type.MakeGenericType is unavailable), so the closed generics are produced at compile time instead. PathTrackingRegistryGenerator walks the DTO graph reachable from every [JsonSerializable] root on your JsonSerializerContext, applying the same scalar-property and container rules the reflection-mode modifier applies, and emits a [ModuleInitializer] that populates ScalarValuePathTracking. The runtime modifier then resolves the converter with a dictionary lookup rather than reflection. Scalar properties use the effective JSON name from JsonTypeInfo — including naming policies and [JsonPropertyName] aliases — rather than falling back to the scalar type name. An explicit property-level [JsonConverter] remains authoritative in both modes: Trellis does not replace it with a validation/path-tracking wrapper, so the consumer's custom wire contract is preserved. No opt-in attribute or configuration is required: the generator ships inside the Trellis.Asp package itself (under analyzers/dotnet/cs/), so a package reference is sufficient.
Domain → HTTP boundary mapping
Trellis.Core.Error is transport-neutral. The ASP boundary translates domain failures to HTTP per the table below.
| Domain case | Status | Wire kind extension slug |
Headers |
|---|---|---|---|
InvalidInput |
422 | unprocessable-content |
— |
InvariantViolation |
422 | unprocessable-content |
— |
NotFound |
404 | not-found |
— |
Forbidden |
403 | forbidden |
— |
Conflict (Code==FaultCodes.ConcurrentModification AND request had If-Match) |
412 | precondition-failed |
— |
Conflict (otherwise) |
409 | conflict |
— |
Gone |
410 | gone |
— |
AuthenticationRequired |
401 | unauthorized |
WWW-Authenticate from Scheme or IAuthenticationSchemeProvider |
Unavailable |
503 | service-unavailable |
Retry-After from RetryAdvice |
RateLimited |
429 | too-many-requests |
Retry-After from RetryAdvice |
Unexpected (default) |
500 | internal-server-error |
faultId extension when set |
Unexpected (Code==FaultCodes.NotImplemented) |
501 | not-implemented |
— |
Aggregate |
worst-status of children | multi |
none — see below |
TransportFault |
per inner HttpError (405/406/412/413/415/416/428) |
inner wire kind | inner-specific |
Important
Companion headers are emitted for the root error only. Error.Aggregate matches none of the header-emitting cases, so an aggregate whose children include a RateLimited, Unavailable, MethodNotAllowed or RangeNotSatisfiable error emits no Retry-After, Allow or Content-Range. The children still render into the problems extension; only their headers are dropped. If a header matters to the caller, return that error as the root rather than nesting it in an aggregate.
The wire token shown above is emitted as the top-level Problem Details extension member kind (populated via ProblemDetails.Extensions["kind"], which ASP.NET Core serializes alongside type, title, status, detail, and instance — RFC 9457 §3.2). The top-level Problem Details type field continues to default to the ASP.NET status-code URL (e.g. https://tools.ietf.org/html/rfc4918#section-11.2 for 422); the top-level kind member is the durable identifier consumers should key on. Domain Kind and wire kind are intentionally distinct for InvalidInput and InvariantViolation: the domain slugs remain invalid-input / invariant-violation, while the on-wire kind stays unprocessable-content for backward compatibility.
Header synthesis
Retry-Afteris synthesized fromRetryAdviceonError.RateLimitedandError.Unavailable.WWW-Authenticatecomes fromError.AuthenticationRequired.Schemewhen set; otherwise the writer asksIAuthenticationSchemeProviderfor the default challenge/authenticate scheme and emits that scheme name.Allowcomes fromError.TransportFault(new HttpError.MethodNotAllowed(...)).Content-Rangecomes fromError.TransportFault(new HttpError.RangeNotSatisfiable(...)).
Wire code and the error.unspecified sentinel
Error.Code reaches the wire verbatim. There is no projection, no filtering, and no second code member: what Core sees, what the log line says, what the span tags, and what the response body carries are the same string.
That works because Error.Code defaults to the sentinel error.unspecified rather than to Kind. The point is that a kind is not a reason. An error with nothing finer to say once restated its kind in code, so code == "not-found" and kind == "not-found" carried one bit of information wearing two hats, and clients could not tell a real reason code from a kind echo. Now code answers exactly one question — "is there a finer machine-readable reason than the HTTP condition?" — and error.unspecified is the single, greppable way of saying no. Because the kind is not in the member at all, no boundary can leak one by accident.
An error whose reason code genuinely equals its own kind slug is still a reason a producer chose, and is emitted verbatim.
Codes Trellis did not choose are never rewritten — an Error.TransportFault publishes the wrapped fault's own code, and an application that emits the pre-vocabulary validation.error gets exactly that on the wire. ReasonCodeVocabularyAnalyzer flags that placeholder at the producer, which is where it is worth catching.
Breaking change. Every error without an explicit reason now emits
error.unspecifiedwhere it previously emitted its kind slug. This includesInvalidInputandAggregate— whose codes live per-violation and per-child, not at the root — andNotFound,Gone,RateLimited,AuthenticationRequired, andUnavailableconstructed without aCode. Clients branching oncodefor these cases must branch onkind(orstatus) instead — which is what those members were always for.
Naming the reason. Every error case carries an inherited
Code(seeErrorcases), soerror.unspecifiedmeans the producer named no reason.new Error.NotFound(ResourceRef.For<Account>(id)) { Code = "account.not-found" }emits that application reason verbatim. For sensitive resources, configureHideExistenceso missing and withheld outcomes share the same public code, detail, and resource metadata; do not restore private distinctions in response customization.
Reading the reason from a response
Everything above is written from the producing side. A client reads the same document in a specific order, and the order matters because the root code is not always where the reason lives:
- Read the top-level
code. If it is noterror.unspecified, that is the reason — use it and stop. - If it is the sentinel, descend before concluding there is no reason.
InvalidInputandAggregateleave the root at the sentinel by default, because their reasons are per-violation and per-child: look infieldViolations[n].codeandruleViolations[n].code, orproblems[n].codefor an aggregate. A response can carry"code": "error.unspecified"at the root and"code": "value.not-empty"one level down, and only the second is actionable. (Codeis an inheritedinitproperty, so a producer may set a root code on these cases too — which is why step 1 comes first.) - Do not assume
fieldViolationsexists. A request whose body never parsed or never converted has nothing semantically rejected, so it reports 400 with nofieldViolationskey at all. Treat its absence as "no per-field reason available", not as a malformed response. - Branch on
kindorstatus, never oncode, when you need the HTTP condition.codeanswers only "is there a finer reason than the condition?" — a client that branches on it for control flow will find the sentinel on every unnamed failure.
A catalog or localization lookup keyed on code should therefore guard the sentinel before the lookup rather than after: error.unspecified is a valid, expected value that no catalog should have an entry for, and a lookup miss is not the same signal as a producer declining to name a reason.
Aggregate rendering
Error.Aggregate renders as one outer Problem Details object whose status is the worst status of the children. Child problems are projected into the problems[] extension, one object per inner error.
Each child is a complete Problem Details object in its own right, not a fixed five-member summary:
typeis a URI reference per RFC 9457 §3.1.1 — ASP.NET Core's default problem type for the child's own resolved status, the same URI that status yields at the root. It is not thekindslug. Several statuses Trellis emits have no framework default (notably429, and428/451/431/423/424); for those the root omitstypeand so does the child, which RFC 9457 §3.1.1 defines as equivalent toabout:blank. The child never falls back to thekindslug. If the application registersAddProblemDetails(o => o.CustomizeProblemDetails = ...)and rewrites the roottype, that customization is not replayed per child:ProblemDetailsContextdescribes the response (it carries theHttpContext, the triggering exception, and endpoint metadata), so running it once per nested child would stamp children with root-scoped values and let it overwrite each child's ownstatusandinstance.status,code,kind, anddetailcarry the same values the child would carry at the root, including 5xx detail redaction keyed off the child's own resolved status.- Child-specific extensions survive: a nested
Error.InvalidInputkeeps itserrorsmap and itsruleViolationsarray, and anError.Unexpectedkeeps itsfaultId. A validation-shaped child carrieserrorseven when it is empty (a rules-only violation), matching the standaloneValidationProblemshape.
Breaking change. This extension was previously named
errors, which collided with the flat field-violationerrorsmap that a validation problem emits, making the two indistinguishable by name for typed clients. It is nowproblems.
Concurrent modification override
When Error.Conflict.Code == FaultCodes.ConcurrentModification and the incoming request carried If-Match, the boundary emits 412 Precondition Failed with wire kind precondition-failed instead of 409 conflict. The top-level Problem Details type continues to default to the ASP.NET status-code URL for 412. The domain code stays "concurrent-modification".
ProblemDetails.Instance synthesis from ResourceRef
When TrellisAspOptions.SynthesizeProblemDetailsInstanceFromResourceRef is true (the default), ResponseFailureWriter populates ProblemDetails.Instance from the failing ResourceRef rather than the request URL whenever:
- The error carries a non-null
ResourceRef(NotFound,Gone,Conflict?,Forbidden?,InvariantViolation?, orTransportFault(HttpError.PreconditionFailed)). ResourceRef.TypeandResourceRef.Idare both non-empty and non-whitespace.- The request URL does not already identify the same resource (segment-and-query-value-aware exact match against the raw id; percent-encoded path segments are decoded for the comparison, and form-encoded
+is treated as space in query values).
The synthesised value is /{collection}/{escapedId} (no /api/ prefix, no api-version segment, no query string), where:
{collection}defaults toResourceRef.Type.ToLowerInvariant() + "s"; override via[ResourceCollectionName(name)]on the aggregate type or viaservices.AddResourceCollectionName<T>(name)/services.AddResourceCollectionNames(assembly). Overrides are emitted verbatim — the lowercase guarantee applies only to the naive plural fallback, so register lowercase names if you want to preserve the convention.{escapedId}isUri.EscapeDataString(ResourceRef.Id).
The original request URI is preserved under ProblemDetails.Extensions["request"] so callers needing both have it. When synthesis is suppressed (toggle off, URL already identifies the resource, or ResourceRef is malformed), Instance falls back to the request URL and no request extension is emitted. Error.Aggregate never promotes a child's ResourceRef; the envelope itself carries no resource identity.
Synthesis is defensive, but not silent about everything. A malformed ResourceRef or a collection name that is not a safe URL path segment falls back to the request URL without comment. A registry that was never registered is not a failure at all — an internal default registry is used. A registry that throws (a DI activation fault, or a resolver that faults on misconfigured overrides) is caught and logged via LogInstanceSynthesisFailure before falling back. In every case the synthesis path can never turn a domain 404/409 into a 500.
ResourceCollectionNameRegistry
Declaration
public sealed class ResourceCollectionNameRegistry
| Signature | Returns | Description |
|---|---|---|
public ResourceCollectionNameRegistry() |
ResourceCollectionNameRegistry |
Empty registry — every ResourceRef.Type resolves to its naive lowercase plural. Used as the static fallback when HttpContext.RequestServices is null. |
public ResourceCollectionNameRegistry(IEnumerable<ResourceCollectionNameOverride> overrides) |
ResourceCollectionNameRegistry |
Builds the registry from DI. Validates each override (non-empty type/name; name must pass ResourceCollectionNameAttribute.IsSafePathSegment). Throws InvalidOperationException if two overrides map the same type name (case-insensitive) to different collection names. Identical duplicates are coalesced silently. Registered as a singleton by AddTrellisAsp. |
public string Resolve(string resourceType) |
string |
Case-insensitive override lookup; falls back to resourceType.ToLowerInvariant() + "s" if no override is registered. Safe to call from concurrent request threads (the underlying dictionary is built once at construction and read-only thereafter). |
ResourceCollectionNameOverride
Declaration
public sealed record ResourceCollectionNameOverride(string ResourceType, string CollectionName);
DI-friendly carrier record. Register one per type via services.AddSingleton(new ResourceCollectionNameOverride("Person", "people")) (or use the AddResourceCollectionName* extensions, which do the same). ResourceCollectionNameRegistry consumes them via IEnumerable<ResourceCollectionNameOverride> in its constructor — Microsoft DI auto-injects an empty enumerable when no overrides are registered.
ViolationLocation
Declaration
public sealed record ViolationLocation(string In, string? Pointer, string? Name);
| Member | Type | Description |
|---|---|---|
In |
string |
The location discriminator — body, query, path, header, or unknown. Always present, never defaulted by omission: an omitted discriminator would force every client to encode the default. |
Pointer |
string? |
An RFC 6901 JSON Pointer into the request document. Present for body and unknown; omitted otherwise. |
Name |
string? |
The parameter name. Present for query, path and header; omitted otherwise. |
unknown means "do not resolve pointer as a document location". A JSON Pointer addresses a location in a JSON document and a query parameter is not in one, so a single member cannot carry both meanings — which is why pointer and name are separate members rather than one polymorphic field.
InputOriginAttribute
Declaration
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class InputOriginAttribute(InputLocation location) : Attribute;
| Member | Type | Description |
|---|---|---|
Location |
InputLocation |
The residual location for this endpoint's otherwise-unlocated violations. |
Overrides where an endpoint's unlocated validation failures are said to have come from. An MVC action or controller applies it directly as [InputOrigin(InputLocation.Body)] (MVC copies both action and controller attributes into endpoint metadata); a Minimal API endpoint or route group calls WithInputOrigin(InputLocation.Body), which adds the same metadata. One declaration type keeps the two hosting models from drifting apart on the wire.
Only Body, Query and Unspecified may be declared; Path and Header throw ArgumentOutOfRangeException.
Most endpoints do not need it — see below.
Locations are derived, not declared
A model binder stamps the parameter it binds, so a route, query or header violation arrives already located. A violation that comes back up from the domain does not: an aggregate names the field that failed but cannot know where the value came from, because the same method is reachable from a worker, a message handler, or a test. It therefore emits InputLocation.Unspecified, which projects as unknown — the producer declining to assert a checkable claim that may be false.
The response boundary resolves those from the endpoint's own binding map, which ASP.NET already builds. Nothing is declared and nothing is guessed:
| The violation names… | Read from | Projects as |
|---|---|---|
| one of the endpoint's route parameters | its route pattern | path |
| a query parameter the endpoint binds | its API description | query |
| a header parameter the endpoint binds | its API description | header |
| anything else, when the endpoint binds a body | its API description | body |
| anything else, when it binds no body | — | unknown |
So POST /api/accounts/{id}/deposit, which binds {id} from the route and a DepositRequest body, resolves a domain violation on amount to {"in": "body", "pointer": "/amount"} and one on id to {"in": "path", "name": "id"} — with no annotation on the endpoint.
The last row is what makes the body row safe. POST /api/accounts/{id}/close takes no body, so there is nothing to attribute a residual to and unknown remains the honest answer.
ApiExplorer reports the same binding map for a controller action and a minimal API endpoint, so the two hosting models cannot disagree about the same request. An application that never registered ApiExplorer loses only the query and header evidence — route parameters still resolve, because they come from the endpoint's own route pattern rather than the API description. What remains is the endpoint's declared residual, which is not applied uniformly: Body rebases any unlocated pointer, including a nested one; Query applies only to a pointer naming a single top-level member, since a nested pointer addresses a document no URL can carry; and a declaration of Unspecified — or none at all — leaves the pointer projecting as unknown.
Why the body is a residual rather than a lookup
Query and route names are matched directly. The body is not: a body parameter names a type, not a set of members, so recovering member names would mean reflecting over the DTO graph — which the AOT-friendly projection path avoids.
That limits the pointer, not the in. A domain producer may raise a name matching no member of the body it was bound from:
// Showcase.Domain — the name raised is the domain parameter…
Error.InvalidInput.ForField(field: nameof(interestAmount), code: ValidationCodes.ValueGreaterThan, detail: ...)
// Showcase.Application — …and the body member is called something else.
public sealed record InterestRequest(decimal AnnualRate);
The emitted pointer /interestAmount then addresses nothing. But "in": "body" is still correct — the URL does not account for the name and the endpoint binds a body — and an explicit declaration would produce exactly the same pointer. The unresolvable pointer is a property of the producer's naming, not of deriving versus declaring.
The nearest declaration wins
Where a declaration is used, one on a controller covers every action, and an action that disagrees overrides it by declaring its own. This is not bespoke precedence logic — MVC appends action metadata after controller metadata, and GetMetadata<T>() returns the last match, so the nearest declaration is simply the one read. Minimal APIs get the same behaviour from convention order, so a route group's declaration is overridden by an endpoint's.
[ApiController]
public sealed class AccountsController : ControllerBase
{
[HttpGet] // cursor derives as query
public ActionResult<PagedResponse<AccountResponse>> List([FromQuery] string? cursor) => ...
[HttpPost("{id:AccountId}/deposit")] // amount derives as body
public Task<ActionResult<AccountResponse>> Deposit(AccountId id, [FromBody] DepositRequest request) => ...
[HttpPost("{id:AccountId}/reconcile")]
[InputOrigin(InputLocation.Unspecified)] // binds a body, but its violations describe neither
public Task<ActionResult<AccountResponse>> Reconcile(AccountId id, [FromBody] ReconcileRequest request) => ...
}
The Showcase carries no declaration at all, in either hosting model. That is the intended state for an ordinary API.
Limits
Named evidence — path, query and header — applies only to a pointer naming exactly one top-level member, because no URL can carry a document: /employeeId/0/name is not mistaken for a route parameter. The body residual carries no such restriction, since a nested pointer like /lines/0/amount already addresses a body document and is rebased as one.
Matching is case-insensitive, as routing is.
Derivation cannot disambiguate a body member sharing a name with a route, query or header parameter — PUT /employee/{id} carrying {"id": …} — where the request's own parameters are evidence about a different value of the same name. An InputOriginAttribute declaration changes only the residual after named-parameter evidence, so it does not override that collision. At the transport boundary, explicitly locate the body violation with InputPointer.ForBody("/id"), or use distinct names and translate the pointer there. An already-located violation is preserved.
One further limit is specific to minimal APIs. A controller action is identified by its ActionDescriptor instance, but a minimal API endpoint is identified only by its handler method, and one method can be mapped to several routes:
app.MapGet("/items/{id}", Handle); // id is a route parameter here
app.MapGet("/items/search", Handle); // …and a query parameter here
The route template and HTTP method disambiguate those. When they cannot, no evidence is derived and the violation falls back to the declared residual — the same answer as an application without ApiExplorer, rather than another route's binding map.
FieldViolationProblemDetail
Declaration
public sealed record FieldViolationProblemDetail(
string Code,
string? Detail,
ViolationLocation Location,
IReadOnlyDictionary<string, ValidationArgValue>? Args);
| Member | Type | Description |
|---|---|---|
Code |
string |
The machine-readable reason code. |
Detail |
string? |
Human-readable explanation. Omitted when absent. |
Location |
ViolationLocation |
Where the offending value came from. |
Args |
IReadOnlyDictionary<string, ValidationArgValue>? |
Arguments that parameterize the message, letting a client render its own localized prose. Omitted when absent. An enum rejection (enum.name-undefined, enum.undefined) carries allowed — the permitted member names, ordinally sorted — from every producer that can reject one: query binding, the body converter, RequiredEnum.TryCreate, its JSON converter, and a FluentValidation IsInEnum() rule. Beyond 64 members the list is dropped whole and allowedCount is sent instead, so a client must treat a missing allowed as "not supplied" rather than "nothing is permitted". |
AOT-friendly JSON payload used inside Problem Details extensions["fieldViolations"] for Error.InvalidInput field violations. Application code should treat this as response shape metadata, not as a domain model.
RuleViolationProblemDetail
Declaration
public sealed record RuleViolationProblemDetail(
string Code,
string? Detail,
IReadOnlyList<ViolationLocation> Locations,
IReadOnlyDictionary<string, ValidationArgValue>? Args);
| Member | Type | Description |
|---|---|---|
Code |
string |
The machine-readable reason code. |
Detail |
string? |
Human-readable explanation. Omitted when absent. |
Locations |
IReadOnlyList<ViolationLocation> |
Every location the rule spans. Always present: an empty array is a positive statement that the rule is form-level rather than bound to any field, which an omitted member could not express. |
Args |
IReadOnlyDictionary<string, ValidationArgValue>? |
Arguments that parameterize the message. Omitted when absent. |
AOT-friendly JSON payload used inside Problem Details extensions["ruleViolations"] for Error.InvalidInput rule violations. Application code should treat this as response shape metadata, not as a domain model.
Breaking change. The member was previously
string[] Fields, a bare array of JSON Pointer strings. It is nowLocations, so that a rule spanning a query parameter and a body field can say so — the old shape could only ever assert "these are pointers into the body", which was false for every non-body input.
Native AOT registration. Both payloads land in
ProblemDetails.Extensions, which isobject-valued, soSystem.Text.Jsonresolves them polymorphically at write time. Under AOT that requires each array type to be rooted on yourJsonSerializerContextexplicitly — registering the element type is not sufficient:[JsonSerializable(typeof(FieldViolationProblemDetail[]))] [JsonSerializable(typeof(RuleViolationProblemDetail[]))] internal sealed partial class MyJsonSerializerContext : JsonSerializerContext;An AOT application missing an entry does not fail to build and does not degrade gracefully — it throws
NotSupportedExceptionfrom inside the response writer while serializing the error response, converting a 422 into a 500 on exactly the path a client hits when it sends bad input.FieldViolationProblemDetail[]is new in this release, so an existing AOT consumer that already registeredRuleViolationProblemDetail[]must add the second entry.
AggregateRepresentationValidator<T>
Declaration
public sealed class AggregateRepresentationValidator<T> : IRepresentationValidator<T> where T : IAggregate
| Signature | Returns | Description |
|---|---|---|
public EntityTagValue GenerateETag(T value, string? variantKey = null) |
EntityTagValue |
Returns EntityTagValue.Strong(value.ETag) when variantKey is null/empty; otherwise SHA-256 hashes $"{value.ETag}:{variantKey}" and returns the first 16 lowercase hex characters as a strong ETag. |
IRepresentationValidator<in T>
Declaration
public interface IRepresentationValidator<in T>
| Signature | Returns | Description |
|---|---|---|
EntityTagValue GenerateETag(T value, string? variantKey = null) |
EntityTagValue |
Generates a representation-specific validator for a domain value and optional variant key (typically the negotiated content type or language). |
ETagHelper
Declaration
public static class ETagHelper
| Signature | Returns | Description |
|---|---|---|
public static bool IfNoneMatchMatches(IList<EntityTagHeaderValue> ifNoneMatchHeader, string currentETag) |
bool |
Weak-comparison helper for If-None-Match; returns true for * or any matching opaque tag. |
public static bool IfMatchSatisfied(IList<EntityTagHeaderValue> ifMatchHeader, string currentETag) |
bool |
Strong-comparison helper for If-Match (RFC 9110 §13.1.1). Returns true when the header is absent/empty (unconditional request), for *, or for a matching strong tag; returns false when currentETag is null/empty or every presented tag is weak or non-matching. |
public static EntityTagValue[]? ParseIfNoneMatch(HttpRequest request) |
EntityTagValue[]? |
null when absent; [] when present but unparseable/empty; wildcard for *; otherwise the parsed strong/weak tags. |
public static DateTimeOffset? ParseIfModifiedSince(HttpRequest request) |
DateTimeOffset? |
Returns the typed If-Modified-Since value. |
public static DateTimeOffset? ParseIfUnmodifiedSince(HttpRequest request) |
DateTimeOffset? |
Returns the typed If-Unmodified-Since value. |
public static EntityTagValue[]? ParseIfMatch(HttpRequest request) |
EntityTagValue[]? |
null when absent; [] when present but empty/only weak; wildcard for *; otherwise strong tags only. |
IfNoneMatchExtensions
Declaration
public static class IfNoneMatchExtensions
Create-if-absent guard for unsafe methods (PUT / POST).
| Signature | Returns | Description |
|---|---|---|
public static Result<T> EnforceIfNoneMatchPrecondition<T>(this Result<T> result, EntityTagValue[]? ifNoneMatchETags) |
Result<T> |
When ifNoneMatchETags contains *, replaces a successful result with Error.TransportFault(new HttpError.PreconditionFailed(ResourceRef.For<T>(), PreconditionKind.IfNoneMatch)). No-op when the header is absent or the result is already a failure. |
public static Task<Result<T>> EnforceIfNoneMatchPreconditionAsync<T>(this Task<Result<T>> resultTask, EntityTagValue[]? ifNoneMatchETags) |
Task<Result<T>> |
Async Task overload. |
public static ValueTask<Result<T>> EnforceIfNoneMatchPreconditionAsync<T>(this ValueTask<Result<T>> resultTask, EntityTagValue[]? ifNoneMatchETags) |
ValueTask<Result<T>> |
Async ValueTask overload. |
PreferHeader
Declaration
public sealed class PreferHeader
Parses the RFC 7240 Prefer request header. Per RFC 7240 §2 unrecognized or malformed tokens are ignored; duplicate recognized preferences use first-wins behavior.
| Name | Type | Description |
|---|---|---|
ReturnRepresentation |
bool |
true for return=representation. |
ReturnMinimal |
bool |
true for return=minimal. |
RespondAsync |
bool |
true for respond-async. |
Wait |
int? |
Parsed wait=N value; null when absent or unparseable. |
HandlingStrict |
bool |
true for handling=strict. |
HandlingLenient |
bool |
true for handling=lenient. |
HasPreferences |
bool |
true when at least one recognized preference was parsed. Unknown preferences do not set this. |
| Signature | Returns | Description |
|---|---|---|
public static PreferHeader Parse(HttpRequest request) |
PreferHeader |
Parses the header from the request. |
PagedResponse<TResponse>
Declaration
public sealed record PagedResponse<TResponse>(
IReadOnlyList<TResponse> Items,
PageLink? Next,
PageLink? Previous,
int RequestedLimit,
int AppliedLimit,
int DeliveredCount,
bool WasCapped);
JSON envelope returned by the Result<Page<T>> overload of ToHttpResponse.
PageDirection
public enum PageDirection
{
Next,
Previous
}
Identifies which adjacent page a URL targets. Next maps to the envelope's Next and
rel="next"; Previous maps to Previous and rel="prev". This ASP-owned enum guides
URL construction only: it does not add reverse-seek support to a data source. A missing
cursor produces neither a callback invocation nor a link.
PageLink
Declaration
public sealed record PageLink(string Cursor, string Href);
A cursor + the absolute URL the client should follow. Also rendered as <{Href}>; rel="next" / rel="prev" entries in the response Link header.
ServiceCollectionExtensions
Declaration
public static class ServiceCollectionExtensions
The main DI surface for Trellis.Asp (in folder Extensions/).
| Signature | Returns | Description |
|---|---|---|
public static IMvcBuilder AddScalarValueValidation(this IMvcBuilder builder) |
IMvcBuilder |
Configures MVC JSON options + the ScalarValueValidationFilter + a ScalarValueModelBinderProvider. Suppresses MVC validation recursion into Maybe<T>. |
public static IServiceCollection AddScalarValueValidation(this IServiceCollection services) |
IServiceCollection |
Configures both MVC (MvcJsonOptions) and Minimal API (HttpJsonOptions) JSON pipelines for scalar-value/Maybe<T> support. Not idempotent. MVC filters and model-binder providers are de-duplicated, but the JSON registrations are not: the type-info modifier is appended via WithAddedModifier on every call, and the converter factories (ValidatingJsonConverterFactory, MaybeScalarValueJsonConverterFactory, MaybePrimitiveJsonConverterFactory) are appended on every call when JsonSerializer.IsReflectionEnabledByDefault is true — they are skipped entirely in reflection-disabled/Native AOT builds, where scalar roots must instead be reachable through your JsonSerializerContext. So calling this twice (or alongside AddScalarValueValidationForMinimalApi()) double-applies the modifier in every build, and the factories too in reflection mode. Call it exactly once. |
public static IApplicationBuilder UseScalarValueValidation(this IApplicationBuilder app) |
IApplicationBuilder |
Adds ScalarValueValidationMiddleware so ValidatingJsonConverter<TValue,TPrimitive> can collect errors per request. |
public static IServiceCollection AddScalarValueValidationForMinimalApi(this IServiceCollection services) |
IServiceCollection |
Configures only the Minimal API JSON pipeline. A strict subset of AddScalarValueValidation(), which already configures it — call one or the other, never both, or the JSON type-info modifier and converter factories are applied twice. Prefer AddScalarValueValidation() (surfaced as UseScalarValueValidation()) unless you want to avoid the inert MvcOptions / MvcJsonOptions callbacks in a controller-less host. |
public static RouteHandlerBuilder WithScalarValueValidation(this RouteHandlerBuilder builder) |
RouteHandlerBuilder |
Adds ScalarValueValidationEndpointFilter to the route handler. |
public static TBuilder WithInputOrigin<TBuilder>(this TBuilder builder, InputLocation location) where TBuilder : IEndpointConventionBuilder |
TBuilder |
Declares where validation failures reaching this endpoint without a location of their own came from, so they project as that location rather than unknown. Adds InputOriginAttribute to the endpoint; the MVC equivalent is [InputOrigin(...)] on the action or controller. Accepts Body, Query, or Unspecified to opt out of a route group's declaration; throws ArgumentOutOfRangeException otherwise. See InputOriginAttribute above. |
public static IServiceCollection AddTrellisAsp(this IServiceCollection services) |
IServiceCollection |
Registers TrellisAspOptions with default error mappings and ResourceCollectionNameRegistry. Does NOT register scalar-value validation — see AddTrellisAspWithScalarValidation or call AddScalarValueValidation() explicitly. The split exists so the global MvcOptions/JsonOptions mutation that scalar validation performs is opt-in instead of silent. |
public static IServiceCollection AddTrellisAsp(this IServiceCollection services, Action<TrellisAspOptions> configure) |
IServiceCollection |
Same as above, with a MapError<TError>(...) callback for overrides. Calls compose — when AddTrellisAsp(o => ...) is invoked more than once (e.g. by a library and the application), every configure delegate runs in registration order against the same TrellisAspOptions instance built lazily by OptionsFactory<TrellisAspOptions>. Same-TError mappings still follow last-wins, but mappings for different error types from earlier calls are preserved. |
public static IServiceCollection AddTrellisAspWithScalarValidation(this IServiceCollection services) |
IServiceCollection |
Convenience composition of AddTrellisAsp() and AddScalarValueValidation() for greenfield projects that want error mapping plus scalar-value model binding / JSON converters in a single call. |
public static IServiceCollection AddTrellisAspWithScalarValidation(this IServiceCollection services, Action<TrellisAspOptions> configure) |
IServiceCollection |
Convenience composition of AddTrellisAsp(configure) and AddScalarValueValidation(). |
public static IServiceCollection AddTrellisProblemDetails(this IServiceCollection services) |
IServiceCollection |
Registers Trellis Problem Details defaults and returns the service collection; See Behavioral notes: AddTrellisProblemDetails below. |
ResourceCollectionNameServiceCollectionExtensions
Declaration
public static class ResourceCollectionNameServiceCollectionExtensions
Registers ResourceCollectionNameOverride entries and the consuming ResourceCollectionNameRegistry. The generic overload derives its lookup key from ResourceRef.For<TResource>(...), so registration and lookup are guaranteed to agree — including for Maybe<T>, which is peeled on both sides (Maybe<Order> and Order register the same entry, and nested Maybe<Maybe<Order>> resolves to Order). The string overload keys on the value you pass, for resources named through ResourceRef.For(string, object?). The assembly-scanning overloads key on ResourceRef.FormatTypeName of the annotated type.
| Signature | Returns | Description |
|---|---|---|
public static IServiceCollection AddResourceCollectionName<TResource>(this IServiceCollection services, string collectionName) |
IServiceCollection |
Maps the simple type name of TResource (as produced by ResourceRef.For<TResource>()) to a URL collection segment used when synthesising ProblemDetails.Instance from a ResourceRef. AOT- and trim-friendly: performs no assembly scanning. Registers ResourceCollectionNameRegistry via TryAddSingleton so callers can use this extension without first calling AddTrellisAsp. |
public static IServiceCollection AddResourceCollectionName(this IServiceCollection services, string resourceType, string collectionName) |
IServiceCollection |
Same as the typed overload but takes the ResourceRef.Type string directly — for cases where the consumer wants to bind a type name that does not exist as a CLR type, or to keep registration centralised. Validates the collectionName is a safe single URL path segment. |
public static IServiceCollection AddResourceCollectionNames(this IServiceCollection services, Assembly assembly) |
IServiceCollection |
Scans the supplied assembly for types decorated with [ResourceCollectionName] and registers one ResourceCollectionNameOverride per type. Marked [RequiresUnreferencedCode] because it uses reflection over the assembly's types; AOT/trim-published apps should prefer the explicit AddResourceCollectionName<T>(...) overload. Conflicting registrations (same type name → different collection names) throw when ResourceCollectionNameRegistry is activated, not when they are registered. On the ResponseFailureWriter path activation happens while writing a failure response — not at startup — and the exception is caught and logged there, so a conflict degrades Instance synthesis to the request URL rather than failing the request or the application. Identical registrations coalesce silently. |
public static IServiceCollection AddResourceCollectionNames(this IServiceCollection services, params Assembly[] assemblies) |
IServiceCollection |
Convenience overload that scans each supplied assembly in order via the single-Assembly overload. Identical overrides across assemblies coalesce silently when the registry is activated; conflicting overrides throw. |
IdempotencyServiceCollectionExtensions
Declaration
public static class IdempotencyServiceCollectionExtensions
| Signature | Returns | Description |
|---|---|---|
public static IServiceCollection AddTrellisIdempotency(this IServiceCollection services, Action<IdempotencyOptions>? configure = null) |
IServiceCollection |
Registers IdempotencyOptions (with the optional configure callback and startup validation), the default IIdempotencyScopeResolver (per-actor, falling back to anonymous when no actor provider is registered), and an internal marker that UseTrellisIdempotency() uses to detect the wiring at startup. Does not register a store — composition is explicit; call AddInMemoryIdempotencyStore() for dev / tests, or register a shared store for multi-instance production hosts. Composition-root consumers can opt in via the options.UseIdempotency(...) slot on TrellisServiceBuilder. |
public static IServiceCollection AddInMemoryIdempotencyStore(this IServiceCollection services) |
IServiceCollection |
Registers InMemoryIdempotencyStore as the singleton IIdempotencyStore. Single-process only — not safe for multi-replica services, where the same Idempotency-Key may land on a different replica. This is a leaf store registration and deliberately has no TrellisServiceBuilder slot. |
Behavioral notes: AddTrellisProblemDetails
AddTrellisProblemDetails() registers IProblemDetailsService via AddProblemDetails and applies the Trellis recipe: trace id projected from Activity.Current?.Id ?? HttpContext.TraceIdentifier, friendly detail rewrite for 500 responses, and an allow extension array on 405 projected from the Allow header after splitting on , and trimming whitespace. It composes with consumer customizations by running Trellis defaults first, then letting any prior or subsequent AddProblemDetails(o => o.CustomizeProblemDetails = ...) callback run last and win on collisions. Additional calls are no-ops. Pair it with app.UseTrellisProblemDetails() in the request pipeline. Composition-root consumers can opt in via the options.UseProblemDetails() slot on TrellisServiceBuilder; direct + builder composition is idempotent with one Trellis post-configure layer.
ApplicationBuilderExtensions
Declaration
public static class ApplicationBuilderExtensions
The middleware pipeline surface for Trellis.Asp (in folder Extensions/).
| Signature | Returns | Description |
|---|---|---|
public static IApplicationBuilder UseTrellisProblemDetails(this IApplicationBuilder app) |
IApplicationBuilder |
Wires the canonical ProblemDetails request pipeline: UseExceptionHandler() then UseStatusCodePages(). Must be registered early in the pipeline — UseStatusCodePages only rewrites status-code responses produced by middleware registered after it (routing, authorization, endpoint execution). Pair with services.AddTrellisProblemDetails() so the rewritten responses pick up Trellis defaults (trace id, friendly 500 detail, 405 allow array). |
RateLimiterOptionsExtensions
Declaration
public static class RateLimiterOptionsExtensions
| Signature | Returns | Description |
|---|---|---|
public static RateLimiterOptions UseTrellisRejectionHandler(this RateLimiterOptions options, Func<OnRejectedContext, CancellationToken, ValueTask>? observer = null) |
RateLimiterOptions |
Owns options.OnRejected, sets options.RejectionStatusCode to 429, and renders supported rejections through the normal Error.ToHttpResponse() path. Throws InvalidOperationException when an OnRejected handler already exists or the optional observer mutates/starts the response. |
Configure policies and partitioning with ASP.NET Core, then install the Trellis-owned writer:
builder.Services.AddTrellisAsp();
builder.Services.AddTrellisProblemDetails();
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("catalog-write", CreateCatalogWritePolicy);
options.UseTrellisRejectionHandler();
});
// In the request pipeline, at the placement required by ASP.NET Core:
app.UseRateLimiter();
The adapter reads MetadataName.RetryAfter from the rejected lease and constructs
new Error.RateLimited(new RetryAdvice(After: retryAfter)) { Code = FaultCodes.RateLimitExceeded }.
The standard writer maps it to HTTP 429, the too-many-requests kind, the frozen
rate-limit.exceeded code, and a delta-seconds Retry-After header. When lease metadata is absent,
the response is still a normal 429 Problem Details document and omits Retry-After.
AddTrellisProblemDetails() adds the standard trace-id customization.
The optional observer receives ASP.NET Core's OnRejectedContext and cancellation token before
Trellis writes. The adapter sets RejectionStatusCode to 429 so the observer sees the same status
as the eventual default Trellis response (rather than ASP.NET Core's default 503). It is for
logging or metrics only. Changing the status, headers, or response body, including from an
OnStarting callback, or starting the response throws rather than producing a mixed two-writer
response. Earlier middleware's OnStarting callbacks still run normally. An existing
OnRejected handler also causes setup to throw; move non-writing work into the observer parameter,
or do not install this adapter when the application owns a custom rejection response.
Policy-level limitation: ASP.NET Core calls a named IRateLimiterPolicy<T>.OnRejected in
preference to RateLimiterOptions.OnRejected; the adapter cannot intercept that callback.
Register endpoint policies by name with no policy-level rejection handler, as in the example
above. Avoid inline endpoint.RequireRateLimiting(policy) with this adapter: ASP.NET Core skips
the options handler for an inline policy even when its OnRejected is null, producing a bare
429 without the Trellis envelope or lease Retry-After. The options API does not expose all
registered policies or endpoint metadata to inspect at setup, so these configurations cannot
be rejected by UseTrellisRejectionHandler(). Use a named policy without a handler or leave the
response writer entirely application-owned.
This is an options extension used inside the application's existing AddRateLimiter callback, not
an IServiceCollection registration. It deliberately has no TrellisServiceBuilder slot: Trellis
does not choose limiter algorithms, policies, permit counts, queues, or partition keys.
IdempotencyApplicationBuilderExtensions
Declaration
public static class IdempotencyApplicationBuilderExtensions
| Signature | Returns | Description |
|---|---|---|
public static IApplicationBuilder UseTrellisIdempotency(this IApplicationBuilder app) |
IApplicationBuilder |
Mounts IdempotencyMiddleware in the request pipeline. The middleware is a no-op on endpoints that do not carry IdempotentAttribute and on methods outside IdempotencyOptions.Methods (default POST and PATCH). Throws InvalidOperationException at startup if services.AddTrellisIdempotency(...) was not called. The IIdempotencyStore registration is validated at startup when the container exposes IServiceProviderIsService (the default Microsoft.Extensions.DependencyInjection container does) — verified without resolving the service, so a scoped store (an EF-backed store depending on a scoped DbContext, for example) is validated at startup without being captured by the root provider. On containers that do not implement IServiceProviderIsService, a missing store surfaces as a per-request resolution error on the first opted-in request. Mount after UseRouting() so opted-in endpoints' metadata is resolvable, and after UseAuthentication() / UseAuthorization() so the default scope resolver sees the authenticated Actor and partitions the store by it; mounting before authentication causes every authenticated request to fall back to the shared anonymous scope, which can let different users collide on the same key. |
Namespace Trellis.Asp.Idempotency
Opt-in IETF Idempotency-Key middleware for POST / PATCH retry safety. See cookbook Recipe 29.
Request identity and scope. The store key is (scope, parsed Idempotency-Key), not (scope, key, fingerprint). DefaultIdempotencyScopeResolver uses the current actor's id; when no IActorProvider is registered or no actor resolves, it uses the shared anonymous scope. A custom IIdempotencyScopeResolver can supply a different isolation boundary, such as tenant plus actor. The default scope is not route-specific: all of an actor's opted-in endpoints sharing the store share one client key namespace.
Request fingerprint. IdempotencyFingerprint.Compute computes SHA-256 over the HTTP method, PathBase + Path, canonicalized query, Content-Type, Content-Encoding, headers configured in AdditionalFingerprintHeaders, and the body bytes. Query keys are sorted ordinally; repeated values for a key retain their order. The body is hashed as bytes, not normalized JSON. The fingerprint is stored with the entry and compared on retry; it does not create a separate entry for each URL or body.
For an existing reservation or an unexpired completed snapshot, reusing the same scope and key with a different fingerprint returns BodyHashMismatch, mapped to MismatchStatusCode (default 422), not a replay and not a new execution. Despite its name, BodyHashMismatch is not limited to body changes. For example, the same actor, key, and body sent first to POST /restaurants/A/import and then to POST /restaurants/B/import have different paths and therefore different fingerprints: the second request is rejected, not replayed. Use a distinct key for each intended operation and reuse that key only for retries of the same request.
The status codes follow draft-ietf-httpapi-idempotency-key-header §2.7 (Error Handling): a missing key on an opted-in endpoint → 400 (RequireKeyOnOptedInEndpoints); a key reused in the same scope with a different request fingerprint → 422 by default (MismatchStatusCode, per RFC 9110 §15.5.21); and a matching-fingerprint retry while the original reservation is still active → 409 (AlreadyInFlight). A malformed key (duplicate header, invalid sf-string, or over-length) is a request-syntax error and stays 400.
Fingerprint mismatches carry the ProblemDetails code idempotency.key_reused_with_different_body. This historical wire code is retained for compatibility and covers all fingerprint mismatches, not just body changes. The human-readable detail names the request fingerprint components; clients should branch on code, not parse the message.
IdempotentAttribute
Declaration
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class, AllowMultiple = false, Inherited = false)]
public sealed class IdempotentAttribute : Attribute
Endpoint marker. Apply to a controller action (or attach as endpoint metadata in Minimal API: .WithMetadata(new IdempotentAttribute())) to opt the endpoint into the idempotency middleware. Endpoints without the attribute pass through.
IdempotencyOptions
Declaration
public sealed class IdempotencyOptions
| Member | Default | Description |
|---|---|---|
HeaderName |
"Idempotency-Key" |
HTTP header carrying the IETF sf-string idempotency key. |
ReplayHeaderName |
"Idempotent-Replayed" |
Response header added to replayed responses so clients can detect that the body came from a cached snapshot rather than a fresh handler invocation. |
Ttl |
24 h |
Time a completed snapshot is retained before it is evicted and the key can be reused. |
ReservationTimeout |
30 s |
Time after which a same-key retry with a matching fingerprint may atomically take over an in-flight reservation (CAS) so a crashed handler does not block retries forever. Stores MUST NOT delete outstanding reservations on this timeout; takeover replaces the entry under a new reservation token, which invalidates the previous token for CompleteAsync / AbandonAsync. |
MaxKeyLength |
200 |
Hard cap on parsed key length; longer keys produce 400 Bad Request. Raw header values are also rejected before parsing when they exceed the parser's 4 KiB defensive cap, using the existing invalid-key 400 Bad Request surface. |
MaxRequestBodyBytes |
1 MiB |
Hard cap on the buffered request body that contributes to the fingerprint; larger bodies produce 413 Payload Too Large. |
MaxResponseBodyBytes |
1 MiB |
Hard cap on the captured response body; exceeding aborts capture and records no snapshot (the next retry re-executes). |
MismatchStatusCode |
422 |
Status returned when a key already in use in the same scope arrives with a different request fingerprint, including differences in path or method even if the body is identical. |
RequireKeyOnOptedInEndpoints |
true |
When true, opted-in endpoints reject requests that omit the header with 400 idempotency.key_required; when false, missing-key requests pass through with no idempotency processing. |
IncludeSetCookieInSnapshot |
false |
When true, Set-Cookie response headers are captured in snapshots; default excludes them so a replay does not re-issue session or authentication cookies that have since been rotated. |
Methods |
{ POST, PATCH } |
Methods the middleware acts on; other methods (GET, PUT, DELETE) pass through. |
AdditionalFingerprintHeaders |
empty | Extra request headers included in the fingerprint (for example a tenant header) when their semantics affect the request identity. |
AddTrellisIdempotency() validates options at host startup: HeaderName, ReplayHeaderName, and AdditionalFingerprintHeaders must be valid HTTP header names; Ttl, ReservationTimeout, MaxKeyLength, MaxRequestBodyBytes, and MaxResponseBodyBytes must be positive; MismatchStatusCode must be between 400 and 599; and Methods must contain at least one valid HTTP method token.
IIdempotencyStore
Declaration
public interface IIdempotencyStore
| Signature | Returns | Description |
|---|---|---|
ValueTask<IdempotencyReservationOutcome> TryReserveAsync(string scope, string key, string fingerprint, CancellationToken cancellationToken) |
one of Reserved(reservationId), AlreadyInFlight(retryAfter), Replay(snapshot), BodyHashMismatch(storedFingerprint) |
CAS reservation keyed only by (scope, key), with fingerprint compared against the stored entry. The reservation token is an opaque string; pass it back to CompleteAsync / AbandonAsync. |
ValueTask CompleteAsync(string scope, string key, string reservationId, IdempotencyResponseSnapshot snapshot, CancellationToken cancellationToken) |
ValueTask |
Records the response snapshot under the reservation. Conditional on the reservation token (CAS) so a slow original handler whose reservation has been atomically taken over by a same-key retry cannot finalise the entry under the now-invalid token. |
ValueTask AbandonAsync(string scope, string key, string reservationId, CancellationToken cancellationToken) |
ValueTask |
Releases a reservation without a snapshot so the next retry can re-reserve. Called on any failure path (exception, response-too-large, SendFileAsync, abort, 5xx response status, response trailers). |
Writing your own store? Inherit
IdempotencyStoreConformancefrom theTrellis.Testing.Idempotencypackage to run the full contract — atomic reserve, replay, fingerprint mismatch, reservation takeover, TTL expiry, and abandon-after-complete — against your implementation. Every rule here fails silently when violated, so the suite is the practical way to know a Redis, Cosmos DB, or EF Core store is correct. Seetrellis-api-testing-idempotency.md.
InMemoryIdempotencyStore
Declaration
public sealed class InMemoryIdempotencyStore : IIdempotencyStore
Single-process ConcurrentDictionary-backed store. Register with services.AddInMemoryIdempotencyStore() for dev / single-instance hosts and tests. Not safe across multiple instances or process restarts — production hosts that retry across replicas need a durable store implementing the same CAS contract. Trellis.Asp.Idempotency.Cosmos ships one; see trellis-api-asp-idempotency-cosmos.md.
IIdempotencyScopeResolver
Declaration
public interface IIdempotencyScopeResolver
| Signature | Returns | Description |
|---|---|---|
ValueTask<string> ResolveAsync(HttpContext context, CancellationToken cancellationToken) |
ValueTask<string> |
Returns an isolation scope (tenant id, actor id, anonymous). Two requests carrying the same key under different scopes never collide. Default registration is DefaultIdempotencyScopeResolver, which resolves IActorProvider from request services and uses the current actor's id (falling back to anonymous when no provider is registered or no actor is resolved). ActorIdempotencyScopeResolver is also shipped for hosts that want a hard dependency on IActorProvider. Replace with a custom implementation for a different isolation boundary, such as tenant plus actor. |
AnonymousIdempotencyScopeResolver is the third shipped implementation: it always returns the shared constant AnonymousIdempotencyScopeResolver.AnonymousScope, which is also the value the actor-based resolvers fall back to when no actor can be resolved. Because that scope is shared across all unauthenticated callers, two different clients that pick the same Idempotency-Key will collide under it — use it only for genuinely public endpoints, and mount UseTrellisIdempotency() after authentication so authenticated requests do not silently land here.
CapturingResponseBodyFeature
A public support type used by IdempotencyMiddleware (public sealed class CapturingResponseBodyFeature : IHttpResponseBodyFeature, IDisposable, with a public (IHttpResponseBodyFeature inner, long maxBytes) constructor). Most consumers never construct or reference it directly — it is documented mainly so its failure modes are legible when a replay unexpectedly does not happen — but it is part of the shipped surface and can be constructed if you are wrapping the response body yourself.
It replaces IHttpResponseBodyFeature for the duration of an opted-in request, tee-ing writes to both the original client stream and an in-memory buffer so the middleware can persist an IdempotencyResponseSnapshot. Capture is best-effort and never degrades the live response — every abort path below leaves the bytes already sent to the client untouched, and simply causes the middleware to record no snapshot, so a subsequent retry re-executes the handler instead of replaying.
| Member | Description |
|---|---|
StartAsync |
Delegates to the wrapped feature to begin the response. |
GetCapturedBytes() |
Returns the buffered body, or null when capture was aborted — the signal the middleware uses to skip snapshot persistence. |
AbortCapture() |
Marks capture abandoned. Called by the internal tee stream when a write would push the buffer past IdempotencyOptions.MaxResponseBodyBytes; the write itself still reaches the client. |
CaptureAborted |
true once capture has been abandoned for any reason. |
DisableBuffering() |
Forwards ASP.NET Core's buffering-disable signal to the wrapped feature. |
FlushCachedWriterAsync |
Flushes the pooled PipeWriter/writer over the tee stream, if one was ever requested, so the captured bytes are complete before GetCapturedBytes() is read. Safe to call when no writer was requested. |
SendFileAsync also aborts capture: a file send bypasses the buffered write path, so no faithful snapshot can be taken. Endpoints that stream files therefore never replay, by design.
Namespace Trellis.Asp.Authorization
The actor-provider DI surface absorbed from the former Trellis.Asp.Authorization package. Domain primitives (Actor, IActorProvider, etc.) live in Trellis.Authorization — see trellis-api-authorization.md.
IProvideActorVaryHeaders
Declaration
public interface IProvideActorVaryHeaders
{
IReadOnlyCollection<string> VaryByHeaders { get; }
}
Optional capability that an IActorProvider implementation can expose so HttpResponseOptionsBuilder<TDomain>.VaryForActor can emit the correct request headers as response Vary entries for cache partitioning by actor.
The bundled providers implement this:
ClaimsActorProvider(andEntraActorProvidervia inheritance) —virtual VaryByHeaders => ["Authorization"]. Subclass and override for non-bearer auth (cookies, mTLS, forwarded headers); leaving the JWT-bearer default in place against a non-Bearer service allows the same cache-poisoning thatVaryForActor()exists to prevent.DevelopmentActorProvider—[X-Test-Actor](the test header).EasyAuthClaimsActorProvider— the Azure "Easy Auth" principal headers (X-MS-CLIENT-PRINCIPAL,-ID,-NAME,-IDP) rather thanAuthorization, because the actor is derived from the platform-injected principal, not a bearer token.CachingActorProvider— delegates to the wrapped provider'sVaryByHeaders; surfaces an empty collection when the wrapped provider does not implement the interface, soVaryForActor()throws fail-closed pointing at the inner provider as the remediation site (the writer unwraps caching wrappers via the internalIDecoratingActorProviderinterface so the diagnostic names the right type).
Custom providers that derive actor identity from request data that cannot be cleanly named by an HTTP header (mTLS, IP-based, etc.) should NOT implement this interface; consumers using such providers must mark cache-eligible endpoints with Cache-Control: private, no-store instead of calling VaryForActor().
CacheControl
Note
Despite appearing under the Trellis.Asp.Authorization heading above (it is grouped here because it pairs with VaryForActor), CacheControl lives in the root Trellis.Asp namespace. Import Trellis.Asp, not Trellis.Asp.Authorization.
Declaration
public static class CacheControl
{
public static CacheControlHeaderValue NoStore();
public static CacheControlHeaderValue NoCache();
public static CacheControlHeaderValue Public(TimeSpan maxAge);
public static CacheControlHeaderValue Private(TimeSpan maxAge);
public static CacheControlHeaderValue Immutable(TimeSpan maxAge);
}
Preset System.Net.Http.Headers.CacheControlHeaderValue builders for the common directives, designed for use with HttpResponseOptionsBuilder<TDomain>.WithCacheControl.
| Preset | Emits | When to use |
|---|---|---|
CacheControl.NoStore() |
Cache-Control: no-store |
Responses that contain personal data, secrets, or per-user state. Use on Error.ToHttpResponse and Result.ToHttpResponse(opts => opts.WithCacheControl(...)) for sensitive endpoints; the static-value overload propagates to failure responses too so 404 / 403 / 422 cannot leak through intermediate caches. |
CacheControl.NoCache() |
Cache-Control: no-cache |
Caches may store the response but must revalidate with the origin before serving. Different from no-store: revalidation is allowed, storage is not forbidden. |
CacheControl.Public(TimeSpan) |
Cache-Control: public, max-age={seconds} |
Public, cacheable read endpoints (catalog data, public reference data). Shared caches may store and serve to any consumer for the lifetime. |
CacheControl.Private(TimeSpan) |
Cache-Control: private, max-age={seconds} |
Per-user representations safe to cache in the user agent only. Compose with VaryForActor() if any intermediate (CDN, reverse proxy) is in the path. |
CacheControl.Immutable(TimeSpan) |
Cache-Control: public, max-age={seconds}, immutable |
RFC 8246 — the response will not change for the freshness lifetime. Clients should not revalidate. Use for content-addressed or versioned assets. The immutable directive is appended via the BCL type's Extensions collection because CacheControlHeaderValue has no dedicated property for it. |
Fresh-instance guarantee. Every preset returns a new CacheControlHeaderValue on each call. CacheControlHeaderValue is mutable; if the presets returned a shared instance, a single caller mutating one returned value could corrupt every subsequent call. Test pinning: CacheControl_presets_return_fresh_instances in WithCacheControlTests.
Directive coverage outside the presets. Pass a hand-built CacheControlHeaderValue directly to WithCacheControl(...) when you need a directive not covered by a preset (s-maxage, proxy-revalidate, stale-while-revalidate via Extensions, etc.):
opts.WithCacheControl(new CacheControlHeaderValue
{
Public = true,
MaxAge = TimeSpan.FromMinutes(5),
SharedMaxAge = TimeSpan.FromMinutes(15),
MustRevalidate = true,
});
Composition with VaryForActor(). Cache-Control and Vary are orthogonal — the former says "is this cacheable, and for how long"; the latter says "by which request dimensions does the cache key vary." For per-user representations served behind a shared cache, combine: opts.WithCacheControl(CacheControl.Private(TimeSpan.FromMinutes(5))).VaryForActor().
ServiceCollectionExtensions
Declaration
public static class ServiceCollectionExtensions
| Signature | Returns | Description |
|---|---|---|
public static IServiceCollection AddClaimsActorProvider(this IServiceCollection services, Action<ClaimsActorOptions>? configure = null) |
IServiceCollection |
Adds IHttpContextAccessor, configures ClaimsActorOptions, and replaces the IActorProvider registration with a scoped ClaimsActorProvider. |
public static IServiceCollection AddNestedJsonPathClaimsActorProvider(this IServiceCollection services, Action<NestedJsonPathClaimsActorOptions>? configure = null) |
IServiceCollection |
Adds IHttpContextAccessor, configures and startup-validates NestedJsonPathClaimsActorOptions, and replaces the IActorProvider registration with a scoped NestedJsonPathClaimsActorProvider. When configure is omitted, the default empty JSON paths make the provider behave like ClaimsActorProvider. |
public static IServiceCollection AddEntraActorProvider(this IServiceCollection services, Action<EntraActorOptions>? configure = null) |
IServiceCollection |
Adds IHttpContextAccessor, configures EntraActorOptions, and replaces the IActorProvider registration with a scoped EntraActorProvider. |
public static IServiceCollection AddEasyAuthActorProvider(this IServiceCollection services, Action<ClaimsActorOptions>? configure = null) |
IServiceCollection |
Adds IHttpContextAccessor, configures ClaimsActorOptions, and replaces the IActorProvider registration with a scoped EasyAuthClaimsActorProvider (Azure "Easy Auth"). Actor mapping only — pair with AddAuthentication(...).AddEasyAuth() so the principal header is decoded onto HttpContext.User first. Registers a startup validator that throws if the Easy Auth authentication scheme is not registered. |
public static IServiceCollection AddDevelopmentActorProvider(this IServiceCollection services, Action<DevelopmentActorOptions>? configure = null) |
IServiceCollection |
Adds IHttpContextAccessor + logging, configures DevelopmentActorOptions, and replaces the IActorProvider registration with a scoped DevelopmentActorProvider. The provider itself throws outside the Development environment. |
public static IServiceCollection AddCachingActorProvider<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] T>(this IServiceCollection services) where T : class, IActorProvider |
IServiceCollection |
Registers concrete provider T as scoped, then replaces the IActorProvider registration with a scoped CachingActorProvider wrapping T. |
public static IServiceCollection AddTrellisWorkerActor(this IServiceCollection services, Actor systemActor) |
IServiceCollection |
Captures the existing unkeyed IActorProvider registration and replaces the slot with a scoped WorkerComposedActorProvider that returns systemActor when IHttpContextAccessor.HttpContext is null and delegates to the inner provider otherwise. Throws when there is no prior unkeyed IActorProvider registration, when more than one is registered, when the helper has already been called, or when the prior descriptor is singleton-lifetime via implementation type or factory (would silently downgrade to per-wrapper-scope — use services.AddSingleton<IActorProvider>(instance) or re-register as scoped) or transient-lifetime (would silently upgrade to scoped-per-wrapper — re-register as scoped). Keyed IActorProvider registrations are ignored and remain untouched. Registers an IHostedLifecycleService validator that throws in StartingAsync (before any BackgroundService.ExecuteAsync runs) if a later registration overwrites the wrapper. On synchronous scope disposal, the wrapper disposes inner providers that implement IDisposable; async-only IAsyncDisposable inners are skipped with a once-per-application-lifetime warning to avoid sync-over-async deadlocks, so consumers with async-only resources must dispose scopes asynchronously (DisposeAsync / await using). |
Replacement semantics. Each
AddXxxActorProviderhelper callsservices.Replace(...)for theIActorProviderslot. Calling more than one helper leaves exactly oneIActorProviderdescriptor — the last one wins — andGetServices<IActorProvider>()returns a single provider. WithoutReplace, two helpers would leave two scoped descriptors with surprising resolution semantics (single resolve picks the last; enumeration exposes both).
ClaimsActorOptions
Declaration
public class ClaimsActorOptions
| Name | Type | Description |
|---|---|---|
ActorIdClaim |
string |
Claim type used for Actor.Id. Default: "sub" (RFC 7519 / OIDC subject claim). Matched against Claim.Type literally first; if the configured name is not found, falls back to its counterpart in a curated short↔long mapping table maintained by the provider. Covers the OAuth2 / OIDC / Microsoft identity-platform claims that consumers realistically configure as an actor id: "sub"/"nameid" ↔ ClaimTypes.NameIdentifier, "oid" ↔ http://schemas.microsoft.com/identity/claims/objectidentifier, "upn" ↔ ClaimTypes.Upn, "email" ↔ ClaimTypes.Email, "role"/"roles" ↔ ClaimTypes.Role, "name"/"unique_name" ↔ ClaimTypes.Name, "tid" ↔ http://schemas.microsoft.com/identity/claims/tenantid, "idp"/"acr"/"amr" ↔ their Microsoft long forms. The bidirectional fallback makes typical configurations just-work against both JwtBearerOptions.MapInboundClaims = true (ASP.NET default) and false. Emits a debug-level log entry when the fallback fires. No dotted/JSON-path traversal. The curated table is a subset of JwtSecurityTokenHandler.DefaultInboundClaimTypeMap; the space-delimited OAuth scope claim "scp", AD FS 1.x legacy aliases, and device / certificate / request-transport / password-policy claims are intentionally not covered (see the PermissionsClaim row for the scp rationale). |
PermissionsClaim |
string |
Claim type used for permissions. Default: "permissions". Multi-valued JWT claims arrive as repeated Claim instances and are aggregated via FindAll. Matched against Claim.Type literally first; the resolver also queries every counterpart in the provider's curated short↔long mapping table (notably "role"/"roles" ↔ ClaimTypes.Role) and merges all matches into a single deduplicated set. This makes PermissionsClaim = "roles" and PermissionsClaim = ClaimTypes.Role both just-work against JwtBearerOptions.MapInboundClaims = true (ASP.NET default) and false. The default "permissions" is not in the mapping table, so it resolves by literal match only (regression-safe). The fallback emits a debug-level log entry when the configured claim resolves nothing but a counterpart does. The OAuth scope claim "scp" is intentionally NOT covered by the fallback: its value is space-delimited (RFC 6749 §3.3, e.g. "orders.read orders.write") and ClaimsActorProvider snapshots claim values verbatim into the permission set, so wiring the fallback would still leave Actor.HasPermission("orders.read") returning false. OAuth scope-as-permission requires a custom subclass that splits the value. See the ActorIdClaim row above for the full covered subset. |
ValidateClaimShapeOnFirstUse |
bool |
Default true. Enables one-off diagnostics for the claim-shape footguns the provider cannot otherwise surface. Two concern PermissionsClaim (silent 403): (1) the configured claim resolved to zero permissions on an authenticated identity that carries other claims, and (2) it resolved to a single value that parses as a JSON object or array (the shape JwtSecurityTokenHandler produces for nested claims — Auth0 app_metadata, Azure B2C extension_*, some Okta tokens). A third concerns ActorIdClaim (silent 401, EventId 5): the configured claim and its short↔long fallback resolved nothing on an authenticated identity that carries other claims, so a successfully authenticated caller is rejected with a bare 401 — usually a JwtBearerOptions.MapInboundClaims mismatch the fallback table does not cover. Each warning fires at most once per application lifetime, and only when a logger is available. These exist because the failures are otherwise invisible: the configured claim name matches nothing and the outcome is an ordinary-looking 401/403 with nothing pointing at the claim mapping. Set to false only when these diagnostics duplicate an existing health-check or claim-validation pipeline. |
NestedJsonPathClaimsActorOptions
Declaration
public sealed class NestedJsonPathClaimsActorOptions : ClaimsActorOptions
Inherits the flat ActorIdClaim / PermissionsClaim defaults from ClaimsActorOptions. A default new NestedJsonPathClaimsActorOptions() is valid: ContainerClaim, ActorIdPath, and PermissionsPath are empty, so the provider delegates to flat-claim resolution.
| Name | Type | Description |
|---|---|---|
ContainerClaim |
string |
Claim type whose value carries the JSON payload. Default: empty. Required only when ActorIdPath or PermissionsPath is non-empty. |
ActorIdPath |
string |
Dotted JSON path inside ContainerClaim used for Actor.Id. Default: empty, which falls back to inherited flat ActorIdClaim resolution. |
PermissionsPath |
string |
Dotted JSON path inside ContainerClaim used for permissions. Default: empty, which falls back to inherited flat PermissionsClaim resolution. Terminal values may be a string, an array of strings, or an object whose property names become permissions. |
ClaimsActorProvider
Declaration
public class ClaimsActorProvider : IActorProvider, IProvideActorVaryHeaders
Hydrates an Actor from the current HttpContext.User using flat JWT/OIDC claims. The optional logger parameter receives debug-level diagnostics when the short↔long claim-name fallback resolves a claim the configured literal name did not produce — helpful for diagnosing silent-401/403 issues caused by JwtBearerOptions.MapInboundClaims = true. AddClaimsActorProvider(...) wires the logger automatically; manual constructions may pass null. Subclass and override GetCurrentActorAsync for nested-claim or computed-permission scenarios; EntraActorProvider is a worked example.
| Name | Type | Description |
|---|---|---|
HttpContextAccessor |
IHttpContextAccessor (protected) |
Exposed to derived providers. |
Options |
ClaimsActorOptions (protected) |
Mapped options value. |
| Signature | Returns | Description |
|---|---|---|
public ClaimsActorProvider(IHttpContextAccessor httpContextAccessor, IOptions<ClaimsActorOptions> options, ILogger<ClaimsActorProvider>? logger = null) |
— | Builds the flat-claims provider. The optional logger receives debug-level short↔long claim-name fallback diagnostics. |
public virtual Task<Maybe<Actor>> GetCurrentActorAsync(CancellationToken cancellationToken = default) |
Task<Maybe<Actor>> |
Returns Maybe<Actor>.None when no authenticated identity exists or the configured ActorIdClaim is missing from the authenticated identity — the mediator pipeline maps Maybe.None to Error.AuthenticationRequired (HTTP 401). Throws InvalidOperationException only when HttpContext is missing (configuration bug, surfaces as HTTP 500). On success, permissions come from FindAll(PermissionsClaim) plus every counterpart in the provider's curated short↔long mapping table (see PermissionsClaim above), merged and snapshotted into a FrozenSet<string>; the result is wrapped via Maybe.From(Actor.Create(actorId, permissions)) so forbidden permissions and attributes default to empty. |
NestedJsonPathClaimsActorProvider
Declaration
public class NestedJsonPathClaimsActorProvider : ClaimsActorProvider
Maps nested JSON claim payloads to actor ids or permissions via NestedJsonPathClaimsActorOptions. If ActorIdPath and PermissionsPath are both empty, it delegates to ClaimsActorProvider, making the no-config registration safe for flat-claim tokens. The DI registration validates at host startup via IValidateOptions<NestedJsonPathClaimsActorOptions> + ValidateOnStart() that a nested path is not configured without ContainerClaim (empty and whitespace-only both count as missing — they would otherwise silently fall back to flat-claim resolution and reintroduce the misconfiguration footgun). The constructor keeps the same InvalidOperationException guard as defense-in-depth for manual construction. ValidateOnStart() fires regardless of whether a later AddXxxActorProvider call replaces IActorProvider; see trellis-api-authorization.md for the "register-then-replace" caveat.
| Signature | Returns | Description |
|---|---|---|
public override Task<Maybe<Actor>> GetCurrentActorAsync(CancellationToken cancellationToken = default) |
Task<Maybe<Actor>> |
Traverses the configured JSON paths when present, falls back to inherited flat-claim resolution when the container claim is absent, malformed, or a path misses, and emits the same silent-empty-permissions diagnostics as ClaimsActorProvider. |
EntraActorOptions
Declaration
public sealed class EntraActorOptions
| Name | Type | Description |
|---|---|---|
IdClaimType |
string |
Claim type used for actor ID. Default: "http://schemas.microsoft.com/identity/claims/objectidentifier". |
MapPermissions |
Func<IEnumerable<Claim>, IReadOnlySet<string>> |
Default returns the values of every roles / ClaimTypes.Role claim (case-insensitive type match). To flatten roles into granular permissions from an app-supplied map, assign RolePermissionProjection.ForRoleClaims(map) instead of hand-rolling the mapping. |
MapForbiddenPermissions |
Func<IEnumerable<Claim>, IReadOnlySet<string>> |
Default returns an empty HashSet<string>. |
MapAttributes |
Func<IEnumerable<Claim>, HttpContext, IReadOnlyDictionary<string, string>> |
Default extracts tid, preferred_username, azp, azpacr, acrs, plus ip_address from Connection.RemoteIpAddress and mfa = "true"|"false" from the amr claim. |
EntraActorProvider
Declaration
public sealed class EntraActorProvider : ClaimsActorProvider
| Signature | Returns | Description |
|---|---|---|
public EntraActorProvider(IHttpContextAccessor httpContextAccessor, IOptions<EntraActorOptions> options) |
— | Builds the Entra-specific provider; passes ActorIdClaim = options.Value.IdClaimType and PermissionsClaim = "roles" to the base. |
public override Task<Maybe<Actor>> GetCurrentActorAsync(CancellationToken cancellationToken = default) |
Task<Maybe<Actor>> |
Returns Maybe<Actor>.None when no authenticated identity exists or the configured ID claim is missing — the mediator pipeline maps that to Error.AuthenticationRequired (HTTP 401). When IdClaimType is the long objectidentifier claim, falls back to the short "oid" claim before returning None. Throws InvalidOperationException only when HttpContext is missing (configuration bug, surfaces as HTTP 500); any exception from MapPermissions, MapForbiddenPermissions, or MapAttributes is rewrapped in InvalidOperationException naming the failing delegate. |
Azure "Easy Auth" — EasyAuthAuthenticationHandler / EasyAuthClaimsActorProvider
For apps behind Azure App Service / Container Apps built-in authentication ("Easy Auth"), the platform authenticates the user and injects the principal as request headers (X-MS-CLIENT-PRINCIPAL, -ID, -NAME, -IDP), stripping any client-supplied copies at the boundary. Trellis models this as two layers — authentication and actor mapping — rather than a bespoke header-parsing IActorProvider:
EasyAuthAuthenticationHandler : AuthenticationHandler<EasyAuthAuthenticationOptions>decodes the base64-JSONX-MS-CLIENT-PRINCIPAL(honoringauth_typ/name_typ/role_typand the{ "typ", "val" }claim shape) ontoHttpContext.User, falling back to-ID/-NAMEwhen the principal header is absent. No Easy Auth header →AuthenticateResult.NoResult()(anonymous); malformed header →Fail(...)(fail closed). Register withAddAuthentication(...).AddEasyAuth().EasyAuthClaimsActorProvider : ClaimsActorProvidermaps thoseHttpContext.Userclaims to theActorexactly likeClaimsActorProvider, overriding onlyVaryByHeadersto name the Easy Auth principal headers soVaryForActor()partitions caches by the platform principal instead ofAuthorization. Register withAddEasyAuthActorProvider(...)/UseEasyAuthActorProvider(...).
Trust precondition. These headers are trustworthy only when the app is reachable exclusively through the Easy Auth front end. If a path bypasses Easy Auth (a misconfigured ingress or local development), a client can forge them and impersonate any actor. Enable the handler only when that boundary holds — it is never auto-registered.
| Signature | Returns | Description |
|---|---|---|
public EasyAuthAuthenticationHandler(IOptionsMonitor<EasyAuthAuthenticationOptions> options, ILoggerFactory logger, UrlEncoder encoder) |
— | Standard AuthenticationHandler constructor. |
protected override Task<AuthenticateResult> HandleAuthenticateAsync() |
Task<AuthenticateResult> |
Decodes the principal header (or -ID / -NAME fallback) into an authenticated ClaimsPrincipal. NoResult when no Easy Auth header is present; Fail on invalid base64/JSON or a payload without a usable claims array. |
public sealed class EasyAuthClaimsActorProvider : ClaimsActorProvider |
— | Inherits actor resolution from ClaimsActorProvider; overrides VaryByHeaders to return EasyAuthDefaults.PrincipalHeaders. |
public static AuthenticationBuilder AddEasyAuth(this AuthenticationBuilder builder) |
AuthenticationBuilder |
Defined on EasyAuthAuthenticationExtensions. Registers the EasyAuthAuthenticationHandler scheme under the default name EasyAuthDefaults.AuthenticationScheme ("EasyAuth") with no extra configuration. |
public static AuthenticationBuilder AddEasyAuth(this AuthenticationBuilder builder, Action<EasyAuthAuthenticationOptions> configureOptions) |
AuthenticationBuilder |
Same, with an options callback. |
public static AuthenticationBuilder AddEasyAuth(this AuthenticationBuilder builder, string authenticationScheme, Action<EasyAuthAuthenticationOptions> configureOptions) |
AuthenticationBuilder |
Same, under an explicit scheme name. The other two overloads delegate to this one. |
EasyAuthAuthenticationOptions carries only the standard AuthenticationSchemeOptions surface (events, forwarding); the Easy Auth principal header names are fixed by the Azure platform contract (EasyAuthDefaults), so the handler and EasyAuthClaimsActorProvider.VaryByHeaders always agree.
EasyAuthDefaults
Constants for the platform contract, plus one static property. Do not hard-code these header names — the platform strips client-supplied copies of exactly these headers at the boundary, so the names are the security contract.
| Member | Value |
|---|---|
AuthenticationScheme |
"EasyAuth" — the default scheme name registered by AddEasyAuth. |
PrincipalHeaderName |
"X-MS-CLIENT-PRINCIPAL" — the base64-encoded JSON client principal. |
PrincipalIdHeaderName |
"X-MS-CLIENT-PRINCIPAL-ID" |
PrincipalNameHeaderName |
"X-MS-CLIENT-PRINCIPAL-NAME" |
PrincipalIdpHeaderName |
"X-MS-CLIENT-PRINCIPAL-IDP" — the identity provider name. |
PrincipalHeaders |
public static IReadOnlyCollection<string> PrincipalHeaders { get; } — a static property, not a constant, holding the four header names above. Returned by EasyAuthClaimsActorProvider.VaryByHeaders. |
builder.Services.AddAuthentication(EasyAuthDefaults.AuthenticationScheme).AddEasyAuth();
builder.Services.AddEasyAuthActorProvider(opts =>
{
opts.ActorIdClaim = "http://schemas.microsoft.com/identity/claims/objectidentifier";
opts.PermissionsClaim = "roles";
});
// ...
app.UseAuthentication();
RolePermissionProjection
Static helper (namespace Trellis.Asp.Authorization) that flattens coarse role names into the granular permission set an Actor carries, using an application-supplied role→permissions map. Replaces the hand-rolled roles.SelectMany(r => map[r]) shape — which throws on an unmapped role — with a skip-unknown, ordinal, deduplicated projection consistent with the pre-flatten guidance in trellis-api-authorization.md.
| Signature | Returns | Description |
|---|---|---|
public static IReadOnlySet<string> ExpandRoles(IEnumerable<string> roles, IReadOnlyDictionary<string, IReadOnlyCollection<string>> rolePermissions) |
IReadOnlySet<string> |
Provider-agnostic core: expands role names into a flattened, ordinal-deduplicated permission set. Roles absent from the map are skipped (no throw); null/whitespace role names and permission values are ignored. Lookups use the map's own key comparer, so a case-insensitive map matches case-variant roles. Throws ArgumentNullException when either argument is null. |
public static Func<IEnumerable<Claim>, IReadOnlySet<string>> ForRoleClaims(IReadOnlyDictionary<string, IReadOnlyCollection<string>> rolePermissions, string? roleClaimType = null, bool keepRolesAsPermissions = false) |
Func<IEnumerable<Claim>, IReadOnlySet<string>> |
Builds an EntraActorOptions.MapPermissions-compatible delegate that reads role claims and expands them via the map. roleClaimType null (default) matches both the short roles claim and ClaimTypes.Role (case-insensitive), tolerating JwtBearerOptions.MapInboundClaims either way; pass a type to match only that. keepRolesAsPermissions = true also adds each matched role name to the result. Throws ArgumentNullException when the map is null, ArgumentException when roleClaimType is non-null but blank. |
The map is application-owned data captured by reference — supply a stable, effectively-immutable map (it is read once per request, concurrently). Roles the map does not contain are silently skipped, so a token carrying a role the service does not recognize yields the recognized permissions rather than a 500.
// Application-owned role→permissions catalog.
var rolePermissions = new Dictionary<string, IReadOnlyCollection<string>>
{
["orders.reader"] = ["orders:read"],
["orders.manager"] = ["orders:read", "orders:write", "orders:cancel"],
};
builder.Services.AddEntraActorProvider(options =>
{
options.MapPermissions = RolePermissionProjection.ForRoleClaims(rolePermissions);
});
// Or, from a custom IActorProvider / gateway that already has the role names:
IReadOnlySet<string> permissions = RolePermissionProjection.ExpandRoles(roleNames, rolePermissions);
TrellisInternalJwtActorOptions / TrellisInternalJwtActorProvider
Moved. These types lived under
Trellis.Asp.Authorizationin earlier previews. They moved to the newxavierjohn/Trellis.Microservicesrepository in v3 as part of the carve-out that consolidates microservice trust-boundary code (gateway minter + consumer hydration). New namespace:Trellis.Microservices.AspNetCore. The API reference is now attrellis-api-internal-jwt.md.Migration: replace
using Trellis.Asp.Authorization;(for theTrellisInternalJwt*types) withusing Trellis.Microservices.AspNetCore;, add aTrellis.Microservices.AspNetCoreNuGet reference, and replaceservices.AddTrellis(b => b.UseTrellisInternalJwtActor(...))withservices.AddTrellisInternalJwtActorProvider(...)(theUseTrellisInternalJwtActorslot was removed fromTrellisServiceBuilderin this v3 cleanup).
DevelopmentActorOptions
Declaration
public sealed class DevelopmentActorOptions
| Name | Type | Description |
|---|---|---|
DefaultActorId |
string |
Default fallback actor ID. Default: "development". |
DefaultPermissions |
IReadOnlySet<string> |
Default fallback permissions when no header is supplied. Default: empty HashSet<string>. |
ThrowOnMalformedHeader |
bool |
When true (the default), a malformed X-Test-Actor header throws instead of falling back to the default actor — a malformed header is a developer error, distinct from an absent header. Set to false to restore the lenient fall-back-to-default behavior. Default: true. |
DevelopmentActorProvider
Declaration
public sealed partial class DevelopmentActorProvider(
IHttpContextAccessor httpContextAccessor,
IHostEnvironment hostEnvironment,
IOptions<DevelopmentActorOptions> options,
ILogger<DevelopmentActorProvider> logger) : IActorProvider, IProvideActorVaryHeaders
Reads the X-Test-Actor header (JSON: { "Id": ..., "Permissions": [...], "ForbiddenPermissions": [...], "Attributes": {...} }, case-insensitive property matching).
| Signature | Returns | Description |
|---|---|---|
public Task<Maybe<Actor>> GetCurrentActorAsync(CancellationToken cancellationToken = default) |
Task<Maybe<Actor>> |
Throws InvalidOperationException whenever !hostEnvironment.IsDevelopment(), regardless of header presence. In Development, always returns Maybe.From(actor) — never Maybe.None — so dev workflows are unaffected by the 401 contract: Maybe.From(Actor.Create(DefaultActorId, DefaultPermissions)) when HttpContext is null or the header is missing/empty, otherwise the parsed actor wrapped via Maybe.From. Malformed JSON throws InvalidOperationException by default (ThrowOnMalformedHeader defaults to true); set it to false to instead log a warning and fall back to the default actor. |
CachingActorProvider
Declaration
public sealed class CachingActorProvider : IActorProvider, IProvideActorVaryHeaders
Decorator that caches the inner provider's resolution task per request scope using LazyInitializer.EnsureInitialized. The shared task uses HttpContext.RequestAborted so expensive work (DB lookups) is canceled with the request, but individual callers' tokens only cancel their own awaits.
When a handler uses RequireActorAsync from Trellis.Authorization after authorization,
both lookups must use this same scoped wrapper (or a provider with an explicit stability
guarantee). Configure the wrapper before dispatch; caching only the handler's lookup does
not preserve the actor identity or permission snapshot previously checked by authorization.
| Signature | Returns | Description |
|---|---|---|
public CachingActorProvider(IActorProvider inner, IHttpContextAccessor httpContextAccessor) |
— | inner cannot be null. |
public Task<Maybe<Actor>> GetCurrentActorAsync(CancellationToken cancellationToken = default) |
Task<Maybe<Actor>> |
Returns the cached task — including a cached Maybe<Actor>.None when the inner provider resolved no authenticated actor — so the inner provider runs once per request scope. If cancellationToken is cancelable (CanBeCanceled) and differs from RequestAborted, applies it to the await via Task.WaitAsync; otherwise the cached task is returned as-is. |
Namespace Trellis.Asp.ModelBinding
ScalarValueModelBinderBase<TResult, TValue, TPrimitive>
Declaration
public abstract class ScalarValueModelBinderBase<TResult, TValue, TPrimitive> : IModelBinder
where TValue : IScalarValue<TValue, TPrimitive>
where TPrimitive : IComparable
| Signature | Returns | Description |
|---|---|---|
protected abstract ModelBindingResult OnMissingValue() |
ModelBindingResult |
Called when no raw value is present in the value provider. |
protected virtual ModelBindingResult? OnEmptyValue() => null |
ModelBindingResult? |
Called when the raw value is an empty string; return null to fall through to normal conversion. For string-typed scalar VOs (TPrimitive == string), the empty string is forwarded to TValue.TryCreate("") so the value object decides whether empty input is valid. For non-string primitives, empty strings continue to fail with the standard "value is required" message before reaching TryCreate. |
protected abstract ModelBindingResult OnSuccess(TValue value) |
ModelBindingResult |
Wraps a validated scalar value into the final binding result. |
public Task BindModelAsync(ModelBindingContext bindingContext) |
Task |
Reads the raw value, converts to TPrimitive, calls TValue.TryCreate, and populates ModelState on failure. |
ScalarValueModelBinder<TValue, TPrimitive>
Declaration
public class ScalarValueModelBinder<TValue, TPrimitive>
: ScalarValueModelBinderBase<TValue, TValue, TPrimitive>
where TValue : IScalarValue<TValue, TPrimitive>
where TPrimitive : IComparable
| Signature | Returns | Description |
|---|---|---|
protected override ModelBindingResult OnMissingValue() |
ModelBindingResult |
Leaves the binding result unset (default). |
protected override ModelBindingResult OnSuccess(TValue value) |
ModelBindingResult |
Returns ModelBindingResult.Success(value). |
MaybeModelBinder<TValue, TPrimitive>
Declaration
public class MaybeModelBinder<TValue, TPrimitive>
: ScalarValueModelBinderBase<Maybe<TValue>, TValue, TPrimitive>
where TValue : IScalarValue<TValue, TPrimitive>
where TPrimitive : IComparable
| Signature | Returns | Description |
|---|---|---|
protected override ModelBindingResult OnMissingValue() |
ModelBindingResult |
Returns ModelBindingResult.Success(Maybe<TValue>.None). |
protected override ModelBindingResult? OnEmptyValue() |
ModelBindingResult? |
Returns ModelBindingResult.Success(Maybe<TValue>.None). |
protected override ModelBindingResult OnSuccess(TValue value) |
ModelBindingResult |
Returns ModelBindingResult.Success(Maybe.From(value)). |
MaybePrimitiveModelBinder<T>
Declaration
public sealed class MaybePrimitiveModelBinder<T> : IModelBinder
where T : notnull
Binds Maybe<T> parameters where T is a primitive in the closed allowed list (string, decimal, int, long, short, byte, double, float, bool, Guid, DateTime, DateTimeOffset) from route / query / form / header sources. Counterpart of MaybeModelBinder<,> for the no-scalar-VO case — the new MaybePrimitiveJsonConverterFactory handles the JSON body side of the same shape. The allowed list itself is exposed via the non-generic MaybePrimitives helper so the FrozenSet<Type> is allocated once for the framework rather than once per closed generic instantiation.
| Signature | Returns | Description |
|---|---|---|
public Task BindModelAsync(ModelBindingContext bindingContext) |
Task |
Missing or empty value → ModelBindingResult.Success(Maybe<T>.None). Parseable primitive → ModelBindingResult.Success(Maybe.From(parsed)). Unparseable → adds a model-state error and returns ModelBindingResult.Failed(). Parses using invariant culture and the typed TryParse methods on each primitive type. |
MaybePrimitives
Note
MaybePrimitives lives in Trellis.Asp.Validation, not Trellis.Asp.ModelBinding — it is documented here because MaybePrimitiveModelBinder<T> above is its main consumer.
Declaration
public static class MaybePrimitives
Non-generic holder for the closed Maybe<T> primitive allowed list shared by MaybePrimitiveJsonConverterFactory and MaybePrimitiveModelBinder<T>. Exists as a non-generic class so the FrozenSet<Type> is shared across all closed generic instantiations of the binder (avoids per-T allocation and the CA1000 "no static members on generic types" guidance).
| Signature | Returns | Description |
|---|---|---|
public static readonly FrozenSet<Type> SupportedPrimitives |
FrozenSet<Type> |
The 12-type allowed list: string, decimal, int, long, short, byte, double, float, bool, Guid, DateTime, DateTimeOffset. Used by both the JSON converter factory's CanConvert and the model binder provider's GetBinder. |
ScalarValueModelBinderProvider
Declaration
public class ScalarValueModelBinderProvider : IModelBinderProvider
| Signature | Returns | Description |
|---|---|---|
public IModelBinder? GetBinder(ModelBinderProviderContext context) |
IModelBinder? |
Returns a MaybeModelBinder<,> for Maybe<TScalar> where TScalar : IScalarValue<,>, a MaybePrimitiveModelBinder<T> for Maybe<T> where T is in MaybePrimitives.SupportedPrimitives, a ScalarValueModelBinder<,> for direct scalar values, or null otherwise. Annotated [UnconditionalSuppressMessage] for IL2070/IL2072/IL2075 and IL3050 — model binding is not Native AOT compatible. |
Namespace Trellis.Asp.Routing
TrellisValueObjectRouteConstraint<T>
Declaration
public sealed class TrellisValueObjectRouteConstraint<T> : IRouteConstraint
where T : IParsable<T>
| Signature | Returns | Description |
|---|---|---|
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) |
bool |
Delegates to T.TryParse(..., CultureInfo.InvariantCulture, out _). Returns false when the route value is missing, null, or fails to parse. |
RouteConstraintRegistrationExtensions
Declaration
public static class RouteConstraintRegistrationExtensions
| Signature | Returns | Description |
|---|---|---|
public static IServiceCollection AddTrellisRouteConstraints(this IServiceCollection services, params Assembly[] assemblies) |
IServiceCollection |
Scans the supplied assemblies (or the calling assembly + the assembly containing IScalarValue<,> from Trellis.Core if none are supplied) for value objects implementing both IScalarValue<TSelf, TPrimitive> and IParsable<TSelf>, then registers a TrellisValueObjectRouteConstraint<T> under the type's simple name. Existing entries in RouteOptions.ConstraintMap are preserved. Reflection-based — not Native AOT compatible. |
public static IServiceCollection AddTrellisRouteConstraint<T>(this IServiceCollection services, string? constraintName = null) where T : IParsable<T> |
IServiceCollection |
Registers a single value-object route constraint without reflection. AOT-safe. |
Once registered, route templates such as "/products/{id:ProductId}" parse and bind the segment via the value object's IParsable<T>.TryParse implementation.
Namespace Trellis.Asp.Validation
ScalarValueJsonConverterBase<TResult, TValue, TPrimitive>
Declaration
public abstract class ScalarValueJsonConverterBase<TResult, TValue, TPrimitive>
: JsonConverter<TResult>
where TValue : class, IScalarValue<TValue, TPrimitive>
where TPrimitive : IComparable
| Name | Type | Description |
|---|---|---|
HandleNull |
bool (override) |
Always true; forces System.Text.Json to call Read(...) for JSON null tokens. |
| Signature | Returns | Description |
|---|---|---|
protected abstract TResult OnNullToken(string fieldName) |
TResult |
Returns the deserialization result for a JSON null token. |
protected abstract TResult WrapSuccess(TValue value) |
TResult |
Wraps a validated scalar value into the final converter result. |
protected abstract TResult OnValidationFailure() |
TResult |
Returns the failure result after a validation error has been collected into ValidationErrorsContext. |
public override TResult Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) |
TResult |
Reads the primitive JSON value, calls TValue.TryCreate, collects errors into ValidationErrorsContext, and returns the derived-type wrapper. |
protected static string GetDefaultFieldName() |
string |
Returns the camel-cased scalar type name used when no property name is available. |
ValidatingJsonConverter<TValue, TPrimitive>
Declaration
public sealed class ValidatingJsonConverter<TValue, TPrimitive>
: ScalarValueJsonConverterBase<TValue?, TValue, TPrimitive>
where TValue : class, IScalarValue<TValue, TPrimitive>
where TPrimitive : IComparable
| Signature | Returns | Description |
|---|---|---|
protected override TValue? OnNullToken(string fieldName) |
TValue? |
Adds "{TypeName} cannot be null." to ValidationErrorsContext and returns null. |
protected override TValue? WrapSuccess(TValue value) |
TValue? |
Returns the validated scalar value. |
protected override TValue? OnValidationFailure() |
TValue? |
Returns null. |
public override void Write(Utf8JsonWriter writer, TValue? value, JsonSerializerOptions options) |
void |
Writes JSON null for null; otherwise writes the underlying primitive value.Value. |
ValidatingJsonConverterFactory
Declaration
public sealed class ValidatingJsonConverterFactory : JsonConverterFactory
| Signature | Returns | Description |
|---|---|---|
public override bool CanConvert(Type typeToConvert) |
bool |
true when typeToConvert is a scalar value type. |
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) |
JsonConverter? |
Builds a ValidatingJsonConverter<TValue, TPrimitive> for supported scalar value types. Suppresses IL3050 — JsonConverterFactory dynamic converter creation is not Native AOT compatible, and Trellis only registers this factory when JsonSerializer.IsReflectionEnabledByDefault is true, so the path is unreachable under AOT. |
MaybeScalarValueJsonConverter<TValue, TPrimitive>
Declaration
public sealed class MaybeScalarValueJsonConverter<TValue, TPrimitive>
: ScalarValueJsonConverterBase<Maybe<TValue>, TValue, TPrimitive>
where TValue : class, IScalarValue<TValue, TPrimitive>
where TPrimitive : IComparable
| Signature | Returns | Description |
|---|---|---|
protected override Maybe<TValue> OnNullToken(string fieldName) |
Maybe<TValue> |
Returns Maybe<TValue>.None; JSON null is valid for optional scalar values. |
protected override Maybe<TValue> WrapSuccess(TValue value) |
Maybe<TValue> |
Returns Maybe.From(value). |
protected override Maybe<TValue> OnValidationFailure() |
Maybe<TValue> |
Returns Maybe<TValue>.None. |
public override void Write(Utf8JsonWriter writer, Maybe<TValue> value, JsonSerializerOptions options) |
void |
Writes JSON null for Maybe.None; otherwise writes the wrapped primitive value.Value.Value. |
MaybeScalarValueJsonConverterFactory
Declaration
public sealed class MaybeScalarValueJsonConverterFactory : JsonConverterFactory
| Signature | Returns | Description |
|---|---|---|
public override bool CanConvert(Type typeToConvert) |
bool |
true when typeToConvert is Maybe<T> and T is a scalar value type. |
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) |
JsonConverter? |
Builds MaybeScalarValueJsonConverter<TValue, TPrimitive> for supported Maybe<TScalar> types. |
MaybePrimitiveJsonConverter<T>
Declaration
public sealed class MaybePrimitiveJsonConverter<T> : JsonConverter<Maybe<T>>
where T : notnull
JSON converter for Maybe<T> where T is an STJ-native primitive in the closed allowed list enforced by MaybePrimitiveJsonConverterFactory. Reads dispatch on typeof(T) to typed Utf8JsonReader methods (GetString / GetInt32 / GetDecimal / GetGuid / GetDateTime / etc.) — no reflection, no JsonSerializer round-trip, AOT-safe by construction. null token → Maybe<T>.None; primitive value → Maybe.From(value). None writes as JSON null.
| Signature | Returns | Description |
|---|---|---|
public override Maybe<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) |
Maybe<T> |
Reads JSON null as Maybe<T>.None; otherwise dispatches on the closed primitive allowed list. A wrong JSON shape is not normalized by this converter — the exception from the underlying Utf8JsonReader typed accessor (GetString(), GetInt32(), GetGuid(), …) propagates as-is. The converter's own explicit JsonException is reserved for the unreachable unsupported-T guard. |
public override void Write(Utf8JsonWriter writer, Maybe<T> value, JsonSerializerOptions options) |
void |
Maybe.None writes JSON null; otherwise switches on the unwrapped value and writes via the matching typed Utf8JsonWriter method. |
MaybePrimitiveJsonConverterFactory
Declaration
public sealed class MaybePrimitiveJsonConverterFactory : JsonConverterFactory
Closes the asymmetry where MaybeScalarValueJsonConverterFactory shipped support for Maybe<TScalar> (typed value objects) but Maybe<long> / Maybe<int> / Maybe<string> / Maybe<DateTime> etc. fell through to STJ's default object handling, producing JSON the converter cannot itself parse back. Registered by AddScalarValueValidation() (or the convenience helper AddTrellisAspWithScalarValidation()) alongside the scalar factory (same JsonSerializer.IsReflectionEnabledByDefault gate for AOT). The supported primitive set deliberately mirrors CompositeValueObjectJsonConverter<T>'s allowed list: the rule is "Maybe<T> works wherever T is a primitive Trellis already supports directly".
Supported primitives: string, decimal, int, long, short, byte, double, float, bool, Guid, DateTime, DateTimeOffset. Shapes outside this set (DateOnly, TimeOnly, unsigned numerics, arrays, collections, nested composites) continue to require the wire-shape DTO + adapter pattern (Cookbook Recipe 14).
| Signature | Returns | Description |
|---|---|---|
public override bool CanConvert(Type typeToConvert) |
bool |
true when typeToConvert is Maybe<T> and T is in the closed primitive allowed list. Returns false for Maybe<TScalar> (handled by MaybeScalarValueJsonConverterFactory) and for unsupported primitive shapes (e.g. Maybe<DateOnly>) so the two factories don't compete. |
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) |
JsonConverter? |
Builds MaybePrimitiveJsonConverter<T> for the inner primitive type. |
ScalarValueValidationFilter
Important
ScalarValueValidationFilter, ScalarValueValidationEndpointFilter, ScalarValueValidationMiddleware and ValidationErrorsContext live in the root Trellis.Asp namespace, not Trellis.Asp.Validation, despite appearing under that heading. Only ScalarValuePathTracking (below) is actually in Trellis.Asp.Validation. Import Trellis.Asp to reference the four types above.
Declaration
public sealed class ScalarValueValidationFilter : IActionFilter, IOrderedFilter
| Name | Type | Description |
|---|---|---|
Order |
int |
Always -2000; runs early in the MVC filter pipeline. |
| Signature | Returns | Description |
|---|---|---|
public void OnActionExecuting(ActionExecutingContext context) |
void |
Short-circuits with a validation problem result for collected JSON validation errors, structured TrellisJsonValidationException entries in ModelState, invalid scalar route/query parameters, or any remaining invalid ModelState entry (the final else if (!ModelState.IsValid) fallback — this is the path a plain type-conversion failure takes). A plain JsonException in ModelState takes precedence and forces the 400 path rather than 422. |
public void OnActionExecuted(ActionExecutedContext context) |
void |
No-op. |
ScalarValueValidationEndpointFilter
Declaration
public sealed class ScalarValueValidationEndpointFilter : IEndpointFilter
| Signature | Returns | Description |
|---|---|---|
public ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) |
ValueTask<object?> |
For Minimal APIs, returns Results.ValidationProblem(...) with the configured Error.InvalidInput status (default 422, see MapError) and MVC dot+bracket field keys when ValidationErrorsContext contains errors; otherwise invokes next. |
ScalarValueValidationMiddleware
Declaration
public sealed class ScalarValueValidationMiddleware
| Signature | Returns | Description |
|---|---|---|
public ScalarValueValidationMiddleware(RequestDelegate next) |
— | Stores the next RequestDelegate. The scope itself is opened in InvokeAsync, which wraps each request in ValidationErrorsContext.BeginScope(). |
public Task InvokeAsync(HttpContext context) |
Task |
Begins a validation scope, invokes the next middleware, and converts scalar-value BadHttpRequestException binding failures into validation problem responses using endpoint parameter metadata plus route/query raw values. |
ScalarValuePathTracking
Declaration
public static class ScalarValuePathTracking
Registry of compile-time-closed path-tracking converter factories. Populated by a [ModuleInitializer] emitted by PathTrackingRegistryGenerator; consulted by the type-info modifier before the reflection fallback, which is how index-precise field paths work under Native AOT. Application code does not normally call these members directly — they exist so generated code (or a hand-written registration for a DTO the generator cannot see) can supply the closed generics the runtime cannot construct.
| Signature | Returns | Description |
|---|---|---|
public static void RegisterProperty<T>() |
void |
Registers a factory producing PathTrackingPropertyConverter<T> for a scalar or optional scalar property, so Native AOT validation uses the effective JSON property name supplied at runtime. The wrapper is applied only when the property has no explicit property-level converter. Idempotent. |
public static void RegisterObject<T>() |
void |
Registers a factory producing PathTrackingObjectConverter<T> for a nested user object whose graph transitively contains a scalar value object. Idempotent. |
public static void RegisterCollection<TCollection, TElement>() |
void |
Registers a factory producing PathTrackingCollectionConverter<TCollection, TElement> for a collection property, so element indexes appear in the reported path. A direct scalar element reports the indexed element pointer itself (for example /allergens/0); a scalar nested in an element object appends its JSON property name (for example /members/0/email). Idempotent. |
public static void RegisterDictionary<TDictionary, TValue>() |
void |
Registers a factory producing PathTrackingDictionaryConverter<TDictionary, TValue> for a string-keyed dictionary property. A direct scalar value reports the keyed entry itself (/prices/USD); a scalar nested in a value object appends its JSON property name (/prices/USD/amount). Idempotent. Non-string keys are deliberately unsupported: they have no faithful RFC 6901 rendering, so such properties fall back to leaf-only paths rather than emitting a pointer the client cannot map back to the input it sent. Keys are escaped per RFC 6901 (~→~0, /→~1), so a key containing those characters stays one segment. |
ValidationErrorsContext
Declaration
public static class ValidationErrorsContext
| Name | Type | Description |
|---|---|---|
HasErrors |
bool |
true when the current async-local scope contains at least one collected validation error. |
CurrentPropertyName |
string? (get/set) |
Async-local property name for the property currently being deserialized. Set to the effective JSON name by PropertyNameAwareConverter<T> in reflection mode and PathTrackingPropertyConverter<T> under Native AOT. PathTrackingCollectionConverter<,> and PathTrackingDictionaryConverter<,> temporarily set it to the empty string for direct converter-backed elements/values; the empty string resolves to the current indexed or keyed ancestor. Read by both the reflection-mode ScalarValueJsonConverterBase<,,> and the AOT-generated converter, which falls back to a camel-cased type name only when this is null. |
| Signature | Returns | Description |
|---|---|---|
public static IDisposable BeginScope() |
IDisposable |
Starts a new async-local validation collection scope; disposing restores the previous scope and property name. |
public static void AddError(string fieldName, string errorMessage) |
void |
Appends a single field violation to the current scope, with FieldViolation.ReasonCode set to error.unspecified — a bare message carries no code to report. Use the Error.InvalidInput overload when the producer knows its code. No-op when no scope is active. |
public static void AddError(Error.InvalidInput unprocessableContent) |
void |
Merges every FieldViolation and RuleViolation in the supplied error into the current scope, preserving each violation's reason code, args, and detail. No-op when no scope is active. |
public static void AddBodyError(string fieldName, string errorMessage) |
void |
As AddError(string, string) — including the error.unspecified reason code — but locates the violation at InputLocation.Body. Both reflection-mode and AOT-generated scalar converters use this overload for non-Error.InvalidInput failures and forward structured failures through AddBodyError(Error.InvalidInput). |
public static void AddBodyError(string fieldName, string code, string message, IReadOnlyDictionary<string, ValidationArgValue>? args = null) |
void |
A coded body violation with optional renderer arguments. No-op when no scope is active. |
public static void AddBodyError(Error.InvalidInput unprocessableContent) |
void |
As AddError(Error.InvalidInput), but promotes every merged violation to InputLocation.Body when the producer left the location unspecified. The JSON pipeline is the one caller that knows by construction that everything it collects came out of the request document — a converter cannot state that itself, since the same converter runs for a body property and for a query-bound scalar. Violations carrying an explicit Query, Path or Header location pass through untouched. A violation already located in the body is prefixed with the current ancestor path but keeps its location; one with an unspecified location is prefixed and promoted to InputLocation.Body. |
public static Error.InvalidInput? GetUnprocessableContent() |
Error.InvalidInput? |
Returns the aggregated Error.InvalidInput for the current scope (with Fields / Rules populated from collected FieldViolations) or null when no errors were collected. |
Behavioral notes
One verb, every shape.
ToHttpResponseis the only supported response mapper. The generic result types it constructs (TrellisHttpResult<TDomain, TBody>,TrellisWriteOutcomeResult<TDomain, TBody>, and the paged success wrapper) implementIResult. Beyond that the two differ, and OpenAPI/ApiExplorer output differs with them:TrellisHttpResult<TDomain, TBody>also implementsIStatusCodeHttpResult,IValueHttpResult,IValueHttpResult<TBody>,IContentTypeHttpResultandIEndpointMetadataProvider. It emits200,201,304,400,404,412,500metadata — or, for theResult<Unit>/no-body shape,204,400,404,500.TrellisWriteOutcomeResult<TDomain, TBody>implements onlyIStatusCodeHttpResultandIEndpointMetadataProvider— notIValueHttpResult<T>orIContentTypeHttpResult. It emits200,201,204,202,400,412,500metadata, including configured-location failures.
Standalone
Error.ToHttpResponse(...)usesTrellisErrorOnlyResult, anIResultfailure writer. Layer your own[ProducesResponseType]/Produces<T>on top.Failures use Problem Details. A failure runs through
ResponseFailureWriter(internal).Error.InvalidInputwith field violations usesResults.ValidationProblem(...); everything else usesResults.Problem(...). Theerrorsdictionary keys are the violationField.Pathtranslated from RFC 6901 JSON Pointer to ASP.NET Core MVC dot+bracket convention, and the RFC 6901 pointers are preserved losslessly beside that map — per field under the top-levelfieldViolationsarray'slocationmember, and per rule under theruleViolationsarray'slocations[]entries (populated viaProblemDetails.Extensions["fieldViolations"]/["ruleViolations"]). Companion headers are emitted automatically:AllowforError.TransportFault(new HttpError.MethodNotAllowed(...)),Content-Range: {Unit} */{CompleteLength}forError.TransportFault(new HttpError.RangeNotSatisfiable(...)),Retry-AfterfromRetryAdviceonError.RateLimited/Error.Unavailable, andWWW-AuthenticatefromError.AuthenticationRequired.Schemeor the registeredIAuthenticationSchemeProviderfallback when the resolved status is401. ForError.TransportFault, the top-level Problem Details extension memberscodeandkindcome from the wrappedHttpError, not the outertransport-faultenvelope. Every failure response carries top-levelcodeandkind(RFC 9457 §3.2 extension members, populated viaProblemDetails.Extensions["code"]/["kind"]and serialized at the JSON root, not nested under anextensionsobject);Error.Unexpectedadds top-levelfaultIdwhen set; rule violations are surfaced under the top-levelruleViolationsarray;Error.Aggregateadds top-levelproblems; every response also carries top-levelinstance. For5xxresponses the publicdetailis always"An internal error occurred.", and the same redaction is applied per child insideError.Aggregate's top-levelproblemsarray — a child whose own mapped status is5xxreports the redacted detail even when the envelope status is not.The
code/kindenvelope holds for every failure, not just handler failures. A request can fail before it ever reaches a handler, and those seams write their own Problem Details:ScalarValueValidationFilter(MVC),ScalarValueValidationEndpointFilter(Minimal API),ScalarValueValidationMiddleware, andIdempotencyMiddleware.AddTrellisProblemDetails()covers the last gap, seeding the envelope on documents ASP.NET Core itself produced — the exception handler and status-code pages underUseTrellisProblemDetails(); it seeds each member independently, so a document that already carries one of them still gains the other. All of them emit the same top-levelcodeandkindmembers asResponseFailureWriter, so a client can read the envelope without first knowing which layer answered.typeis resolved the same way everywhere too — see the rule below. Two rules decide whatcodeandkindcarry:- When an
Errorexists, the envelope comes from the error. A rejected scalar value is anError.InvalidInput, so it reportskind: unprocessable-contenteven whenMapError<Error.InvalidInput>(400)onTrellisAspOptionshas remapped the response to400.kindnames what failed, and remapping a status does not change that — otherwise the same rejection would describe itself differently depending on where it was caught.codeis the error's ownCode, which every case inherits and which defaults to the sentinel — so an error that named no reason reportserror.unspecifiedhere too. See Wirecodeand theerror.unspecifiedsentinel for naming one. - When no
Errorwas ever constructed,kindfalls back to the status;codecan still name the producer's reason. The status supplies the kind (400→bad-request,409→conflict,413→content-too-large,422→unprocessable-content,5xx→internal-server-error). A producer that supplies no reason useserror.unspecified, as with an unparseable request body or a route miss. Idempotency middleware supplies its ownidempotency.*codes instead: a missing required key reportsidempotency.key_required, and an in-flight duplicate reportsidempotency.in_flight. - These two rules describe where the members came from, not something a client can branch on. The sentinel is not evidence that no
Errorexisted: anError.NotFoundcarrying noCodereports exactly thecodeandkinda route miss reports underUseTrellisProblemDetails()—ProblemEnvelope.KindForStatus(404)is the samenot-foundslugError.NotFound.Kindreports, and both leavecodeat the sentinel. (The two documents are not necessarily identical:instanceis synthesized from theResourceRefby default, so it usually differs — but it is a URI reference identifying the occurrence, not a member a client can dispatch on.) If a client needs to tell "no such row" from "no such route", the producer must name a reason —error.unspecifiedrecords that no one made that decision, not that no error was involved. - The invariant covers responses Trellis writes — and, with
UseTrellisProblemDetails(), route misses too. Without that middleware a request matching no route is answered by ASP.NET Core's terminal middleware with a bodiless404: no Problem Details, so no envelope.UseTrellisProblemDetails()callsUseStatusCodePages(), which rewrites that bare404into Problem Details carrying the full Trellis envelope (application/problem+json,status,traceId,code,kind) — pinned byPipeline_returns_404_problem_details_with_trace_id_when_route_missing. Either way noErroris ever constructed, so the envelope comes from the status-fallback rule above andcodeiserror.unspecified. That includes a route whose constraint rejected the value (for example aGuid-constrained segment givennot-a-guid): constraint failure means "no route matched", not "the handler said not found".
- When an
typeis resolved from the status, never hard-coded. Every seam takes it fromProblemEnvelope.ProblemTypeForStatus(status), which asks ASP.NET Core for the default problem type it would assign that status — so a422written byIdempotencyMiddlewarecarries the sametypeas a422written byResponseFailureWriter, and the same status cannot describe itself two ways depending on which layer answered. Where the framework has no default (429,428,451,431,423,424),typeis omitted: RFC 9457 §3.1.1 makes an absenttypeequivalent toabout:blank, whereas writing a kind slug would put a bare non-URI token in a member declared to be a URI reference.Status code resolution precedence.
WithErrorMapping(Func<Error, int>)(per call) →WithErrorMapping<TError>(int)(per call, walks the type hierarchy) →TrellisAspOptionsresolved fromHttpContext.RequestServices(orTrellisAspOptions.SystemDefaultif none registered) →500 Internal Server Error. AFunc<Error, int>mapper opts out of a given error by returning any value outside100–599(default/0is the idiomatic choice); resolution then continues at the next step in the chain instead of writing an invalid status.Conditional requests.
EvaluatePreconditions()runs only onGET/HEADand only when at least one ofWithETag/WithLastModifiedis configured. The internalConditionalRequestEvaluatorevaluates RFC 9110 preconditions in this order:If-Match(strong); elseIf-Unmodified-Since; thenIf-None-Match(weak); elseIf-Modified-Sincefor safe methods. FailedIf-Match/If-Unmodified-Since→412; failedIf-None-Match/If-Modified-SinceonGET/HEAD→304.Varyis append-only. Both theHonorPrefer()switch andVary(...)useAppendVaryUnique— they preserve any pre-existingVaryvalues added by other middleware and skip duplicates (case-insensitive).HonorPrefer()semantics onWriteOutcome.Updated.HonorPrefer()is opt-in. Without it,Preferrequest headers are ignored entirely: noVary: Prefer, noPreference-Applied, andreturn=minimaldoes not suppress the body. WhenHonorPrefer()is configured,Prefer: return=minimalshort-circuits to204 No Contentand emitsPreference-Applied: return=minimal;return=representationreturns200 OKwith the body and emitsPreference-Applied: return=representation.Vary: Preferis always emitted underHonorPrefer(), regardless of which preference was sent.CreatedAtActionis not AOT-safe. It depends on MVC'sControllerLinkGeneratorExtensions. The builder method, the writer'sResolveActionLocationprivate, and theLocationKind.Actionbranch are annotated[RequiresUnreferencedCode]/[RequiresDynamicCode]. UseCreatedAtRoutewith a named route for trim/AOT scenarios;ResolveActionLocationthrowsNotSupportedExceptionwhenRuntimeFeature.IsDynamicCodeSupportedisfalse.Pagination. The
Result<Page<T>>overload always emits thePagedResponse<TBody>envelope; the RFC 8288Linkheader is added only whenPage.Nextand/orPage.Previouscursors are present. The direction-awareurlBuilder(cursor, direction, appliedLimit)distinguishesPageDirection.NextfromPageDirection.Previous; the legacynextUrlBuilder(cursor, appliedLimit)intentionally uses the same callback for both directions. Failure on the page result short-circuits through the standard error pipeline without invoking the URL builder or body projector. Conditional304/412responses also skip those callbacks. Each URL builder result is percent-encoded for the characters RFC 3986 excludes from a URI (controls, space,<,>,",\,^,`,{,},|) before it is embedded between theLinkfield's angle brackets, so an opaque cursor token cannot terminate theURI-Referenceearly or forge extra link-params. The envelope retains the original URL; well-formed URLs pass through byte-identical.Validation collection scope.
ScalarValueValidationMiddlewareopens aValidationErrorsContextscope per request. BothValidatingJsonConverter<,>andMaybeScalarValueJsonConverter<,>collect errors into this scope;ScalarValueValidationFilter(MVC) andScalarValueValidationEndpointFilter(Minimal API) short-circuit with a validation problem when the scope is non-empty at action/handler entry.AOT-generated converters participate in the same scope. The bundled source generator emits converters for supported scalar declarations in the current compilation, even without a
JsonSerializerContextor[GenerateScalarValueConverters]. That attribute enables the missing-[JsonSerializable]context diagnostic (TRLS059), not converter registration. The emittedJsonConverter<TValue>s use the same validation contract asScalarValueJsonConverterBase<,,>for the generator's supported primitives. They readValidationErrorsContext.CurrentPropertyName(falling back to the camel-cased type name), report JSON null asvalue.not-null, report blank strings for non-string primitives asvalue.not-empty, and classify rejected primitive tokens withValidationCodes.FormatCodeFor. They callTryCreate(primitive, fieldName)and forward structured failures throughValidationErrorsContext.AddBodyError(Error.InvalidInput), preserving reason codes, args and explicit locations while rebasing unspecified/body pointers against the ambient body path. Other failures use the message-onlyAddBodyErroroverload. Parity assumes the same ambient property/path tracking; installing a scalar converter alone does not establish a nested DTO path. The factoryTrellis.Generated.GeneratedValueObjectConverterFactoryis emitted alongside the per-type converters; consumers add it toJsonSerializerOptions.Converters(e.g. insideAddJsonOptions(o => o.JsonSerializerOptions.Converters.Add(new GeneratedValueObjectConverterFactory()))). It does not auto-register and does not registerMaybe<TValue>converters. For optional scalars, the closedMaybeScalarValueJsonConverter<TValue, TPrimitive>uses the same non-null validation semantics but accepts JSON null asMaybe.Nonewithout a violation; register it explicitly when composing an AOT converter set.[GenerateScalarValueConverters]cannot annotate your types for System.Text.Json. Roslyn source generators all analyze the same original compilation and cannot observe one another's output, so any attribute Trellis emits is invisible to System.Text.Json's generator. Two attributes must therefore be written by hand:- The context needs at least one
[JsonSerializable]. Without it STJ skips the context entirely and never emits the abstract members the base class requires, failing the build with two CS0534 errors that say nothing about the cause. Trellis reports TRLS059 to explain it. - Every value object reachable from a serialized type needs
[JsonConverter]on the value object itself, for example[JsonConverter(typeof(ParsableJsonConverter<OrderId>))]. Otherwise STJ reaches the type transitively, treats it as a POCO, and emitsObjectCreator = () => new OrderId()— a constructor that does not exist on a Trellis value object, failing with CS1729 inside STJ's own generated file. When you declare it, the Trellis primitive generator detects the attribute and steps aside rather than emitting a duplicate (which would be CS0579). - This applies only when the value object is declared in the same compilation as the context. Generator blindness is a within-compilation problem: once a value object lives in a referenced assembly, the
[JsonConverter]Trellis emitted has already been compiled into that assembly's metadata, so STJ reads it like any other attribute and routes the type through the converter without help. A solution that keeps value objects in a domain project and theJsonSerializerContextin the web project — the layout the Showcase uses — is unaffected and needs no hand-written[JsonConverter].
- The context needs at least one
Binder conversion failures carry a reason code. Route/query values that cannot be converted to the parameter's CLR type report a
ValidationCodesformat.*code naming the target type —format.integer,format.guid,format.date-time, and so on, falling back toformat.conversionwhen no more specific code applies. Out-of-range-for-type is also aformat.*code (int.TryParse("99999999999")andint.TryParse("abc")both returnfalseand are indistinguishable), except forbyte, where the binder emitsformat.integerwithargs: { min: 0, max: 255 }so the bound survives structurally. Enums split two ways: a supplied name that is not a member of the enum reportsenum.name-undefined, while a numeric value that parses into an undefined member reportsenum.undefined.Absent, blank, and malformed are three different codes. A missing value reports
value.not-null; a value that arrived but is empty or whitespace reportsvalue.not-empty; only a non-blank value the parser rejected reportsformat.*. Blank input is checked before the type parser for every non-stringtarget, because whitespace cannot parse into any scalar — letting it reachint.TryParsewould reportformat.integerfor a failure that has nothing to do with integers. String-typed primitives are exempt: the empty-vs-required decision belongs to the value object's ownTryCreate, since some scalar value objects legitimately accept an empty string.Minimal API scalar binding failures are metadata-driven. When ASP.NET Core throws a 400 while binding route/query parameters,
ScalarValueValidationMiddlewareno longer parsesBadHttpRequestException.Messageto discover a field name or invalid value. It inspectsIParameterBindingMetadata, reads the matching route/query raw value, and re-runs Trellis scalar validation forIScalarValue<,>/Maybe<TScalar>parameters. Non-scalar endpoint binding failures are rethrown to ASP.NET Core.Binder/JSON validation status is configurable. All three validation seams —
ScalarValueValidationMiddleware,ScalarValueValidationFilter(MVC), andScalarValueValidationEndpointFilter(Minimal API) — resolve the semantic-validation status from the ambientTrellisAspOptionsError.InvalidInputmapping (default422), the same map domain handlers use viaResponseFailureWriter. A singleMapError<Error.InvalidInput>(status)therefore governs scalar/value-object validation failures uniformly across the binder, the JSON body, and handlers. Syntactically malformed JSON is not remapped — it stays400(RFC 9110 §15.5.1).AddTrellisAspis error-mapping setup only. It registersTrellisAspOptionsandResourceCollectionNameRegistry; it does not chain scalar-value validation. CallAddTrellisAspWithScalarValidation()for the combined setup, or callAddTrellisAsp()plusAddScalarValueValidation()explicitly. You still needUseScalarValueValidation()middleware in the request pipeline andWithScalarValueValidation()on each Minimal API endpoint that should short-circuit on validation errors.Composite value objects in request/response DTOs.
AddTrellisAsp/AddScalarValueValidationonly wires the scalar VO converters. Composite VOs (multi-field[OwnedEntity]types likeShippingAddress,Money) bind throughCompositeValueObjectJsonConverter<T>(inTrellis.Primitives), which is opt-in per type via[JsonConverter(typeof(CompositeValueObjectJsonConverter<MyVo>))]on the value object class itself. Without that attribute, model binding falls back to default construction and silently bypassesTryCreate— the inner-field validation never runs and an invalid payload propagates into the domain layer. See Cookbook Recipe 13 for the full Domain + API JSON + EF pattern.Responses are JSON-only, whatever output formatters you register. Trellis always writes JSON, and both success and failure bypass content negotiation to get there — neither consults an output formatter, so registering one cannot change what a Trellis endpoint returns.
- Success responses bypass negotiation because
ToHttpResponse(...)produces anIResultthat writes its own body. A request sendingAccept: application/xmlagainst a Trellis endpoint receivesapplication/jsoneven when an XML formatter is registered and the body type is XML-serializable. The same action returning a plainOk(dto)would returnapplication/xml. - Failure responses bypass it because Trellis writes
application/problem+jsonitself, at both seams that produce a failure document.ResponseFailureWriter(handler failures) still constructs throughResults.Problem(...)/Results.ValidationProblem(...), so ASP.NET Core's owntitle/typedefaulting applies unchanged, but it does not hand the result toIProblemDetailsService— under MVC that writer negotiates onAccept.ProblemDetailsActionResult(the binder and JSON-body failuresScalarValueValidationFilteranswers before the handler runs) likewise writes its own body rather than executing an innerObjectResult; declaringproblem+jsonas that inner result's only content type is not sufficient, because MVC still selects an XML formatter for an XMLAccept. - Why negotiation had to go rather than be configured. Trellis carries
FieldViolationProblemDetailandRuleViolationProblemDetailvalues inProblemDetails.Extensions, which is typedobject?;DataContractSerializerrefuses values whose runtime type it was not given viaKnownTypeAttribute, and because the member isobject?no application can supply one. Before this was pinned,AddXmlDataContractSerializerFormatters()plus a singleAccept: application/xmlheader turned every failure into an unhandledInvalidCastException— a denial of service any client could trigger.AddXmlSerializerFormatters()did not throw, but it still negotiated failures away fromproblem+json. - The Minimal API seams —
ScalarValueValidationEndpointFilterandScalarValueValidationMiddleware— were never affected: they resolve the non-MVC problem-details writer, which does not negotiate. This is pinned by test rather than left to inference. - Customization is preserved. Bypassing
IProblemDetailsServicedoes not bypassProblemDetailsOptions.CustomizeProblemDetails:ResponseFailureWriterinvokes it directly, exactly once, withAdditionalMetadataset from the endpoint. ThetraceIdandcode/kindmembers thatAddTrellisProblemDetails()seeds, and any consumer customization layered after them, therefore appear on failure-writer output as they always did. - Serialization is by runtime type, resolved through
JsonSerializerOptions.GetTypeInfoso the path stays trim- and AOT-clean, and so a validation document keeps theerrorsmember that only the derived type declares.ResponseFailureWriterusesMicrosoft.AspNetCore.Http.Json.JsonOptions;ProblemDetailsActionResultuses MVC'sJsonOptions, which keeps its bodies byte-identical to what the JSON output formatter produced. An application that customized MVC's options and expected them to shape handler failure documents should mirror the relevant settings ontoservices.ConfigureHttpJsonOptions(...). ProblemDetailsActionResultno longer declaresapplication/problem+xmlamong its content types. That entry existed to match what MVC'sObjectResultExecutorinfers, so a[Produces]attribute could not overwrite the list; the parity it bought was itself the defect, and it was never a promise that Trellis can render XML.
- Success responses bypass negotiation because
[Produces("application/json")]breaks problem responses your application builds itself.ProducesAttributereplacesObjectResult.ContentTypeswholesale, and the JSON output formatter will then write aProblemDetailsasapplication/jsoninstead ofapplication/problem+json. Trellis-owned responses are immune —AsActionResult<T>()returns a plainActionResultthe filter cannot see, andScalarValueValidationFilterowns every invalidModelState— but anObjectResultyou construct yourself, chieflyProblem(...)andValidationProblem(...), is fully exposed. Addingapplication/problem+jsonto the list does not repair it in any position: MVC selects viaSelectFormatterUsingAnyAcceptableContentType, which loops over formatters outer and media types inner, so the JSON formatter claims whichever of its media types the list names and aproblem+jsonentry either sits inert behind an earlierapplication/jsonor rewrites successful responses. TrimMvcOptions.OutputFormattersinstead of narrowing with[Produces], asExamples/Showcase/src/Showcase.Mvc/Program.csdoes.TRLS065reports this; see trellis-api-analyzers.md. A list naming no JSON-family type at all —[Produces("text/csv")],[Produces("application/pdf")]— is safe and is not reported, because those formatters declineProblemDetails.
Code examples
Basic Result<T> → 200 / Problem Details
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using Trellis;
using Trellis.Asp;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTrellisAspWithScalarValidation();
var app = builder.Build();
app.UseScalarValueValidation();
app.MapGet("/widgets/{id}", (string id) =>
{
Result<Widget> result = WidgetService.Get(id);
return result.ToHttpResponse(opts => opts
.WithETag(w => w.ETag)
.WithLastModified(w => w.UpdatedAt)
.EvaluatePreconditions());
}).WithScalarValueValidation();
app.Run();
WriteOutcome<T> with Prefer / Created
app.MapPost("/widgets", async (CreateWidget cmd, IWidgetWriter writer, CancellationToken ct) =>
{
Result<WriteOutcome<Widget>> result = await writer.CreateAsync(cmd, ct);
return result.ToHttpResponse(
body: w => new WidgetResponse(w.Id, w.Name),
configure: opts => opts
.WithETag(w => w.ETag)
.HonorPrefer());
});
Note
WriteOutcome.Created(value) leaves Location generation at the HTTP boundary. A nonblank
outcome Location wins; null, empty, or whitespace falls back to the builder's Created,
CreatedAtRoute, CreatedAtAction, or WithLocation. Without either source, Created emits
201 without a Location header. Builder status flags never change the outcome's status.
Route/action fallbacks run the usual route-value and destination-aware callbacks, including
WithVersionedRoute(). An unresolved configured fallback emits
FaultCodes.ResponseLocationUnresolved (500 by default); callback exceptions propagate.
Other outcome variants and Accepted monitor URIs do not use these fallbacks.
Parse pagination input and return Result<Page<T>>
app.MapGet("/widgets", async (IWidgetReader reader, HttpContext ctx) =>
{
Result<Page<Widget>> page = await ctx.Request.TryCreatePageRequest()
.BindAsync(request => reader.ListAsync(request, ctx.RequestAborted));
return page.ToHttpResponse(
nextUrlBuilder: (c, applied) =>
$"{ctx.Request.Scheme}://{ctx.Request.Host}/widgets?cursor={Uri.EscapeDataString(c.Token)}&limit={applied}",
body: w => new WidgetResponse(w.Id, w.Name));
}).WithInputOrigin(InputLocation.Query);
The parser keeps ?cursor= distinct from a missing cursor, rejects repeated query values, and
returns query-located failures through the same 422 response mapper. Because BindAsync runs the
reader only on success, malformed pagination input does not dispatch the query. The declared query
origin keeps a later opaque-cursor decode failure query-located too.
When an endpoint supports distinct forward and backward query parameters, select the direction-aware overload instead. The endpoint must actually implement both directions; this callback only formats the links supplied by the page:
return page.ToHttpResponse(
urlBuilder: (cursor, direction, applied) =>
$"https://api.example.com/widgets?{(direction == PageDirection.Next ? "after" : "before")}={Uri.EscapeDataString(cursor.Token)}&limit={applied}",
body: widget => new WidgetResponse(widget.Id, widget.Name));
Both overload shapes also support Task<Result<Page<T>>> and
ValueTask<Result<Page<T>>> through ToHttpResponseAsync.
Canonical MVC controller — ToHttpResponseAsync(...).AsActionResultAsync<T>()
This is the single end-to-end controller idiom for every verb: send the message, project the domain value to a response DTO with ToHttpResponseAsync(map, opts => …), then adapt to a typed ActionResult<T> with AsActionResultAsync<T>(). The options builder is the one place HTTP metadata is attached (WithETag / WithLastModified / CreatedAtRoute / HonorPrefer); ETagHelper.ParseIfMatch(Request) flows the precondition into the command. There is no manual status-code branching — a failure Error maps to the correct status at the boundary.
Warning
Do not put [Produces("application/json")] on the controller — at class or action level; it is
the same result filter either way. ProducesAttribute rewrites ObjectResult.ContentTypes
wholesale, so any failure written as a plain ObjectResult silently degrades from
application/problem+json to application/json. Its status code and its ProblemDetails body are
both unchanged, so the response stops conforming to RFC 9457 while every status-and-body
assertion still passes. Assert content type, not just status and body.
Trellis' own failures are immune: ToHttpResponse returns an IResult that writes its own media
type, AsActionResult<T> wraps it in a plain ActionResult rather than an ObjectResult, and
ScalarValueValidationFilter uses an internal problem result for the same reason. Because that
filter takes over every invalid ModelState — plain [Required]/DataAnnotations failures
included, and bodies that fail to deserialize at all, not only value-object ones — an app that
registers it via AddTrellisAspWithScalarValidation has no exposed model-validation seam. A body
whose JSON fails to parse or convert is worth calling out: nothing was semantically rejected, so
the problem carries no fieldViolations and reports 400 rather than 422. That absence makes it
look like a response the filter never touched, but the media type is still Trellis-owned.
Stock MVC's automatic model-validation response, in an app
that does not register the filter, is a plain ObjectResult and is exposed.
Do not try to identify the producing component from the response body — there is no body-shape
discriminator between these seams. code and kind are seeded on every problem ≥ 400 by
AddTrellisProblemDetails, and fieldViolations is written by four different sites including the
AsActionResult path that never enters the filter, so neither presence nor absence identifies a
producer. Whether the filter owns a response is decided by configuration — whether
AddTrellisAspWithScalarValidation is registered — and nothing else.
Listing application/problem+json alongside application/json does not repair it: selection
follows list order, so problem+json is inert anywhere but first. Putting it first does repair the
failure but rewrites plain ObjectResult success responses to application/problem+json, which
is worse. There are exactly two safe remedies — remove the attribute, or trim the formatters via
PostConfigure<MvcOptions>.
Before reaching for it at all, note that [Produces] earns its keep only when something consumes
the description it produces. If the app publishes no OpenAPI document, the attribute is pure
downside.
using System.Collections.Generic;
using System.Linq;
using Mediator;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Trellis;
using Trellis.Asp;
[ApiController]
[Route("api/[controller]")]
public sealed class WidgetsController(ISender sender) : ControllerBase
{
// GET by id — 200 with strong ETag + Last-Modified; 404 on NotFound.
[HttpGet("{id}", Name = "Widgets_GetById")]
[ProducesResponseType(typeof(WidgetResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ValueTask<ActionResult<WidgetResponse>> GetById(WidgetId id, CancellationToken ct) =>
sender.Send(new GetWidgetByIdQuery(id), ct)
.ToHttpResponseAsync(
WidgetResponse.From,
opts => opts.WithETag(w => EntityTagValue.Strong(w.ETag)).WithLastModified(w => w.LastModified))
.AsActionResultAsync<WidgetResponse>();
// GET list — 200 with a projected collection.
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<WidgetResponse>), StatusCodes.Status200OK)]
public ValueTask<ActionResult<IReadOnlyList<WidgetResponse>>> GetAll(CancellationToken ct) =>
sender.Send(new GetAllWidgetsQuery(), ct)
.ToHttpResponseAsync(widgets => widgets.Select(WidgetResponse.From).ToList())
.AsActionResultAsync<IReadOnlyList<WidgetResponse>>();
// POST create — 201 Created with Location via CreatedAtRoute; 422 on invalid input.
[HttpPost]
[Consumes("application/json")]
[ProducesResponseType(typeof(WidgetResponse), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status422UnprocessableEntity)]
public ValueTask<ActionResult<WidgetResponse>> Create([FromBody] CreateWidgetRequest request, CancellationToken ct) =>
sender.Send(new CreateWidgetCommand(request.Name), ct)
.ToHttpResponseAsync(
WidgetResponse.From,
opts => opts.CreatedAtRoute("Widgets_GetById", w => new RouteValueDictionary { ["id"] = w.Id.Value }))
.AsActionResultAsync<WidgetResponse>();
// PUT update — If-Match precondition + Prefer handling.
[HttpPut("{id}")]
[Consumes("application/json")]
[ProducesResponseType(typeof(WidgetResponse), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
[ProducesResponseType(StatusCodes.Status428PreconditionRequired)]
public ValueTask<ActionResult<WidgetResponse>> Update(WidgetId id, [FromBody] UpdateWidgetRequest request, CancellationToken ct)
{
var ifMatch = ETagHelper.ParseIfMatch(Request);
return sender.Send(new UpdateWidgetCommand(id, ifMatch, request.Name), ct)
.ToHttpResponseAsync(
WidgetResponse.From,
opts => opts.WithETag(w => EntityTagValue.Strong(w.ETag)).WithLastModified(w => w.LastModified).HonorPrefer())
.AsActionResultAsync<WidgetResponse>();
}
// DELETE — 204 No Content. Body-less, so return IResult directly (no AsActionResultAsync).
// Fully-qualify Microsoft.AspNetCore.Http.IResult — it clashes with Trellis.IResult under `using Trellis;`.
// ToHttpResponseAsync() returns ValueTask<Microsoft.AspNetCore.Http.IResult>, so await it and return Task<Microsoft.AspNetCore.Http.IResult>.
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status412PreconditionFailed)]
public async Task<Microsoft.AspNetCore.Http.IResult> Delete(WidgetId id, CancellationToken ct)
{
var ifMatch = ETagHelper.ParseIfMatch(Request);
return await sender.Send(new DeleteWidgetCommand(id, ifMatch), ct).ToHttpResponseAsync();
}
}
What this canonicalizes (the same shape the Todo sample and generated services use):
- One projection + adapter per action.
ToHttpResponseAsync(map, opts)thenAsActionResultAsync<T>()for body responses; the no-argumentToHttpResponseAsync()(returningMicrosoft.AspNetCore.Http.IResult) for body-less responses (DELETE →204). - The options builder owns HTTP metadata.
WithETag/WithLastModifiedfor validators;Created/CreatedAtRoutefor201 Created+Location;WithLocationfor aLocationheader on the response's natural 2xx status (a state-transition primitive — it does not mark the response201); andHonorPrefer()forPrefer: return=representation|minimal. - Preconditions flow through the command.
ETagHelper.ParseIfMatch(Request)→ command →.RequireETag(...)in the handler (see trellis-api-core.md); the boundary turns a stale/missing precondition into412/428.
Per-call error mapping override
return result.ToHttpResponse(opts => opts
.WithErrorMapping<Error.Conflict>(StatusCodes.Status409Conflict)
.WithErrorMapping(err => err is Error.Conflict { Code: "inventory.out-of-stock" }
? StatusCodes.Status410Gone
: default));
Note
Error is a closed hierarchy — public abstract record Error with private constructors — so you cannot declare your own subclass of it. Discriminate your domain conditions with Error.Code on the built-in cases, as above, rather than by CLR type.
The delegate runs first for every failure, so it needs a way to say "not mine". Returning default (or any value outside 100–599) declines the error and resolution continues down the chain — here to the Error.Conflict → 409 mapping, then to TrellisAspOptions, then to 500. Aggregate children in the problems[] extension are resolved through the same per-call overrides as the top-level status.
Actor providers
AddXxxActorProvider helpers all Replace the IActorProvider slot — only the last call wins. The two clean composition patterns:
Pattern A — select one provider per environment:
using Microsoft.Extensions.DependencyInjection;
using Trellis.Asp.Authorization;
var services = new ServiceCollection();
if (env.IsDevelopment())
{
services.AddDevelopmentActorProvider(opts =>
{
opts.DefaultActorId = "development";
opts.DefaultPermissions = new HashSet<string> { "orders:read", "orders:create" };
});
}
else
{
services.AddEntraActorProvider(opts =>
{
opts.MapPermissions = claims => claims
.Where(c => string.Equals(c.Type, "roles", StringComparison.OrdinalIgnoreCase))
.Select(c => c.Value)
.ToHashSet();
});
}
Pattern B — wrap the chosen inner provider with caching:
// First register the inner provider, then wrap with caching.
// Each AddCachingActorProvider<T>() call replaces the IActorProvider slot
// with CachingActorProvider over T. The inner T is registered idempotently
// via TryAddScoped<T>() so library + application calls do not duplicate.
services.AddEntraActorProvider(opts => { /* ... */ });
services.AddCachingActorProvider<EntraActorProvider>();
Do not chain multiple AddXxxActorProvider calls expecting them to coexist or fall back — the last one always wins. If you need different providers in different environments, branch the registration code as in Pattern A.
Route constraints for scalar value objects
// AOT-safe — explicit registration
services.AddTrellisRouteConstraint<ProductId>("ProductId");
// Reflection-based — scans the calling assembly
services.AddTrellisRouteConstraints();
app.MapGet("/products/{id:ProductId}", (ProductId id) => Results.Ok(id));
Cross-references
- trellis-api-core.md —
Result,Result<T>,Error,Page<T>,Cursor,ResourceRef. - trellis-api-http-abstractions.md — the HTTP value objects shared between hosts and clients:
WriteOutcome<T>,RepresentationMetadata,EntityTagValue,PreconditionKind,HttpError,RetryAfterValue. - trellis-api-authorization.md —
Actor,IActorProvider,IAuthorize,IAuthorizeResource<TResource>, resource loaders. - trellis-api-primitives.md —
IScalarValue<TSelf, TPrimitive>,Maybe<T>. - trellis-api-http.md —
HttpClientresult extensions (the client side). - trellis-api-testing-aspnetcore.md —
WebApplicationFactoryExtensions.CreateClientWithActor(writes theX-Test-Actorheader consumed byDevelopmentActorProvider).