Wednesday 27 December 2017

Accept header based response formatting in asp net core

To be able to return responses from controller's actions based of on the returned type dot net core uses formatters. I've cobbled together an example how boilerplate code can look. This is pretty much similar to what you can find on msdn with some extra flair.

First, I've implemented the following 2 adapters (bear in mind there's a built-in xml serializer/formatter in .net core, I've created one for the sake of an example). They perform formatting on the list of cities passed in the form of dtos.

public class XmlCityAdapter : ICityTextAdapter
{
    public AdapterOutputFormat AdapterOutputFormat => AdapterOutputFormat.Xml;

    public string GetFormatted(IEnumerable<CityDto> cities)
    {
        var citiesElement = cities.Select(c =>
            new XElement("City",
                new XAttribute("Name", c.CityName),
                new XAttribute("Population", c.Population)));
        
        var doc = new XElement("Cities", citiesElement);
        return doc.ToString();
    }
}

and

public class CsvCityAdapter : ICityTextAdapter
{
    public AdapterOutputFormat AdapterOutputFormat => AdapterOutputFormat.Csv;

    public string GetFormatted(IEnumerable<CityDto> cities)
    {
        var stringBuilder = new StringBuilder();
        foreach (var city in cities)
        {
            stringBuilder.AppendLine($"{city.CityName};{city.Population}");
        }
        return stringBuilder.ToString();
    }
}

There are many, many ways to perform such serialization. Here's the interface behind these classes:

public interface ICityTextAdapter
{
    AdapterOutputFormat AdapterOutputFormat { get; }

    string GetFormatted(IEnumerable<CityDto> cities);
}

Next step was to add some boilerplate base class for the formatters. The formatter needs to derive from one of the base classes offered by core libraries. My choice was to use the OutputFormatter.
For our response formatter to work we need to override the following methods:
  • CanWriteType method (to asses, whether the passed in type should be formatted or not)
  • WriteResponseBodyAsync to do the actual formatting
Another method which we could override is WriteResponseHeaders. It is not really needed though, because the headers can be safely expanded in the WriteResponseBodyAsync method.
The base class will implement the above, it will also use ServiceProvider, which will allow us to use the container to get our adapter classes.

Here's the base class:

public abstract class BaseFormatter<TFormattedTypeTFormatter> : OutputFormatter
{
    private const string ContentDispositionHeader = "Content-Disposition";

    protected override bool CanWriteType(Type type)
    {
        return base.CanWriteType(type) && type.IsAssignableFrom(typeof(TFormattedType));
    }
    
    public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
    {
        if (!(context.Object is TFormattedType contextObject))
        {
            return;
        }

        var response = context.HttpContext.Response;
        var serviceProvider = response.HttpContext.RequestServices;
        var availableAdapters = serviceProvider
            .GetServices(typeof(TFormatter))
            .Cast<TFormatter>();                

        if(availableAdapters != null)
        {
            response.Headers.Add(ContentDispositionHeader, $"attachment;filename={GetFileName(contextObject)}");
            var formattedResult = GetFormattedResult(availableAdapters, contextObject);
            await response.WriteAsync(formattedResult);
        }             
    }

    protected abstract string GetFileName(TFormattedType contextObject);

    protected abstract string GetFormattedResult(
        IEnumerable<TFormatter> availableAdapters, 
        TFormattedType contextObject);
}

TFormattedType is the type, which we will validate against to check, if we can perform the formatting. If CanWriteType returns false other methods from the class won't be called.
TFormatter is the interface, which implementations of we will resolve from the container.

Given the formatters can't get dependencies through the constructor we need to get the container through the HttpContext. We will try and resolve all the implementation of our TFormatter type from it.

Another functionality of this class is to attach the disposition header. In this case each deriving class will set its disposition header as a filename passed through the overridable method.
Last thing to note is that the use of adapters have also been delegated to the deriving classes.

An example of the csv formatter is as follows, as you can see it passes List<CityDto> as the type, which is returned by the controller's action and which will get formatted. It also passes the interface ICityTextAdapter used for resolving the adapter implementations:

public class CsvCityFormatter : BaseFormatter<List<CityDto>, ICityTextAdapter>
{
    public CsvCityFormatter()
    {
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
    }
    
    protected override string GetFileName(List<CityDto> contextObject)
    {
        return "Cities.csv";
    }

    protected override string GetFormattedResult(
        IEnumerable<ICityTextAdapter> availableAdapters, 
        List<CityDto> contextObject)
    {
        var csvAdapter = availableAdapters.FirstOrDefault(p => p.AdapterOutputFormat == AdapterOutputFormat.Csv);
        if (csvAdapter == null)
            throw new NullReferenceException(nameof(csvAdapter));
        return csvAdapter.GetFormatted(contextObject);
    }
}

The actual implementation of the formatter adds the supported media type as well. Uses the abstract methods to:
  • pass the filename, which will be set as disposition header
  • chooses and uses the adapter
Last piece to note is the registration of the formatter in the Startup class:

services.AddMvc(options =>
{                
    options.OutputFormatters.Add(new XmlCityFormatter());
    options.OutputFormatters.Add(new CsvCityFormatter());
});

What could be further added is:

  • null/error checking
  • logging
  • selection of the adapter could be based on another generic type, instead of an enum - which would make it much more robust and solid.

The code sample will be added on github soon.

No comments:

Post a Comment