.net-coreasp.net-core-webapicontent-negotiationhttp-accept-header

How to use built-in xml or json formatter for custom accept header value in .Net Core 2.0


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?


Solution

  • 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