Output formatters in ASP.NET Core Web API

By | January 7, 2022

This post will be about how to implement custom output formatters for ASP.Net Core Web APIs.

I needed to create custom output formatters for my spatial data model because geometry objects can contain negative/positive infinity and nan types. JSON does not support these types.

GeoJson Output Formatter

So I preceded to write my own output formatter using the following code. It uses the NetTopologySuite.IO converter factory which overcomes the negative/positive infinity and nan issue for geometry types.

The following code is for the Geo+Json media type. This is commonly used to add layers to maps. The format has features, feature collections, geometries and attributes. My geometry was a point but others are supported as well.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Formatters;
using NetTopologySuite.Features;
using NetTopologySuite.Geometries;
using NOMS.Shared.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;

public static class GeoJsonOutputFormatterBuilderExtensions
{
    public static IMvcCoreBuilder AddGeoJsonSerializerOutputFormatters(this IMvcCoreBuilder builder)
    {
        if (builder == null)
        {
            throw new ArgumentNullException(nameof(builder));
        }

        builder.AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("geo+json", new MediaTypeHeaderValue("application/geo+json")));

        builder.AddMvcOptions(options => options.OutputFormatters.Add(new GeoJsonOutputFormatter()));

        return builder;
    }
}

public class GeoJsonOutputFormatter : OutputFormatter
{
    public GeoJsonOutputFormatter()
    {
        ContentType = "application/geo+json";
        SupportedMediaTypes.Add(Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/geo+json"));
    }

    public string ContentType { get; private set; }

    public async override Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
    {
        var outputOptions = new OutputOptions();

        // create a feature collection from the context
        FeatureCollection featureCollection = new FeatureCollection();

        foreach (var obj in (IEnumerable<object>)context.Object)
        {
            Feature feature = new Feature();

            feature.Attributes = new AttributesTable();

            var vals = obj.GetType().GetProperties().Select(propInfo => new
            {
                Value = propInfo.GetValue(obj, null),
                Name = propInfo.Name,
                Type = propInfo.PropertyType,
            });

            foreach (var val in vals)
            {
                if (val.Type.FullName == "System.DateTime")
                {
                    DateTime valDate = (DateTime)val.Value;
                    string valBase = valDate.ToString(outputOptions.ISO_8601_UTC_DateTime);
                    feature.Attributes.Add(val.Name, valBase);
                }
                else
                {
                    feature.Attributes.Add(val.Name, val.Value);
                }
            }

            var latitude = (double)feature.Attributes.GetOptionalValue("Latitude");
            var longitude = (double)feature.Attributes.GetOptionalValue("Longitude");

            feature.Geometry = new Point(longitude, latitude);

            featureCollection.Add(feature);
        }

        featureCollection.BoundingBox = null;

        // serialize the feature collection

        var response = context.HttpContext.Response;

        var streamWriter = new StreamWriter(response.Body);

        var options = new JsonSerializerOptions();

        options.Converters.Add(new NetTopologySuite.IO.Converters.GeoJsonConverterFactory());

        var serialized = JsonSerializer.Serialize(featureCollection, options);
        var stringifiedAreas = serialized.ToString();

        await streamWriter.WriteLineAsync(stringifiedAreas);

        await streamWriter.FlushAsync();
    }
}

Add the new media type to the api controller

To use my new output formatter I added the following media type to the produces filter on my api call.

[Produces("application/geo + json")]
[HttpGet("geojson")]
public async Task<IActionResult> Get([FromQuery] string param1, [FromQuery] string param2, [FromQuery] string param3)
{
    return Ok(await _context.myTable(param1, param2, param3));
}

Then I load my custom output formatter by calling the following method in my startup.cs file.

private static void ConfigureOutputFormatters(IServiceCollection services)
{
    services.AddMvcCore().AddGeoJsonSerializerOutputFormatters();
}

I ran the code and found that my custom output formatter was not being used. That is when I discovered that the built in SystemTextJsonOutputFormatter was being used instead, but why?

My media type is application/geo + json and should be unique among other formatters but its not. Why is this? I dug further and found that the media types for the SystemTextJsonOutputFormatter are as follows:

  • application/json
  • text/json
  • application/* + json

I could remove the wildcard media type from SystemTextJsonOutputFormatter but decided instead to remove the default SystemTextJsonOutputFormatter and replace it with my own.

Replace built in output formatters with custom ones

I created three custom output formatters: kml, geojson and json and then loaded them by calling the following method in my startup.cs file.

private static void ConfigureOutputFormatters(IServiceCollection services)
{
    services.AddMvcCore(options =>
    {
        options.OutputFormatters.RemoveType<Microsoft.AspNetCore.Mvc.Formatters.SystemTextJsonOutputFormatter>();
    });

    services.AddMvcCore().AddKmlSerializerOutputFormatters();
    services.AddMvcCore().AddGeoJsonSerializerOutputFormatters();
    services.AddMvcCore().AddJsonSerializerOutputFormatters();
}

JSON output formatter

Just like the GeoJson output formatter I wanted the JSON formatter to handle spatial types too. That was easily accomplished with the following code.

using System;
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Formatters;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;

public static class JsonOutputFormatterBuilderExtensions
{
    public static IMvcCoreBuilder AddJsonSerializerOutputFormatters(this IMvcCoreBuilder builder)
    {
        if (builder == null)
        {
            throw new ArgumentNullException(nameof(builder));
        }

        builder.AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("json", new MediaTypeHeaderValue("text/json")));

        builder.AddMvcOptions(options => options.OutputFormatters.Add(new JsonOutputFormatter()));

        return builder;
    }
}

public class JsonOutputFormatter : OutputFormatter
{
    public JsonOutputFormatter()
    {
        ContentType = "text/json";
        SupportedMediaTypes.Add(Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse("text/json"));
    }

    public string ContentType { get; private set; }

    public async override Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
    {
        var response = context.HttpContext.Response;

        var streamWriter = new StreamWriter(response.Body);

        var options = new JsonSerializerOptions();

        options.Converters.Add(new NetTopologySuite.IO.Converters.GeoJsonConverterFactory());

        var serialized = JsonSerializer.Serialize(context.Object, options);
        var stringifiedGeometry = serialized.ToString();

        await streamWriter.WriteLineAsync(stringifiedGeometry);

        await streamWriter.FlushAsync();
    }
}

Kml output formatter

KML is another common output type. Like GeoJson it is commonly used to add layers to maps. The format has placemarks, geometries and extended data. It is in a XML format. I put all the models data into extended data and choose SiteName from my placemark name. The following is the code I used.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Net.Http.Headers;
using Microsoft.AspNetCore.Mvc.Formatters;

using SharpKml.Base;
using SharpKml.Dom;
using Point = SharpKml.Dom.Point;

public static class KmlOutputFormatterBuilderExtensions
{
    public static IMvcCoreBuilder AddKmlSerializerOutputFormatters(this IMvcCoreBuilder builder)
    {
        if (builder == null)
        {
            throw new ArgumentNullException(nameof(builder));
        }

        builder.AddFormatterMappings(m => m.SetMediaTypeMappingForFormat("kml", new MediaTypeHeaderValue("application/vnd.google-earth.kml+xml")));

        builder.AddMvcOptions(options => options.OutputFormatters.Add(new KmlOutputFormatter()));

        return builder;
    }
}

public class KmlOutputFormatter : OutputFormatter
{
    public KmlOutputFormatter()
    {
        ContentType = "application/vnd.google-earth.kml+xml";
        SupportedMediaTypes.Add(Microsoft.Net.Http.Headers.MediaTypeHeaderValue.Parse("application/vnd.google-earth.kml+xml"));
    }

    public string ContentType { get; private set; }

    public async override Task WriteResponseBodyAsync(OutputFormatterWriteContext context)
    {
        var doc = new Document();

        var kml = new Kml();

        var outputOptions = new OutputOptions();

        foreach (var obj in (IEnumerable<object>)context.Object)
        {
            var properties = obj.GetType().GetProperties();

            var vals = obj.GetType().GetProperties().Select(propInfo => new
            {
                Value = propInfo.GetValue(obj, null),
                Name = propInfo.Name,
                Type = propInfo.PropertyType,
            });

            string siteName = string.Empty;

            foreach (var val in vals)
            {
                if (val.Name == "SiteName")
                {
                    siteName = (string)val.Value;
                    break;
                }
            }

            var extendedData = new ExtendedData();
            double latitude = 0;
            double longitude = 0;

            foreach (var val in vals)
            {
                var data = new Data();
                data.Name = val.Name;
                data.DisplayName = val.Name;
                if (val.Type.Name != "Point" && val.Name != "SiteName")
                {
                    if (val.Type.FullName.Contains("System.String")) data.Value = val.Value == null ? "null" : val.Value.ToString();
                    if (val.Type.FullName.Contains("System.Double")) data.Value = val.Value == null ? "null" : val.Value.ToString();
                    if (val.Type.FullName.Contains("System.Int64")) data.Value = val.Value == null ? "null" : val.Value.ToString();
                    if (val.Type.FullName.Contains("System.DateTime"))
                    {
                        if (val.Value == null)
                        {
                            data.Value = "null";
                        }
                        else
                        {
                            DateTime valDate = (DateTime)val.Value;
                            data.Value = valDate.ToString(outputOptions.ISO_8601_UTC_DateTime);
                        }
                    }
                    extendedData.AddData(data);
                }
                if (val.Name == "Longitude")
                {
                    longitude = double.Parse(data.Value);
                }
                if (val.Name == "Latitude")
                {
                    latitude = double.Parse(data.Value);
                }
            }

            var point = new Point()
            {
                Coordinate = new Vector(latitude, longitude)
            };

            var placemark = new Placemark
            {
                Name = siteName,
                Geometry = point,
                ExtendedData = extendedData
            };

            doc.AddFeature(placemark);
        }

        kml.Feature = doc;

        // serialize the kml object
        var response = context.HttpContext.Response;

        // create unique filename to download
        Guid g = Guid.NewGuid();

        var file = "response_" + g.ToString() + ".kml";

        System.Net.Mime.ContentDisposition cd = new System.Net.Mime.ContentDisposition
        {
            FileName = file,
            Inline = true
        };
        response.Headers.Add("Content-Disposition", cd.ToString());
        response.Headers.Add("X-Content-Type-Options", "nosniff");

        var streamWriter = new StreamWriter(response.Body);

        var serializer = new Serializer();
        serializer.Serialize(kml);
        await streamWriter.WriteLineAsync(serializer.Xml);
        await streamWriter.FlushAsync();
    }
}

Conclusion

Hopefully this will help you in creating custom output formatters for your api needs. Have fun!