1

Update: I have uploaded a small test project to github: link

I am creating a small web service with .Net Core 2, and would like to give the ability to clients to specify if they need navigational info in the response or not. The web api should only support xml and json, but it would be nice if clients could use Accept: application/xml+hateoas or Accept: application/json+hateoas in their request.

I tried setting up my AddMvc method like this:

public void ConfigureServices(IServiceCollection services)
    {
        services.AddMvc(options =>
        {
            options.RespectBrowserAcceptHeader = true;
            options.ReturnHttpNotAcceptable = true;
            options.FormatterMappings.SetMediaTypeMappingForFormat(
                "xml", MediaTypeHeaderValue.Parse("application/xml"));
            options.FormatterMappings.SetMediaTypeMappingForFormat(
                "json", MediaTypeHeaderValue.Parse("application/json"));
            options.FormatterMappings.SetMediaTypeMappingForFormat(
                "xml+hateoas", MediaTypeHeaderValue.Parse("application/xml"));
            options.FormatterMappings.SetMediaTypeMappingForFormat(
                "json+hateoas", MediaTypeHeaderValue.Parse("application/json"));
        })            
        .AddJsonOptions(options => {
            // Force Camel Case to JSON
            options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
        })
        .AddXmlSerializerFormatters()
        .AddXmlDataContractSerializerFormatters()
        ;

And I am using the accept header in my controller methods to differentiate between normal xml/json response, and hateoas-like response, like this:

[HttpGet]
[Route("GetAllSomething")]
public async Task<IActionResult> GetAllSomething([FromHeader(Name = "Accept")]string accept)
{
...
bool generateLinks = !string.IsNullOrWhiteSpace(accept) && accept.ToLower().EndsWith("hateoas");
...
if (generateLinks)
{
    AddNavigationLink(Url.Link("GetSomethingById", new { Something.Id }), "self", "GET");
}
...
}

So, in short, I do not want to create custom formatters, because the only "custom" thing is to either include or exclude navigational links in my response, but the response itself should be xml or json based on the Accept header value.

My model class looks like this (with mainly strings and basic values in it):

[DataContract]
public class SomethingResponse
{
    [DataMember]
    public int Id { get; private set; }

When calling my service from Fiddler, I got the following results for the different Accept values:

  1. Accept: application/json -> Status code 200 with only the requested data.
  2. Accept: application/json+hateoas -> Status code 406 (Not Acceptable).
  3. Accept: application/xml -> Status code 504. [Fiddler] ReadResponse() failed: The server did not return a complete response for this request. Server returned 468 bytes.
  4. Accept: application/xml+hateoas -> Status code 406 (Not Acceptable).

Could someone tell me which setting is wrong?

Daniel
  • 153
  • 3
  • 16

1 Answers1

2

Mapping of format to Media Type (SetMediaTypeMappingForFormat calls) works not as you expect. This mapping does not use Accept header in the request. It reads requested format from parameter named format in route data or URL query string. You should also mark your controller or action with FormatFilter attribute. There are several good articles about response formatting based on FormatFilter attribute, check here and here.

To fix your current format mappings, you should do the following:

  1. Rename format so that it does not contain plus sign. Special + character will give you troubles when passed in URL. It's better to replace it with -:

    options.FormatterMappings.SetMediaTypeMappingForFormat(
        "xml-hateoas", MediaTypeHeaderValue.Parse("application/xml"));
    options.FormatterMappings.SetMediaTypeMappingForFormat(
        "json-hateoas", MediaTypeHeaderValue.Parse("application/json"));
    
  2. Add format parameter to the route:

    [Route("GetAllSomething/{format}")]
    
  3. Format used for format mapping can't be extracted from Accept header, so you will pass it in the URL. Since you need to know the format for the logic in your controller, you could map above format from the route to action parameter to avoid duplication in Accept header:

    public async Task<IActionResult> GetAllSomething(string format)
    

    Now you don't need to pass required format in Accept header because the format will be mapped from request URL.

  4. Mark controller or action with FormatFilter attribute.

    The final action:

    [HttpGet]
    [Route("GetAllSomething/{format}")]
    [FormatFilter]
    public async Task<IActionResult> GetAllSomething(string format)
    {
        bool generateLinks = !string.IsNullOrWhiteSpace(format) && format.ToLower().EndsWith("hateoas");
    
        //  ...
    
        return await Task.FromResult(Ok(new SomeModel { SomeProperty = "Test" }));
    }
    

Now if you request URL /GetAllSomething/xml-hateoas (even with missing Accept header), FormatFilter will map format value of xml-hateoas to application/xml and XML formatter will be used for the response. Requested format will also be accessible in format parameter of GetAllSomething action.

Sample Project with formatter mappings on GitHub

Besides formatter mappings, you could achieve your goal by adding new supported media types to existing Media Types Formatters. Supported media types are stored in OutputFormatter.SupportedMediaTypes collection and are filled in constructor of concrete output formatter, e.g. XmlSerializerOutputFormatter. You could create the formatter instance by yourself (instead of using AddXmlSerializerFormatters extension call) and add required media types to SupportedMediaTypes collection. To adjust JSON formatter, which is added by default, just find its instance in options.OutputFormatters:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc(options =>
        {
            options.RespectBrowserAcceptHeader = true;
            options.ReturnHttpNotAcceptable = true;

            options.InputFormatters.Add(new XmlSerializerInputFormatter());
            var xmlOutputFormatter = new XmlSerializerOutputFormatter();
            xmlOutputFormatter.SupportedMediaTypes.Add("application/xml+hateoas");
            options.OutputFormatters.Add(xmlOutputFormatter);

            var jsonOutputFormatter = options.OutputFormatters.OfType<JsonOutputFormatter>().FirstOrDefault();
            jsonOutputFormatter?.SupportedMediaTypes.Add("application/json+hateoas");
        })
        .AddJsonOptions(options => {
            // Force Camel Case to JSON
            options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
        })
        .AddXmlDataContractSerializerFormatters();
}

In this case GetAllSomething should be the same as in your original question. You should also pass required format in Accept header, e.g. Accept: application/xml+hateoas.

Sample Project with custom media types on GitHub

CodeFuller
  • 30,317
  • 3
  • 63
  • 79
  • Thanks for your long answer! I tried both of the things you recommended, but still no results. The first version does not work as the parameter value is null (I debugged it and saw the value is null). The second part still gives me 406. I also tried many combinations of the two approaches, but still no luck. If your solution works, then I have a feeling that some other part of my solution breaks this functionality, either in the web api setup, or in my local iis, or somewhere else. I will do some more research before accepting your answer. – Daniel Apr 23 '18 at 10:51
  • 1
    "The first version does not work as the parameter value is null (I debugged it and saw the value is null)." - which parameter, `format` in `GetAllSomething`? "The second part still gives me 406" - do you send HTTP GET requests or trying to POST data. My answer adds only output formatters. You need also cover input formatters if you want to post data. Could you please update your question with controller action you're trying to call (if it's not `GetAllSomething` from the question) and exact HTTP request you send? – CodeFuller Apr 23 '18 at 12:23
  • Yes, in the first version format is null. In my original code (so without any of your recommendation), this value has the accept header's value, so application/json, or application/xml+hateoas, or whatever I use, this is why I introduced the variable 'generateLinks' and its logic. I send Get requests with specifying the accepted response type in the header, like this: accept: application/xml So, in this case I do not post anything. And it is really strange that the call reaches my controller's method, the response is seemingly returned, but in Fiddler I saw status code 406. – Daniel Apr 23 '18 at 12:43
  • Have you added format to the URL like in my answer? It should be `/GetAllSomething/xml-hateoas`, not `/GetAllSomething`. – CodeFuller Apr 23 '18 at 12:48
  • Yes. I created a new project just to test these: [link](https://github.com/KuzDeveloper/ExtendedResponseTest) I set it up in my local IIS and in the host file I linked my localhost ip to this IIS website (127.0.0.1 www.extendedresponsetest.com). – Daniel Apr 23 '18 at 14:46
  • 1
    Format mapping will work only for formats that you explicitly mapped. You call `/getsomething/xml` but you have not mapped `xml` to `application/xml`. Either add appropriate mapping or make test call for `/GetAllSomething/xml-hateoas` – CodeFuller Apr 23 '18 at 16:38
  • I tried all of these, but none worked, so I gave up and do not support xml, only json. Thanks for your time and help. I will mark your answer as accepted, because I think the problem lies somewhere else specifically on my machine (bad nuget package, wrong IIS or config settings, or God knows what else). – Daniel Apr 24 '18 at 10:30