The problem
We have the following action method:
[Route("[controller]")]
[ApiController]
[Produces("application/json")]
public class HomeController : ControllerBase
{
public IActionResult Post(
[FromBody] PostRequestBody body,
[FromHeader(Name = "Accept")] string accept,
[FromHeader(Name = "X-Correlation-Id")] string correlationId,
[FromQuery(Name = "filter")] string filter)
{
// Do something
return Ok();
}
}
We want to consolidate all the parameters to one class, PostRequest
, instead of using a bunch of different parameters.
The solution
Let's create a class that holds all our data:
public class PostRequest
{
[FromBody]
public PostRequestBody Body { get; set; } = null!;
[FromHeader(Name = "Accept")]
public string Accept { get; set; } = null!;
[FromHeader(Name = "X-Correlation-Id")]
public string CorrelationId { get; set; } = null!;
[FromQuery(Name = "filter")]
public string Filter { get; set; } = null!;
}
public class PostRequestBody
{
public string SomeString { get; set; } = null!;
public bool SomeBool { get; set; }
}
Looks good, right? Let's change our action method to have the following signature instead:
[Route("[controller]")]
[ApiController]
[Produces("application/json")]
public class HomeController : ControllerBase
{
[HttpPost]
public IActionResult Post(PostRequest req)
{
return new OkObjectResult(req);
}
}
Now, if we try to call our action method like this:
POST
Accept: application/json
X-Correlation-Id: my-correlation-id
/post?filter=one
{"someString": "Some string", "someBool": true}
We would get the following error message:
{
"type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-5962a6779c924d01bddf7f5a9a8cbf87-bf0d5d719504e20b-00",
"errors": {
"Body": ["The Body field is required."],
"Accept": ["The Accept field is required."],
"filter": ["The Filter field is required."],
"X-Correlation-Id": ["The CorrelationId field is required."]
}
}
Out of the box, ASP.NET Core is not capable of model binding all the different sources to a single class. Let's solve that.
The first thing we will do is to create a new attribute that we will decorate our action parameter with.
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property)]
public class FromRequestAttribute : Attribute, IBindingSourceMetadata
{
public BindingSource BindingSource => new FromRequestModelBindingSource();
}
The attribute specifies that we should use FromRequestModelBindingSource as a BindingSource.
FromRequestModelBindingSource looks like this:
public class FromRequestModelBindingSource : BindingSource
{
public FromRequestModelBindingSource() : base(
"FromModelBinding",
"FromModelBinding",
true,
true
)
{
}
public override bool CanAcceptDataFrom(BindingSource bindingSource)
{
return bindingSource == ModelBinding;
}
}
The only thing left to do is to add the attribute to our action method like this:
[HttpPost]
public IActionResult Post([FromRequest]PostRequest req)
{
return new OkObjectResult(req);
}
Now when we do the same request as we did previously, we can see that we manage to bind all the different sources to one class:
{
"body": {
"someString": "Some string",
"someBool": true
},
"accept": "application/json",
"correlationId": "my-correlation-id",
"filter": "one"
}
A complete example with tests can be found here.
As noted in the comments by Piotr, it's possible to achieve the same behaviour by just configuring the ApiBehaviourOptions
like this:
services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressInferBindingSourcesForParameters = true;
});