Running ASP.NET Core content negotiation by hand

Β· 1554 words Β· 8 minutes to read

When you are building flexible HTTP APIs, supporting a wide array of different clients, it is common to rely on the process of content negotiation, to allow each client to interact with the API in the most convenient way - be it JSON, XML, Protobuf, Messagepack or any other media type on which both the client and the server can agree.

I have blogged about content negotiation (or in short: conneg) a few times in the past (for example here or here, in ASP.NET Core context). Today I’d like to show you how - in ASP.NET Core - to easily run conneg by hand, instead of relying on the built-in MVC conneg mechanisms.

The problem πŸ”—

When you are using the MVC framework, content negotiation is an intrinsic part of the framework, supported out of the box. You can simply return your data model or something like ObjectResult from a controller and rely on the framework to do the serialization into the relevant response format - as long as the relevant set of output formatters is plugged into your application.

The same process works in the opposite direction - for the so called input formatters. This is not a new topic, as we have covered that here in the past (probably more than once too). In other words, when building features on top of MVC, content negotiation is always there, right at your fingertips, ready to be used whenever you feel like you need it - even if all you rely on is, for example, JSON.

Things get slightly more interesting when you consider using ASP.NET Core without the MVC framework. You could, after all, build your entire API and its feature set using middleware components or using lightweight routing extensions.

In those situations, you are however responsible for writing your own HTTP response directly which makes content negotiation rather cumbersome process. A similarly interesting case is when you’d want to have sort of a “dry run” of content negotiation - so if you’d like to determine what media type should be used to respond to the caller, without actually writing this response yet.

In the past, in the old ASP.NET Web API framework, content negotiation was actually exposed as a standalone service, which meant you could at least go through the process manually, and perform conneg with the help of that service. Unfortunately in ASP.NET Core, conneg engine is kind of coupled to MVC and its IActonResult concept, and buried inside ObjectResultExecutor, which then does content negotiation internally.

Middleware invocation of IActionResultExecutors in ASP.NET Core 2.1 πŸ”—

In ASP.NET Core 2.1, it is actually possible to use the framework’s IActionResultExecutor executors, such as the aforementioned ObjectResultExecutor, from inside middleware. This is a great improvement, allowing us to have much wider set of features that we can use when building the HTTP APIs without the MVC framework.

This feature set is described in great depth by Kristian in an excellent blog post, so there is not much point in me repeating this stuff here. Long story short, if you are inside a middleware component, you could resolve IActionResultExecutor from the DI container, and use that to write your model to the response while respecting the content negotiation.

Here is the extension method that Kristian came up with:

public static class HttpContextExtensions
{
    private static readonly RouteData EmptyRouteData = new RouteData();

    private static readonly ActionDescriptor EmptyActionDescriptor = new ActionDescriptor();

    public static Task WriteModelAsync<TModel>(this HttpContext context, TModel model)
    {
        var result = new ObjectResult(model)
        {
            DeclaredType = typeof(TModel)
        };

        return context.ExecuteResultAsync(result);
    }

    public static Task ExecuteResultAsync<TResult>(this HttpContext context, TResult result)
        where TResult : IActionResult
    {
        if (context == null) throw new ArgumentNullException(nameof(context));
        if (result == null) throw new ArgumentNullException(nameof(result));

        var executor = context.RequestServices.GetRequiredService<IActionResultExecutor<TResult>>();

        var routeData = context.GetRouteData() ?? EmptyRouteData;
        var actionContext = new ActionContext(context, routeData, EmptyActionDescriptor);

        return executor.ExecuteAsync(actionContext, result);
    }
}

You can then use it in the following manner:

public void Configure(IApplicationBuilder app)
{
    app.Use((context, next) =>
    {
        var responseModel = new[] 
        {
            new Book
            {
                Author = "Eric Zweig",
                Title = "The Toronto Maple Leafs: The Complete Oral History",
            },
            new Book
            {
                Author = "Landolf Scherzer",
                Title = "Buenos dias, Kuba: Reise durch ein Land im Umbruch",
            }
        };

        return context.WriteModelAsync(responseModel);
    });
}

Unfortunately, the downside to this approach - for the time being - is that you will still need to reference at least the Microsoft.AspNetCore.Mvc.Core package in your project, as this is where those executors are defined. In fact, you actually need to wire in the MVC framework completely (even if it’s just the “core” of MVC), otherwise the services used here, such as ObjectResultExecutor and friends, would not resolve correctly.

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvcCore()
        .AddXmlSerializerFormatters()
        .AddJsonFormatters()
        .AddCsvSerializerFormatters();
}

By the way, to make things more interesting, I included not only XML and JSON formatters, which are part of ASP.NET Core MVC, but also a CSV formatter from WebApiContrib.

Now your middleware can handle all three response types.

Manual content negotiation in ASP.NET Core πŸ”—

If you don’t like that approach, there is an alternative we can use - and the benefit of that one will be that you will be completely skipping the wiring-in of the MVC framework.

While the logic of content negotiation is, as mentioned, packaged into ObjectResultExecutor, there is another service, called OutputFormatterSelector (it’s an abstract class, the default implementation is called, shockingly, DefaultOutputFormatterSelector), which is used there internally to perform the core conneg activity.

As a consequence, turns out that you could actually leverage that one to perform conneg by hand - just resolve it from the dependency injection container, set up a few additional things, and off you go. The code is shown below.

public static class HttpContextExtensions
{
    public static (IOutputFormatter SelectedFormatter, OutputFormatterWriteContext FormatterContext) SelectFormatter<TModel>(this HttpContext context, TModel model)
    {
        if (context == null) throw new ArgumentNullException(nameof(context));
        if (model == null) throw new ArgumentNullException(nameof(model));

        var selector = context.RequestServices.GetRequiredService<OutputFormatterSelector>();
        var writerFactory = context.RequestServices.GetRequiredService<IHttpResponseStreamWriterFactory>();
        var formatterContext = new OutputFormatterWriteContext(context, writerFactory.CreateWriter, typeof(TModel), model);

        var selectedFormatter = selector.SelectFormatter(formatterContext, Array.Empty<IOutputFormatter>(), new MediaTypeCollection());
        return (selectedFormatter, formatterContext);
    }
}

In the above code example, we created our own extension method, that reaches into the DI container, and resolves OutputFormatterSelector. It then constructs an instance of OutputFormatterWriteContext, which is required as input into the conneg process, alongside few other pieces of info, such as the type of our response model and the stream writer factory that can be used to write the result to the HTTP response.

Since we want two pieces of information to be returned by our extension method, and since it’s year 2018 in C#, we just use a tuple to do it - with both the selected formatter (negotiated for us by the OutputFormatterSelector, and the OutputFormatterWriteContext being returned.

This approach has one extra advantage. Since we split the process of content negotiation and writing to the response to two steps, we can now perform a “dry run” if we want to. So we can ask ASP.NET Core which formatter is the most suitable one for the given request, without flushing the response yet. Should we decide that now is the time to actually write the response, we can do it at any point by just using the selected formatter instance. This is shown in the next snippet.

app.Use((context, next) =>
{
    var responseModel = new[] 
    {
        new Book
        {
            Author = "Eric Zweig",
            Title = "The Toronto Maple Leafs: The Complete Oral History",
        },
        new Book
        {
            Author = "Landolf Scherzer",
            Title = "Buenos dias, Kuba: Reise durch ein Land im Umbruch",
        }
    };

    var (formatter, formatterContext) = context.SelectFormatter(responseModel);
    // if needed, inspect the result of the negotiation here
    
    return formatter.WriteAsync(formatterContext);
});

In order to make this work, we need one additional change. Contrary to the earlier approach, there is no need to fully wire in the MVC with AddMvcCore(), but we still need to ensure the several services mandatory for OutputFormatterSelector to work are in place.

I extracted them into a separate extension method, and connected them to a dummy instance of the MvcCoreBuilder so that we can reuse the same registration extension methods for XML and JSON as before.

public static class ServiceCollectionExtensions
{
    public static IMvcCoreBuilder AddFormatterServices(this IServiceCollection services)
    {
        services.TryAddSingleton<IHttpRequestStreamReaderFactory, MemoryPoolHttpRequestStreamReaderFactory>();
        services.TryAddSingleton<IHttpResponseStreamWriterFactory, MemoryPoolHttpResponseStreamWriterFactory>();
        services.TryAddSingleton(ArrayPool<byte>.Shared);
        services.TryAddSingleton(ArrayPool<char>.Shared);
        services.TryAddEnumerable(
            ServiceDescriptor.Transient<IConfigureOptions<MvcOptions>, MvcCoreMvcOptionsSetup>());
        services.TryAddSingleton<OutputFormatterSelector, DefaultOutputFormatterSelector>();

        return new MvcCoreBuilder(services, new ApplicationPartManager());
    }
}

The usage now is the following:

public void ConfigureServices(IServiceCollection services)
{
    services.AddFormatterServices()
        .AddXmlSerializerFormatters()
        .AddJsonFormatters()
        .AddCsvSerializerFormatters();
}

Which I think is a nice, lightweight way of doing this.

We are pretty much done at this point. Of course you can now send a request with i.e. Accept: application/xml header, and our middleware returns:

<ArrayOfBook xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
    <Book>
        <Title>The Toronto Maple Leafs: The Complete Oral History</Title>
        <Author>Eric Zweig</Author>
    </Book>
    <Book>
        <Title>Buenos dias, Kuba: Reise durch ein Land im Umbruch</Title>
        <Author>Landolf Scherzer</Author>
    </Book>
</ArrayOfBook>

As soon as you send a request with Accept: application/json, it responds with:

[
    {
        "title": "The Toronto Maple Leafs: The Complete Oral History",
        "author": "Eric Zweig"
    },
    {
        "title": "Buenos dias, Kuba: Reise durch ein Land im Umbruch",
        "author": "Landolf Scherzer"
    }
]

And finally, if you request Accept: text/csv, the CSV formatter is selected, and you get the following response:

Title;Author
The Toronto Maple Leafs: The Complete Oral History;Eric Zweig
Buenos dias, Kuba: Reise durch ein Land im Umbruch;Landolf Scherzer

Hope you find it useful! The code for this article is available on Github.

About


Hi! I'm Filip W., a software architect from ZΓΌrich πŸ‡¨πŸ‡­. I like Toronto Maple Leafs πŸ‡¨πŸ‡¦, Rancid and quantum computing. Oh, and I love the Lowlands 🏴󠁧󠁒󠁳󠁣󠁴󠁿.

You can find me on Github, on Mastodon and on Bluesky.

My Introduction to Quantum Computing with Q# and QDK book
Microsoft MVP