ASP.NET Core Web API with ReturnHttpNotAcceptable and custom media types results in "no output formatter found" on 4xx responses

Viewed 895

I have an ASP.NET Core Web API (TargetFramework is net5.0) where I accept 2 media types:

  • application/json
  • application/vnd.my.customtype+json

I have a basic controller as follows:

[ApiController]
[ApiConventionType(typeof(DefaultApiConventions))]
[Route("")]
public class RootController : ControllerBase
{
    [HttpGet]
    [HttpHead]
    [Route("", Name = "get_root")]
    [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))]
    public IActionResult Get()
    {
        return this.Ok(new { Item1 = "item 1" });
    }
}

If I make a GET request with the header Accept: application/json and return a 200 OK with a result, everything is fine:

GET https://localhost:5001/ HTTP/1.1
Accept: application/json
Host: localhost:5001

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Server: Kestrel
Content-Length: 18

{"item1":"item 1"}

If I make a GET request with the header Accept: application/vnd.my.customtype+json and return a 200 OK with a result, everything is also fine:

GET https://localhost:5001/ HTTP/1.1
Accept: application/vnd.my.customtype+json
Host: localhost:5001

HTTP/1.1 200 OK
Content-Type: application/vnd.my.customtype+json; charset=utf-8
Server: Kestrel
Content-Length: 18

{"item1":"item 1"}

The same controller returning a 404 (e.g. if a resource is not found)

[ApiController]
[ApiConventionType(typeof(DefaultApiConventions))]
[Route("")]
public class RootController : ControllerBase
{
    [HttpGet]
    [HttpHead]
    [Route("", Name = "get_root")]
    [ApiConventionMethod(typeof(DefaultApiConventions), nameof(DefaultApiConventions.Get))]
    public IActionResult Get()
    {
        return this.NotFound();
    }
}

Now, if I make a GET request with the header Accept: application/json, everything is fine (I get a proper 404 as well as ProblemDetails in the response body):

GET https://localhost:5001/ HTTP/1.1
Accept: application/json
Host: localhost:5001

HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
Server: Kestrel
Content-Length: 161

{
  "type":"https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title":"Not Found",
  "status":404,
  "traceId":"00-54d50dfe6a27674ba83a73324fcf2721-d4286734f860c94a-00"
}

If I make a GET request with the header Accept: vnd.my.customtype+json, I get a 406 Not Acceptable response:

GET https://localhost:5001/ HTTP/1.1
Accept: application/vnd.my.customtype+json
Host: localhost:5001

HTTP/1.1 406 Not Acceptable
Server: Kestrel
Content-Length: 0

And in the logs, I get this:

info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
      Request starting HTTP/1.1 GET https://localhost:5001/ - -

info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'Hooli.WebAPI.Controllers.RootController.Get (Hooli.WebAPI)'

info: Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker[3]
      Route matched with {action = "Get", controller = "Root"}. Executing controller action with signature Microsoft.AspNetCore.Mvc.IActionResult Get() on controller Hooli.WebAPI.Controllers.RootController (Hooli.WebAPI).

warn: Microsoft.AspNetCore.Mvc.Infrastructure.ObjectResultExecutor[1]
      No output formatter was found for content types 'application/problem+json, application/problem+xml' to write the response.

info: Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker[2]
      Executed action Hooli.WebAPI.Controllers.RootController.Get (Hooli.WebAPI) in 0.8058ms

info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'Hooli.WebAPI.Controllers.RootController.Get (Hooli.WebAPI)'

info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
      Request finished HTTP/1.1 GET https://localhost:5001/ - - - 406 0 - 1.1703ms

Note the warn line: No output formatter was found for content types 'application/problem+json, application/problem+xml' to write the response.

I cannot figure out why this will not return a proper 404 with problem details.

My Startup.cs is as follows:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        this.Configuration = configuration;
    }

    public IConfiguration Configuration { get; }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllers(
                     options =>
                     {
                         options.ReturnHttpNotAcceptable = true;
                     })
                .AddNewtonsoftJson(
                     options =>
                     {
                         options.SerializerSettings.ContractResolver =
                             new CamelCasePropertyNamesContractResolver
                             {
                                 NamingStrategy = new CamelCaseNamingStrategy(true, true)
                             };
                     });

        services.Configure<MvcOptions>(
            options =>
            {
                NewtonsoftJsonOutputFormatter newtonsoftJsonOutputFormatter =
                    options.OutputFormatters.OfType<NewtonsoftJsonOutputFormatter>().FirstOrDefault() ??
                    throw new ArgumentNullException(nameof(newtonsoftJsonOutputFormatter));

                // add media types that we want to support by default
                newtonsoftJsonOutputFormatter
                    .SupportedMediaTypes
                    .Add(MediaTypeHeaderValue.Parse("application/vnd.my.customtype+json"));
            });
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseExceptionHandler(env.IsDevelopment() ? "/error-local-development" : "/error");
        app.UseHttpsRedirection();
        app.UseRouting();
        app.UseAuthorization();
        app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
    }
}

And just for completeness, my Program.cs file:

public class Program
{
    public static void Main(string[] args)
    {
        CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args)
    {
        return Host.CreateDefaultBuilder(args)
                   .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>(); });
    }
}

I have stripped my API down to this most basic setup to rule out any additional middleware / configurations. It doesn't matter what status code I return, if it is any of the 4xx responses I always get a 406 when the Accept header is the custom media type.

e.g.

  • this.BadRequest();
  • this.NotFound();
  • this.Unauthorized();
  • etc.

If I change options.ReturnHttpNotAcceptable = true to options.ReturnHttpNotAcceptable = false, the responses come back fine for everything, but then the API doesn't return a 406 when an invalid media type is passed in the Accept header.

How can I get the API to return the proper response when the custom media type is in the Accept header?

2 Answers

Try this:

Add a class:

public class CustomOutputFormatter: OutputFormatter
    {
        public CustomOutputFormatter()
        {
            SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("application/vnd.my.customtype+json"));
        }
        
        public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
        {
            var response = context.HttpContext.Response;
            await response.WriteAsJsonAsync(context.Object);
        }
    }

And register in your services:

services
    .AddControllers(options =>
    {
        options.RespectBrowserAcceptHeader = true;
        options.ReturnHttpNotAcceptable = true;
    })
    .AddMvcOptions(o => o.OutputFormatters.Add(new CustomOutputFormatter()))

I see this as a expected behavior of the system. The problem is that when an exception occurred that resulted in 4xx response and the response content type would be changed to application/problem+json.

If there was no Accept header and the content type supposed to be returned is application/json, then application/problem+json is a subset of application/json. So it would work.

If the request had Accept header like Accept: application/vnd.my.customtype+json, then application/problem+json is not a subset of application/vnd.my.customtype+json. So it would not work. Hence the behavior.

So one possible solution is to override OutputFormatterSelector and choose the correct formatter,

public class AcceptHeaderOutputFormatterSelector : OutputFormatterSelector
    {
        private readonly DefaultOutputFormatterSelector _fallbackSelector;
        private readonly List<IOutputFormatter> _formatters;

        public AcceptHeaderOutputFormatterSelector(IOptions<MvcOptions> options, ILoggerFactory loggerFactory)
        {
            _fallbackSelector = new DefaultOutputFormatterSelector(options, loggerFactory);
            _formatters = new List<IOutputFormatter>(options.Value.OutputFormatters);
        }

        public override IOutputFormatter? SelectFormatter(OutputFormatterCanWriteContext context, IList<IOutputFormatter> formatters, MediaTypeCollection mediaTypes)
        {

            var selectedFormatter = _fallbackSelector.SelectFormatter(context, formatters, mediaTypes);
            if (selectedFormatter == null)
            {
                var isAProblemDetailsType = mediaTypes.Any(i => i.Contains("problem+"));
                if (isAProblemDetailsType)
                {
                    var acceptableMediaType = MediaTypeHeaderValue.ParseList(context.HttpContext.Request.Headers.Accept).FirstOrDefault();
                    if (acceptableMediaType == null)
                    {
                        return selectedFormatter;
                    }

                    string acceptableMediaTypeStr = $"{acceptableMediaType.Type}/{acceptableMediaType.Suffix}";
                    
                    selectedFormatter = _formatters.FirstOrDefault(i =>
                    {
                        OutputFormatterCanWriteContext formatterContext = context;
                        formatterContext.ContentType = acceptableMediaTypeStr;
                        formatterContext.ContentTypeIsServerDefined = false;
                        return i.CanWriteResult(formatterContext);
                    });

                }
            }

            return selectedFormatter;
        }
    }

then in your program.cs or starup.cs, add the following to override the default output formatter selector,

builder.Services.AddSingleton<OutputFormatterSelector, AcceptHeaderOutputFormatterSelector>();
Related