Using AsParameters 3.13
WARNING
When you use [AsParameters], you can read HTTP form data or deserialize a request body as JSON, but not both at the same time and Wolverine will happily throw an exception telling you so if you try to do this.
TIP
Use Wolverine's pre-generated code to understand exactly how Wolverine is processing any model object decorated with [AsParameters]
Wolverine supports the ASP.Net Core AsParameters attribute usage for complex binding of a mixed bag of HTTP information including headers, form data elements, route arguments, the request body, IoC services to a single input model using the ASP.Net Core [AsParameters] attribute as a marker.
See the Microsoft documentation on AsParameters for more background.
Below is a sample from our test suite showing what is possible for query string and header values:
public static class AsParametersEndpoints{
[WolverinePost("/api/asparameters1")]
public static AsParametersQuery Post([AsParameters] AsParametersQuery query)
{
return query;
}
}
public class AsParametersQuery{
[FromQuery]
public Direction EnumFromQuery{ get; set; }
[FromForm]
public Direction EnumFromForm{ get; set; }
public Direction EnumNotUsed{get;set;}
[FromQuery]
public string StringFromQuery { get; set; } = null!;
[FromForm]
public string StringFromForm { get; set; } = null!;
public string StringNotUsed { get; set; } = null!;
[FromQuery]
public int IntegerFromQuery { get; set; }
[FromForm]
public int IntegerFromForm { get; set; }
public int IntegerNotUsed { get; set; }
[FromQuery]
public float FloatFromQuery { get; set; }
[FromForm]
public float FloatFromForm { get; set; }
public float FloatNotUsed { get; set; }
[FromQuery]
public bool BooleanFromQuery { get; set; }
[FromForm]
public bool BooleanFromForm { get; set; }
public bool BooleanNotUsed { get; set; }
[FromHeader(Name = "x-string")]
public string StringHeader { get; set; } = null!;
[FromHeader(Name = "x-number")] public int NumberHeader { get; set; } = 5;
[FromHeader(Name = "x-nullable-number")]
public int? NullableHeader { get; set; }
}And the corresponding test case for utilizing this:
var result = await Host.Scenario(x => x
.Post
.FormData(new Dictionary<string, string>
{
{ "EnumFromForm", "east" },
{ "StringFromForm", "string2" },
{ "IntegerFromForm", "2" },
{ "FloatFromForm", "2.2" },
{ "BooleanFromForm", "true" },
{ "StringNotUsed", "string3" }
}).QueryString("EnumFromQuery", "west")
.QueryString("StringFromQuery", "string1")
.QueryString("IntegerFromQuery", "1")
.QueryString("FloatFromQuery", "1.1")
.QueryString("BooleanFromQuery", "true")
.QueryString("IntegerNotUsed", "3")
.ToUrl("/api/asparameters1")
);
var response = await result.ReadAsJsonAsync<AsParametersQuery>();
response.EnumFromForm.ShouldBe(Direction.East);
response.StringFromForm.ShouldBe("string2");
response.IntegerFromForm.ShouldBe(2);
response.FloatFromForm.ShouldBe(2.2f);
response.BooleanFromForm.ShouldBeTrue();
response.EnumFromQuery.ShouldBe(Direction.West);
response.StringFromQuery.ShouldBe("string1");
response.IntegerFromQuery.ShouldBe(1);
response.FloatFromQuery.ShouldBe(1.1f);
response.BooleanFromQuery.ShouldBeTrue();
response.EnumNotUsed.ShouldBe(default);
response.StringNotUsed.ShouldBe(default);
response.IntegerNotUsed.ShouldBe(default);
response.FloatNotUsed.ShouldBe(default);
response.BooleanNotUsed.ShouldBe(default);Wolverine.HTTP is also able to support [FromServices], [FromBody], and [FromRoute] bindings as well as shown in this sample from the tests:
public class AsParameterBody
{
public string Name { get; set; } = null!;
public Direction Direction { get; set; }
public int Distance { get; set; }
}
public class AsParametersQuery2
{
// We do a check inside of an HTTP endpoint that this works correctly
[FromServices, JsonIgnore]
public IDocumentStore Store { get; set; } = null!;
[FromBody]
public AsParameterBody Body { get; set; } = null!;
[FromRoute]
public string Id { get; set; } = null!;
[FromRoute]
public int Number { get; set; }
}
public static class AsParametersEndpoints2{
[WolverinePost("/asp2/{id}/{number}")]
public static AsParametersQuery2 Post([AsParameters] AsParametersQuery2 query)
{
// Just proving the service binding works
query.Store.ShouldBeOfType<DocumentStore>();
return query;
}
}Splitting a Route Value from the Request Body
A common shape is an endpoint whose route carries the identity (say, an aggregate id) while the JSON body carries the rest of the command. If you bind the body to a single plain type that also has a property matching the route token, that property is duplicated — it shows up both as a route parameter and as a property of the request body in the generated OpenAPI:
// {journeyId} is in the route AND JourneyId is a property of the body type,
// so journeyId appears twice in the OpenAPI document (in: path and in the body schema)
public record AddPassengerCommand(Guid JourneyId, string PassengerName);
[WolverinePost("/journey/{journeyId:guid}/passenger")]
public static string Post(AddPassengerCommand command) => "ok";This is the same behavior as ASP.NET Core's own minimal APIs — neither framework strips a body property just because its name happens to match a route token. The idiomatic way to avoid the duplication is to bind with [AsParameters] and explicitly source the id from the route and the rest from the body:
public record AddPassengerCommand(
[FromRoute] Guid JourneyId,
[FromBody] AddPassengerCommand.Payload Body)
{
public record Payload(string PassengerName);
}
[WolverinePost("/journey/{journeyId:guid}/passenger")]
public static string Post([AsParameters] AddPassengerCommand command)
=> $"{command.JourneyId}: {command.Body.PassengerName}";Now journeyId is described only as a route parameter (with its real type/format, e.g. uuid), and the request body schema is just the Payload type — the route id is not duplicated in the body. This works the same way when the route id also feeds a Marten/Polecat [WriteAggregate]/[Aggregate] parameter on the same endpoint.
Using Records
And lastly, you can use C# records or really just any constructor function as well and decorate parameters like so:
public record AsParameterRecord(
[FromRoute] string Id,
[FromQuery] int Number,
[FromHeader(Name = "x-direction")] Direction Direction,
[FromForm(Name = "test")] bool IsTrue);
public static class AsParameterRecordEndpoint
{
[WolverinePost("/asparameterrecord/{Id}")]
public static AsParameterRecord Post([AsParameters] AsParameterRecord input) => input;
}Strict Query String Binding 6.18
By default in Wolverine 6.x, a query string value that is present but cannot be parsed to the target member type (an enum, int, Guid, etc.) is silently ignored — the member simply keeps its default or property initializer value. ASP.NET Core's own minimal API [AsParameters] binder returns 400 Bad Request for the same input, and the lenient behavior makes a typo'd query value indistinguishable from an absent one (not even FluentValidation can catch it downstream, because the bound value looks legitimate).
You can opt into the strict, minimal-API-compatible behavior with WolverineHttpOptions.RejectUnparseableQueryValues:
app.MapWolverineEndpoints(opts =>
{
// Return 400 with a ProblemDetails body naming the offending parameter
// when a query string value is present but unparseable
opts.RejectUnparseableQueryValues = true;
});The behavior matrix for a bound query string member (for example [FromQuery] public SortField SortBy { get; set; } = SortField.Date; on GET /search):
| Request | Flag off (default) | Flag on |
|---|---|---|
GET /search?SortBy=Name | 200 — binds Name | 200 — binds Name |
GET /search (missing) | 200 — keeps the initializer (Date) | 200 — keeps the initializer (Date) |
GET /search?SortBy=bogus (malformed) | 200 — keeps the initializer (Date) | 400 — ProblemDetails naming SortBy |
Only a present but unparseable value triggers the 400; a missing query string value keeps the member's default / initializer in both modes. The flag applies to query string binding for [AsParameters] members and for endpoint method arguments bound from the query string alike. Route argument binding is unaffected (an unparseable route value already returns 404).
Collections 6.18
The flag covers collection query string parameters (T[], List<T>, IList<T>, IReadOnlyList<T>, IEnumerable<T>) too. With the flag off, an element that fails to parse is silently dropped, which is especially dangerous for an optional filter: the endpoint sees null (or a partial collection) and quietly returns an unfiltered 200 while the caller believes the results were filtered.
With the flag on, binding a collection is all or nothing — a single unparseable element rejects the whole request with a 400 naming the parameter, rather than silently dropping the bad element and keeping the good ones. For [FromQuery] public Colour[]? Colours { get; set; } on GET /widgets:
| Request | Flag off (default) | Flag on |
|---|---|---|
GET /widgets?Colours=Red&Colours=Blue | 200 — binds [Red, Blue] | 200 — binds [Red, Blue] |
GET /widgets (missing) | 200 — keeps the initializer (null) | 200 — keeps the initializer (null) |
GET /widgets?Colours=Purple | 200 — Colours is null | 400 — ProblemDetails naming Colours |
GET /widgets?Colours=Red&Colours=Purple | 200 — binds only [Red] | 400 — ProblemDetails naming Colours |
String collections are unaffected in both modes, as there is nothing to parse.
WARNING
RejectUnparseableQueryValues is opt-in (defaults to false) throughout Wolverine 6.x to preserve the previous lenient behavior, but the default flips to true (strict) in Wolverine 7.0. If you depend on the lenient behavior, set the flag explicitly to be ready for 7.0.
The Fluent Validation middleware for Wolverine.HTTP is able to validate against request types bound with [AsParameters]:
public static class ValidatedAsParametersEndpoint
{
[WolverineGet("/asparameters/validated")]
public static string Get([AsParameters] ValidatedQuery query)
{
return $"{query.Name} is {query.Age}";
}
}
public class ValidatedQuery
{
[FromQuery]
public string? Name { get; set; }
public int Age { get; set; }
public class ValidatedQueryValidator : AbstractValidator<ValidatedQuery>
{
public ValidatedQueryValidator()
{
RuleFor(x => x.Name).NotNull();
}
}
}
