My blog about software development on the Microsoft® stack.

Custom Data Types in ASP.NET Core Web APIs

If you have issues with how Swagger generates the documentation for custom types in your ASP.NET Core Web APIs, you should read this post.

OpenAPI / Swagger

OpenAPI is a widely used industry standard specification for documenting APIs, such as the ones you create using ASP.NET Core and the web API project template in Visual Studio 2019 or using the .NET Core command-line interface (CLI) and the dotnet new webapi command.

Swagger is a set of tools that implement this specification. For .NET, there is for example the Swashbuckle.AspNetCore NuGet package that automatically produces a JSON document and an HTML, Javascript and CSS based documentation of your REST API based on the implementation of your controller classes and the data they return. Version 5.0.0 or later of Swashbuckle supports ASP.NET Core 3.1 and OpenAPI 3, which is the latest version of the specification at the time of writing this.

Setting things up

Once you have installed the NuGet package, you add the Swagger generator to the services collection in the ConfigureServices method of your Startup class as described in the docs:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddSwaggerGen(c =>
    {
        c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
    });
}

Don’t forget to add a using directive for the Microsoft.Extensions.DependencyInjection namespace.

In the Configure method, you then enable the middleware that serves the generated JSON document and the web UI that’s generated based on it:

public void Configure(IApplicationBuilder app)
{
    app.UseSwagger();

    app.UseSwaggerUI(c =>
    {
        c.SwaggerEndpoint("/swagger/v1/swagger.json", "My API V1");
    });

    app.UseRouting();
    app.UseEndpoints(endpoints => endpoints.MapControllers());
}

You can then run your app and navigate to http://localhost:<port>/swagger/v1/swagger.json to download the generated JSON document that describes your API. The web UI is at http://localhost:<port>/swagger by default. You can set the RoutePrefix property of the SwaggerUIOptions object that gets passed to the UseSwaggerUI method to change the URL.

Data Types

OpenAPI defines the following basic data types:

  • string
  • number
  • integer
  • boolean
  • array
  • object

 
A data type is accompanied by an optional format identifier. A date is for example represented by a type of string and a format of date (string($date)), and a signed 64-bit integer (long or Int64 in C#) has a type of integer and a format of int64 (integer($int64)). You’ll find all valid combinations in the docs at swagger.io.

Swashbuckle automatically maps between the OpenAPI types and the built-in .NET types as expected. For example, an int property in C# gets represented by an integer($int32) in the generated documentation, and a DateTime is represented by a string($date-time).

If you define properties of custom types in your model or data transfer object (DTO) types, you will end up with an expandable resource by default. The picture below shows how Swagger generates the documentation for the following simple Date struct by default:

//NOTE: Operators, overloads and interface implementations have been omitted for brevity.
public readonly struct Date
{
    public Date(int year, int month, int day)
    {
        if (year < 1 || year > 9999)
            throw new ArgumentOutOfRangeException(nameof(year));
        if (month < 1 || month > 12)
            throw new ArgumentOutOfRangeException(nameof(month));
        if (day < 1 || day > DateTime.DaysInMonth(year, month))
            throw new ArgumentOutOfRangeException(nameof(day));

        Year = year;
        Month = month;
        Day = day;
    }

    public readonly int Year { get; }
    public readonly int Month { get; }
    public readonly int Day { get; }
}

Schemas

I’ve intentionally left out the implemention of any interfaces, methods and operators here. I also know that the built-in DateTime struct has a Date property, but it just returns another DateTime object with the TimeOfDay property set to TimeSpan.Zero. This is equivalent to a time of 00:00:00.

An expandable resource like this is fine for complex types that cannot be mapped directly to an OpenAPI counterpart. But since the above Date type is meant to represent a date without a time, you probably want Swagger to document it as an OpenAPI string with a format of date. You do this by adding a mapping to the SwaggerGenOptions object that gets passed to the AddSwaggerGen method that is called in the ConfigureServices method of the Startup class:

services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "My API", Version = "v1" });
    c.MapType<Date>(() => new OpenApiSchema { Type = "string", Format = "date" });
});

This makes the documentation look a lot cleaner:

Clean Schemas

Remember that the OpenAPI specification is language-agnostic and that your API should be consumable by clients that doesn’t know anything about .NET or the data types in C#.

I should also mention that you can break out the Swagger configuration and the type mappings from ConfigureServices into a separate class that implements the IConfigureOptions interface. That’s what Microsoft have done in their example in the ASP.NET API Versioning repo on GitHub.

If you want to customize the example value for a custom property that is displayed when you expand an operation in the web UI, the OpenApiSchema class has an Example property that can be set to an IOpenApiAny. The Microsoft.OpenApi NuGet package contains implementations of this interface for all OpenAPI types. If you however use the OpenApiDate class to provide an example date like this:

c.MapType<Date>(() => new OpenApiSchema { Type = "string", Format = "date", 
    Example = new OpenApiDate(new DateTime(2020, 1, 1)) });

…you will notice that you just get a string representation of the DateTime object (including the time part) that you pass to the constructor:

Example Value

If you want the sample value that is displayed in the documentation to actually reflect the actual type of the property, you can provide your own implementation of the Write method of the IOpenApiExtension interface (from which IOpenAny derives):

public class CustomOpenApiDateTime : IOpenApiPrimitive
{
    public CustomOpenApiDateTime(DateTime value)
    {
        Value = value;
    }

    public AnyType AnyType { get; } = AnyType.Primitive;

    public string Format { get; set; } = "yyyy-MM-dd";

    public PrimitiveType PrimitiveType { get; } = PrimitiveType.DateTime;

    public DateTime Value { get; }

    public void Write(IOpenApiWriter writer, OpenApiSpecVersion specVersion) => 
        writer.WriteValue(Value.ToString(Format));
}

Serialization

Besides the customization of the OpenAPI documentation, you should also define how your custom data types are serialized and deserialized. The System.Text.Json serializer, which is the default one used in ASP.NET Core 3.0 and later versions, will for example serialize a value of new Date(2020, 4, 2) like this by default:

{"year":2020,"month":4,"day":2}

If you want it to behave and look like an OpenAPI date to the consumers of your API, which you most probably do, you should implement a custom converter that serializes any value of a Date as such. Below is an example.

public class DateJsonConverter : JsonConverter<Date>
{
    public override bool CanConvert(Type typeToConvert) => typeToConvert == typeof(Date);

    public override Date Read(ref Utf8JsonReader reader, Type typeToConvert, 
        JsonSerializerOptions options)
    {
        DateTime dateTime = reader.GetDateTime();
        return new Date(dateTime.Year, dateTime.Month, dateTime.Day);
    }

    public override void Write(Utf8JsonWriter writer, Date value, JsonSerializerOptions options)
    {
        const string Format = "yyyy-MM-dd";
        writer.WriteStringValue(new DateTime(value.Year, value.Month, value.Day).ToString(Format));
    }
}

Don’t forget to tell ASP.NET Core to use your custom converter. You do this by calling the AddJsonOptions extension method on the IMvcBuilder that is returned from the call to AddControllers() in the ConfigureServices method of the Startup class. It accepts an action with a JsonOptions parameter. You add an instance of the custom converter class to the Converters collection of its JsonSerializerOptions property like this:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers()
        .AddJsonOptions(options => options.JsonSerializerOptions.Converters
            .Add(new DateJsonConverter()));

    services.AddSwaggerGen(c =>
    {
        ...
    });
}

If you are still using Newtonsoft.Json, your converter class should inherit from Newtonsoft.Json.JsonConverter<T> and override the ReadJson and WriteJson methods. The implementation is very similar:

public class DateNewtonsoftJsonConverter : JsonConverter<Date>
{
    private const string Format = "yyyy-MM-dd";

    public override Date ReadJson(JsonReader reader, Type objectType, Date existingValue, 
        bool hasExistingValue, JsonSerializer serializer)
    {
        DateTime dateTime = DateTime.ParseExact((string)reader.Value, Format, 
            CultureInfo.InvariantCulture);
        return new Date(dateTime.Year, dateTime.Month, dateTime.Day);
    }

    public override void WriteJson(JsonWriter writer, Date value, JsonSerializer serializer)
    {
        writer.WriteValue(new DateTime(value.Year, value.Month, value.Day).ToString(Format));
    }
}

There is an AddNewtonsoftJson extension method defined in the Microsoft.AspNetCore.Mvc.NewtonsoftJson NuGet package that accepts an action with a single MvcNewtonsoftJsonOptions parameter. This type has a SerializerSettings property that returns a Newtonsoft.Json.JsonSerializerSettings object which in turn has a Converters property:

services.AddControllers()
    .AddNewtonsoftJson(options => options.SerializerSettings.Converters
        .Add(new DateNewtonsoftJsonConverter()));

The main difference between Newtonsoft.Json and System.Text.Json is that the latter is more performant and allocate less memory on the managed heap under the hood. Please refer to Immo Landwerth’s blog post for more details.

In this particular sample code, I’ve used the DateTime class to parse and format the value that is deserialized and serialized in both converter implementations. You can of course avoid doing this if you’re not too lazy to implement your own custom parsing logic. You may also choose to override the ToString() method of the Date type and call it to get a string representation of the date in the converter(s).

Parameters

So far only properties of models or DTOs have been handled. Assume you want to define a REST endpoint for getting some kind of items based on a date (without a time). Your controller action would then be implemented something like this:

/// <summary>
/// Retrieves an array of items for a specific date.
/// </summary>
/// <param name="date">The date of the items to be retrieved.</param>
/// <returns>An array of items.</returns>
[HttpGet]
[ProducesResponseType(typeof(Model[]), StatusCodes.Status200OK)]
public IEnumerable<Model> Get([BindRequired, FromQuery(Name = "date")]Date date) =>
    _items.Where(x => x.Date.Equals(date)).ToArray();

If you run the app and try to browse to http://localhost:<port>/items at this point, you will get an InvalidOperationException saying that an instance of the custom Date class could be created:

Web API Exception

This makes perfect sense since I haven’t yet specified how the framework should construct a Date object based on the query string somewhere. This is what a model binder is used for. A model binder handles the mapping between incoming request data and custom data types. It’s a class that implements the Microsoft.AspNetCore.Mvc.ModelBinding.IModelBinder interface.

In the below sample implementation, the DateTime.TryParse method is used to try to parse the query string value to a DateTime object. If the conversion succeeds, a Date object is created and eventually being passed to the controller action by the framework.

public class DateModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        string modelName = bindingContext?.ModelName;
        if (!string.IsNullOrEmpty(modelName))
        {
            ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
            if (valueProviderResult != ValueProviderResult.None)
            {
                bindingContext.ModelState.SetModelValue(modelName, valueProviderResult);

                if (!DateTime.TryParse(valueProviderResult.FirstValue,
                    CultureInfo.InvariantCulture,
                    DateTimeStyles.None,
                    out DateTime dateTime))
                {
                    bindingContext.ModelState.TryAddModelError(modelName, "Invalid date.");
                    return Task.CompletedTask;
                }
                else
                {
                    bindingContext.Result =
                        ModelBindingResult.Success(new Date(dateTime.Year, dateTime.Month,
                            dateTime.Day));
                }
            }
        }
        return Task.CompletedTask;
    }
}

You can apply a model binder to a custom type by decorating the type with the [Microsoft.AspNetCore.MvcModelBinder] attribute like this:

[ModelBinder(BinderType = typeof(DateModelBinder))]
public class Date
{
    ...
}

If you can’t or don’t want to modify your model or DTO types for some reason, you could instead apply the attribute to the parameter in the controller:

public IEnumerable<Model> Get([BindRequired,
    FromQuery(Name = "date"),
    ModelBinder(BinderType = typeof(DateModelBinder))]Date date) =>
    _items.Where(x => x.Date == date).ToArray();

The third option is to create a custom model binder provider class that implements the IModelBinderProvider interface:

public class DateBindingProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (context.Metadata.ModelType == typeof(Date))
        {
            return new BinderTypeModelBinder(typeof(DateModelBinder));
        }

        return null;
    }
}

The provider is instantiated and applied in the ConfigureServices method of the Startup class:

services.AddControllers(options =>
{
    options.ModelBinderProviders.Insert(0, new DateBindingProvider());
})
.AddJsonOptions(options => options.JsonSerializerOptions.Converters.Add(new DateJsonConverter()));

With the model binder in place, you can now browse to the REST endpoint at http://localhost:<port>/items?date=yyyy-MM-dd and get a successful response with an HTTP status of 200 back.

For the comparison of the date in the controller method to work as expected, you should override the Equals and GetHashCode methods and implement the IEquatable<T> interface in the Date struct:

public readonly bool Equals(Date other) =>
    Year == other.Year && Month == other.Month && Day == other.Day;

public readonly override bool Equals(object obj) =>
    obj is Date date && Equals(date);

public readonly override int GetHashCode() =>
    HashCode.Combine(Year.GetHashCode(),
        Month.GetHashCode(),
        Day.GetHashCode());

Going back to the documentation, this is how Swagger presents the operation with the custom parameter in the web browser:

Controller Action

You can use a type converter to replace the Year, Month and Day parameters with a single date parameter. ASP.NET Core’s built-in metadata provider that is defined in the Microsoft.AspNetCore.Mvc.ApiExplorer package uses the converter to map between a simple string and a custom type that is decorated with the [System.ComponentModel.TypeConverter] attribute:

[TypeConverter(typeof(CustomParameterTypeConverter))]
public class Model
{
    public Date Date { get; set; }
}

An option to decorating the model or DTO class with the [TypeConverter] attribute is to call the static TypeDescriptor.AddAttributes method in the ConfigureServices method of the Startup class:

TypeConverterAttribute typeConverterAttribute = 
    new TypeConverterAttribute(typeof(CustomParameterTypeConverter));
TypeDescriptor.AddAttributes(typeof(Date), typeConverterAttribute);

The implementation of the type converter itself may be as simple as this:

public class CustomParameterTypeConverter : TypeConverter
{
    public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) =>
        sourceType == typeof(string);
}

You might have expected that the ApiExplorer should use the configured model binder to represent the type without the need for an additional type converter class, but it currently doesn’t as reported in this GitHub issue.

Summary

Having defined a custom model binder, a JSON serializer, a type converter, an IOpenApiAny implementation to represent an example value of your custom type, and optionally also a model binder provider, you’re finally ready to ship the OpenAPI and Swagger documentation for your REST API.

You’ll find the source code presented in this blog post included in a minimal and compilable ASP.NET Core Web API sample project that can be downloaded from GitHub.



Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s