Use with Wolverine
dotnet add package AdCodicem.ValueObjects.Wolverine.Http
using AdCodicem.ValueObjects.Wolverine.Http;
builder.Services.AddWolverineHttp();
builder.Services.AddProblemDetails(); // required where JSON serialization runs without reflection, as under native AOT
var app = builder.Build();
app.MapWolverineEndpoints(opts =>
{
opts.UseValueObjects(); // or opts.UseValueObjects(messages => messages.UseResource<ValidationMessages>())
opts.RejectUnparseableQueryValues = true; // recommended, for the parameters Wolverine still binds itself
});
Wolverine.Http binds a route, query, header or form value to any type with a public static TryParse, a value object
among them, but answers every failure in its own terms (WolverineFx.Http 6.51.0):
- A refused or unparseable route value is a 404, with no message and no code.
- A refused query value reaches the handler as the default instance under the default
RejectUnparseableQueryValues = false:?qty=500runs the handler withqty=0, and so do?qty=x,?qty=and noqtyat all. UnderRejectUnparseableQueryValues = true, a refused value is a 400 naming the first parameter alone, quoting the refused text, without the rule's code; an absent or empty value still binds the default instance. - A header and a form field are never strict:
X-Customer: abcand a form fieldqty=500bind the default instance in both modes. A header sent twice throwsInvalidOperationException: Sequence contains more than one element. - An array drops what it refuses:
?qty=1&qty=500binds[1]. [FromQuery]on a value object stops the application at startup:InvalidOperationException: Sequence contains no elements.- A compound handler's
Beforemethod sees the default instance too.
UseValueObjects() plugs a parameter strategy into Wolverine's code generation, ahead of Wolverine's own. The code
Wolverine writes for an endpoint then calls the package's typed helpers for each value object, so that TSelf.TryParse
runs closed at compile time, with no registry, no visitor and no reflection.
Where a value is read from
Call UseValueObjects() inside the callback of MapWolverineEndpoints, the one place Wolverine accepts a parameter
strategy. Every parameter whose type is a value object T, a T?, a T[], a List<T>, an IList<T>, an
IReadOnlyList<T> or an IEnumerable<T> is claimed, on the endpoint's method and on its middleware methods (Before,
Load, Validate) alike, and read from the source Wolverine reads it from, in Wolverine's order:
- a form field, under
[FromForm], by itsNameor the parameter's; - a route value: the one
[FromRoute(Name = …)]names, compared ignoring case as Wolverine compares it, or the one of the parameter's name, which wins over[FromHeader]and[FromQuery], as in Wolverine; - a header, under
[FromHeader], by itsNameor the parameter's; - the query string otherwise, under
[FromQuery]'sNameor the parameter's.
A collection binds from the query string or a form only; one bound to a route value or a header is left to Wolverine. A
parameter another strategy binds is left alone: [AsParameters], [FromBody], [FromServices], [FromKeyedServices],
Wolverine's [NotBody], and Wolverine's parameter attributes ([Entity], [Aggregate] and the others). A value is
read once per endpoint, so a compound handler's Before(Quantity qty) and its handler share the same qty; the same
name bound to two value objects in one endpoint stops MapWolverineEndpoints with an InvalidOperationException.
Wolverine looks a value up again by its source and name for an After or Finally method, for a route value a
middleware method takes, and for the identity [Entity] and the other parameter attributes load by: each finds the
package's read of a value the endpoint's value objects read, so it receives the value the rules accepted, and none runs
for a refused request. A middleware method that a policy registered after UseValueObjects() adds and matches the
parameters of (chain.ApplyParameterMatching(call) in ConfigureEndpoints) has its value objects read and answered
the same way, before it runs.
A generic value object's construction and a value object written by hand bind without registration: the generated code names the type.
A refused value
Every refusal of a request is answered at once, with the validation problem a minimal API answers, through
IProblemDetailsService when the application registers one:
GET /when?due=x&at=x&consent=x&amount=-1
400 {"title":"One or more validation errors occurred.","status":400,
"errors":{"due":["The text is not a valid System.DateOnly."],"at":["The text is not a valid System.DateTimeOffset."],
"consent":["The text is not a valid bool."],"amount":["The value must be greater than or equal to 0."]},
"errorCodes":{"due":"value_object.not_parsable","at":"value_object.not_parsable",
"consent":"value_object.not_parsable","amount":"value_object.out_of_range"}}
- Each member is listed under the name it binds from (
X-Customerfor a header named so), with the message of the rule, never the refused text, and its code undererrorCodes, keyed through the HTTP JSON options' dictionary key policy aserrorsis. The elements of a collection a request refuses are listed under its name, one message each, with the code of the first. - The answer comes before any middleware method or the handler runs: a
Before,LoadorValidatemethod never sees a refused value. - The codes are also recorded on the request, where a middleware, a log or
CustomizeProblemDetailsreads them withValueObjectProblemDetails.GetErrorCodes(httpContext). - A refused route value is a 400, where Wolverine alone answers 404. A route constraint,
{qty:int}, still answers 404 for text it refuses, since routing matches no endpoint;/lines/500reaches the binding and its 400. - Where reflection-based serialization is off, as under native AOT,
MapWolverineEndpointsthrows anInvalidOperationExceptionnamingAddProblemDetails()when the HTTP JSON options cannot write the validation problem, rather than answer each refusal with a 500.
Absent and empty values
- An absent value that cannot be
nullis refused,value_object.required, "A value is required.", unless the parameter has a default value, as a minimal API decides. An optional route segment,{qty?}, does not make a value object optional: declare the parameterT?, which bindsnullwhen the value is absent. - An absent collection binds an empty one.
- Empty text is parsed, nullable or not:
?country=is refused by the type's rule,value_object.required, "The value must not be empty.", and?qty=over a number isvalue_object.not_parsable. Only an absent value bindsnull. - A query key or a header sent twice is parsed as its values joined by a comma, as a minimal API reads it, and refused by the rules of most types; a collection reads each value.
Messages in the request's language
UseValueObjects(messages => messages.UseResource<T>()) reads the message of each code from the application's
resource, as the minimal API package does (Messages in the application's
language). A Wolverine endpoint runs in the ASP.NET Core
pipeline, so UseRequestLocalization() sets the request's culture as for any endpoint; AddLocalization() registers the
localizer the lookup needs, and without it a refusal throws an InvalidOperationException naming the call. With the
French example of the errors reference:
GET /items?qty=500 Accept-Language: fr-FR → « qty » doit être compris entre 1 et 100.
GET /optional?country=XX Accept-Language: fr-FR → « country » doit être l'une des valeurs suivantes : FR; DE.
A code the resource has no message for keeps the English message; the codes never change.
What stays with Wolverine
[AsParameters]members. Wolverine binds them itself, without asking a strategy: leniently under the default, or with its own 400, which carries no code, underRejectUnparseableQueryValues = true. So do the members of a complex[FromQuery]or[FromForm]type, and a parameter of anAfterorFinallymethod reading a value no value object of the endpoint reads. A property of a complex[FromQuery]or[FromForm]type bound to the very name a value object of the endpoint reads stays at its default: take the value from the parameter. SetRejectUnparseableQueryValues = truefor them, as Wolverine itself recommends; it also silences the warning Wolverine logs at startup while the flag is off, which describing a value object's query parameter for OpenAPI triggers, though the package binds that parameter strictly whatever the flag.- The JSON body. It is read through the generated converter, under the HTTP JSON options, so a refused body is a 400,
but Wolverine writes it itself:
"title":"Invalid JSON format", the rule's message indetail, and neither the code nor the member, since Wolverine handsIProblemDetailsServiceno exception.
OpenAPI
Nothing to add: Wolverine's ApiExplorer descriptions name the value object as each route, query, header and form
parameter's type, and the OpenAPI transformer and the Swashbuckle filters describe it with its rules, as
for a minimal API. A path parameter is required; Wolverine marks no query, header or form parameter required.
Static mode
Wolverine's static mode (TypeLoadMode.Static) runs the code codegen write wrote beforehand, compiled into the
application. That code calls the package by name, and static mode never notices code written by another version: run
codegen write again after installing or upgrading the package, or the endpoints keep the binding the code was written
with, Wolverine's own included.
Native AOT
The package is AOT-compatible: the code Wolverine writes names each value object, and the strategy runs at startup, when
Wolverine builds its endpoints, in static mode too. Under native AOT, Wolverine requires static mode and its
pre-generated code, and the application calls builder.Services.AddProblemDetails(), which lets the HTTP JSON options
write the validation problem without reflection. CI publishes an application serving Wolverine.Http endpoints this way,
its code written by codegen write first, and compares what the native binary answers with what it answers under the
JIT.
Antiforgery
A form value keeps its endpoint marked as reading a form, as Wolverine's own [FromForm] binding marks it, so that
AutoAntiforgeryOnFormEndpoints() still requires an antiforgery token of it.
Messaging
Wolverine's messaging needs no package. Its default serializer is System.Text.Json, which writes a value object as its
underlying value and reads it back through the generated converter: a message holding a value its type refuses fails,
and over a transport it moves to the error queue, the logged exception being the converter's ValueObjectJsonException,
from which ValueObjectErrors.TryGetCode reads the code. A member the payload leaves out holds the default instance:
declare it T? or required (A member missing from the payload).
Not covered
- F# endpoints: the frames the package adds to the generated code write C# alone.
[Entity]and the Marten aggregate attributes of WolverineFx.Http.Marten over a value-object identifier, loaded from a store: the strategy leaves the attributed parameter to them. Where the endpoint takes the identifier as a value object too, their lookup by name finds the package's read; otherwise their loader reads the identifier itself.- The dead-letter record a durable message store keeps for a refused message.