Aspnetcore: Let the content negotiation process determine which controller action is called

Created on 6 Nov 2018  路  18Comments  路  Source: dotnet/aspnetcore

Currently, content negotiation is focused on the use case of representing the same document in different formats, which is obtained by adding different formatters. However, with the increased popularity of HATEOAS in any of its many forms, which include techniques based on passing specific media types in the Accept header such as HAL, the result of a content negotiation process also includes the possibility of returning entirely different documents.

Therefore, it would be nice if the outcome of the content negotiation process would not only help pick the output formatter but also determine the controller action that should be used to return a specific media type.

Take, for example, HAL. HAL supports two media types: application/hal+json and application/hal+xml. If a GET request is made with application/json or application/xml then the expected outcome of the content negotiation process would be to call the controller action that returns the document formatted in JSON or XML, depending on the content type. However, if a GET request is made with application/hal+json or application/hal+xml then the desired outcome would be to route the request to a dedicated action that returns an entirely different document, one that in this case happens to be an extended version of the document that otherwise would be returned if the content negotiation process picked application/json or application/xml but featuring additional content or even changes in its DOM tree structure.

area-mvc question

Most helpful comment

I talked this over with a colleague that knows more about content negotiation than I. We both think that this is useful for you, and potentially other developers, but content negotiation has different points of view about how it should work. For example, what would be the correct status code if no actions match? We are hesitant to bake an opinion about how it should be done into ASP.NET, at least not until we hear more devs ask for similar behavior.

To help you, and to test our this area, I've put together a sample of route matching using an endpoint policy. You can get here.

All 18 comments

@ruimaciel you could use `ConsumeAttribute' to do the action selection:

```C#
[HttpGet]
[Consumes("application/hal+json")]
public ActionResult GetJson() { .. }

[HttpGet]
[Consumes("application/hal+xml")]
public ActionResult GetXml() { .. }
```

@pranavkm thank you for the quick reply. After reading Microsoft's article it seems that ConsumesAttribute only specifies the media type used in the request, and does nothing regarding the response.

There is, however, ProducesAttribute. However, I've ran a test where I've defined two actions sharing the same route but with distinct media types passed to the ProducesAttribute and unfortunately a request sent to a route triggers a 500 Internal Server Error by throwing the the following exception:

Microsoft.AspNetCore.Mvc.Internal.AmbiguousActionException: Multiple actions matched. The following actions matched route data and had all constraints satisfied:

Routing has to look at the request to determine what action gets selected, so what's it supposed to use to two discriminate between two actions with ProducesAttribute for a given request?

@pranavkm it would be ideal if the routing process would also take in consideration the outcome of the content negotiation process. So, if there are multiple actions matched to a route, the routing process could use the outcome of the content negotiation process to help match requests to the intended action.

For example, consider the following actions:

[HttpGet("/products")]
[Produces("application/json")]
public IActionResult GetProductsAsJson()
{
   // ...
}

[HttpGet("/products")]
[Produces("application/hal+json")]
public IActionResult GetProductsAsHal()
{
   // ...
}

Now, consider the following GET request:

GET /products
...
Accept: application/hal+json

Afaik, this GET request currently triggers an AmbiguousActionException, even when a service is configured with RespectBrowserAcceptHeader = true. Yet, if the content negotiation respects the browser's Accept header then there is already enough information to disambiguate which action should be matched.

I would also add that implementing support for HATEOAS is not the only use case that would benefit from considering the outcome of the content negotiation in routing. Adding support for media type API versioning would become quite trivial with ASP.NET Core with this feature. In fact, in the developer's point of view, I believe it would be very useful if annotating a Controller with a ProducesAttribute would be enough to disambiguate routes between controllers that share the same route. That would mean that adding support for a new version would be a matter of adding a new controller that shares the old route but takes requests for the version-specific media type, without having to touch any other source file in the process.

cc @JamesNK

ping @JamesNK

The idea behind MVC's content negotiation is a single action is run, and the appropriate OutputFormatter is then chosen based on the accepts header.

Are the logic in the actions so radically different that they would need to return different action results? I can see overloading the action based on the accepts header being useful, although I'm not sure how wide-spread the need for it would be. There is a relatively simple workaround by testing the accepts header inside the action and then returning different action results from a single action:

[HttpGet]
public ActionResult<IEnumerable<string>> Get()
{
    if (IsHal(HttpContext.Request))
    {
        // special HATEOAS result
        return new string[] { "value1", "value2" };
    }
    else
    {
        // normal result
        return new string[] { "value1", "value2" };
    }
}

private bool IsHal(HttpRequest request)
{
    var result = new List<MediaTypeSegmentWithQuality>();
    AcceptHeaderParser.ParseAcceptHeader(request.Headers[HeaderNames.Accept], result);

    // check result
    return true;
}

Are the logic in the actions so radically different that they would need to return different action results?

Yes.

As I've mentioned previously, actions may return documents which are extended and following entirely different DOM structures (i.e., see HAL vs returning a serialized DTO) or they may even return entirely different documents (i.e., media type-based versioning).

Picking the OutputFormatter only covers the needs of a small (or even negligible) corner case: serialize the same DTO in different data transfer languages.

There is a relatively simple workaround by testing the accepts header inside the action and then returning different action results from a single action:

That is technically true, but having to do the content negotiation directly within each action ends up creating more problems than it solves.

It would be nice if ASP.NET Core could reflect the outcome of the content negotiation process in the routing process and determine which action should be performed to produce the content. That would mean that developers could work on their REST APIs without having to add their own content negotiation logic ( which is convoluted and bug-prone and not easy to test) and simply focus on instantiating and returning specific DTOs from specific actions.

Yeah on initial impression it feels like it would be useful (although I'm not a content negotiation expert so need to run it pass someone who is 馃槃 ). And need to prioritize this against other features.

Accept can contain multiple values. We would need to think about how that would effect the situation when there are multiple matches. And weight would need to be taken into account.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept

From a backwards compatibility perspective I think it is ok. This would only turn situations where there previously was an ambiguous match to a working request.

I talked this over with a colleague that knows more about content negotiation than I. We both think that this is useful for you, and potentially other developers, but content negotiation has different points of view about how it should work. For example, what would be the correct status code if no actions match? We are hesitant to bake an opinion about how it should be done into ASP.NET, at least not until we hear more devs ask for similar behavior.

To help you, and to test our this area, I've put together a sample of route matching using an endpoint policy. You can get here.

@JamesNK thank you for your work. I understand there is no "one size fits all" solution.

Meanwhile, regarding the API versioning use case, I've just stumbled on this:
https://github.com/Microsoft/aspnet-api-versioning

I think this schould also be fixed in 2.1, because it looks like a Bug to me and some Environmets will stay on 2.1.x.

https://github.com/aws/aws-lambda-dotnet/issues/345

This isn't a bug.

If you want to stick with an earlier version of ASP.NET Core then use the technique here - https://github.com/aspnet/AspNetCore/issues/3891#issuecomment-438798940 - or implement an IActionConstraint. There is an constraint for consumes that is similar to get you started.

The implementation as IActionContraint seems to be good solution for me. I will try this next week.

Thanks for your help.

Thanks for contacting us. We believe that the question you've raised have been answered. If you still feel a need to continue the discussion, feel free to reopen it and add your comments.

Got this and I also think is a bug with Consumes.

        [HttpGet("test")]
        [Consumes(MediaTypeNames.Application.Json)]
        public ActionResult GetJson()

        [HttpGet("test")]
        [Consumes(MediaTypeNames.Text.Html)]
        public ActionResult GetHtml()

Ideally I would want to match the action based on Accept header. Currently return ambiguous view error.

@Bartmax : Consumes seems to work correctly it selects the correct Action by negotiating the Content-Type-Header, Produces ignores the Accept-Header. If you encountered a problem, it could be the combination of HttpGet and Consumes. There is no request-body in a GET-request. and a ConsumesAttribute cannot negitate the Content-Type of the body.

I think you may confuse Consumes and Provides. Consumes should handle the Action-Selection based on the Content-Type-Header while Provides should handle the Action-Selection based on the Accept-Header.

As far as I know there is a solution provided for 2.2 and newer (See: https://github.com/aspnet/samples/pull/22). I created an ProducesConstraintAttribute for the 2.1 LTS version. This enables a ContentNegotiation-based Action-Selection. The ProducesConstraintAttribute is not standard compliant regarding the content negotation, but it workes for my use-cases.

Was this page helpful?
0 / 5 - 0 ratings