Using the new LengthAttribute in .NET 8 with EFCore and Swashbuckle

In .NET 8 Preview 2 and the final release, a new LengthAttribute which is a shorter-hand for using both MinLengthAttribute and MaxLengthAttribute but not a replacement. However, this support did not show up in EFCore 8 and is yet to be added to Swashbuckle.AspNetCore. See this GitHub issue for EFCore, but I could not find any for Swashbuckle.

The model

The model before looked like this:

public class MyClass
{
    [MinLength(5), MaxLength(12)]
    public string RegistrationNumber { get; set; }
}

The model changes to:

public class MyClass
{
    [Length(5, 12)]
    public string RegistrationNumber { get; set; }
}

Entity Framework Core 8

To make EFCore 8 pickup maximum length from the LengthAttribute, you can override the OnModelCreating(...) method ad iterate through entities and their properties but if you have this nested beyond one level, it does not work. The solution to this is a convention that can be applied to the whole context. In fact, the inbuilt support for MaxLengthAttribute is done via a convention; see the source.

We can use this as the base to build our own convention LengthAttributeConvention:

using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata.Internal;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Reflection;
 
namespace Tingle.Extensions.EntityFrameworkCore;
 
/// <summary>
/// A convention that configures the maximum length based on the <see cref="LengthAttribute" /> applied on the property.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-conventions">Model building conventions</see> for more information and examples.
/// </remarks>
public class LengthAttributeConvention : PropertyAttributeConventionBase<LengthAttribute>, IComplexPropertyAddedConvention
{
    /// <summary>
    /// Creates a new instance of <see cref="LengthAttributeConvention" />.
    /// </summary>
    /// <param name="dependencies">Parameter object containing dependencies for this convention.</param>
    public LengthAttributeConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies) { }
 
    /// <inheritdoc />
    protected override void ProcessPropertyAdded(
        IConventionPropertyBuilder propertyBuilder,
        LengthAttribute attribute,
        MemberInfo clrMember,
        IConventionContext context)
    {
        if (attribute.MaximumLength > 0)
        {
            propertyBuilder.HasMaxLength(attribute.MaximumLength, fromDataAnnotation: true);
        }
    }
 
    /// <inheritdoc />
    protected override void ProcessPropertyAdded(
        IConventionComplexPropertyBuilder propertyBuilder,
        LengthAttribute attribute,
        MemberInfo clrMember,
        IConventionContext context)
    {
        var property = propertyBuilder.Metadata;
#pragma warning disable EF1001
        var member = property.GetIdentifyingMemberInfo();
#pragma warning restore EF1001
        if (member != null
            && Attribute.IsDefined(member, typeof(ForeignKeyAttribute), inherit: true))
        {
            throw new InvalidOperationException(
                CoreStrings.AttributeNotOnEntityTypeProperty(
                    "MaxLength", property.DeclaringType.DisplayName(), property.Name));
        }
    }
}

To register this I use an extension method since there are many other conventions I have:

using Microsoft.EntityFrameworkCore.Metadata.Conventions.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using System.ComponentModel.DataAnnotations;
 
namespace Microsoft.EntityFrameworkCore;
 
/// <summary>Extensions for <see cref="ModelConfigurationBuilder"/>.</summary>
public static class ModelConfigurationBuilderExtensions
{
    // omitted other conventions
 
    /// <summary>
    /// Add convention for handling <see cref="LengthAttribute"/>.
    /// </summary>
    /// <param name="configurationBuilder">The <see cref="ModelConfigurationBuilder"/> to use.</param>
    public static void AddLengthAttributeConvention(this ModelConfigurationBuilder configurationBuilder)
    {
        ArgumentNullException.ThrowIfNull(configurationBuilder);
 
        configurationBuilder.Conventions.Add(provider
            => new LengthAttributeConvention(
                provider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
    }
}

In the DB Context:

public class MyDbContext : DbContext
{
    public MyDbContext(DbContextOptions<MyDbContext> options) : base(options) {}
 
    /// <inheritdoc />
    protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
    {
        base.ConfigureConventions(configurationBuilder);
        configurationBuilder.AddLengthAttributeConvention();
    }
 
    // you can configure your model and DbSet below
}

Swashbuckle/Swagger/OpenAPI support

To add this support, we need to create a schema filter by implementing ISchemaFilter. We are guided by two files in the codebase:

  1. SchemaGenerator.cs
  2. OpenApiSchemaExtensions.cs

First, we create the schema filter:

using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
 
namespace Tingle.AspNetCore.Swagger;
 
public class LengthAttributeSchemaFilter : ISchemaFilter
{
    /// <inheritdoc/>
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        var memberInfo = context.MemberInfo;
        if (memberInfo is not null)
        {
            var customAttributes = context.MemberInfo.GetInlineAndMetadataAttributes();
            ApplyAttributes(schema, customAttributes);
        }
 
        var parameterInfo = context.ParameterInfo;
        if (parameterInfo is not null)
        {
            var customAttributes = parameterInfo.GetCustomAttributes();
            ApplyAttributes(schema, customAttributes);
        }
    }
 
    private static void ApplyAttributes(OpenApiSchema schema, IEnumerable<object> customAttributes)
    {
        foreach (var attribute in customAttributes)
        {
            if (attribute is LengthAttribute lengthAttribute)
            {
                if (schema.Type == "array")
                {
                    schema.MinItems = lengthAttribute.MinimumLength;
                    schema.MaxItems = lengthAttribute.MaximumLength;
                }
                else
                {
                    schema.MinLength = lengthAttribute.MinimumLength;
                    schema.MaxLength = lengthAttribute.MaximumLength;
                }
            }
        }
    }
}

Notice that we set MinItems and MaxItems for arrays while we set MinLength and MaxLength for the others. This matches usage for collections (e.g. IList<T>, IEnumerable<T>, etc.) and strings respectively.

Next we register the filter in the options:

builder.Services.AddSwaggerGen(options =>
{
    options.SchemaFilter<LengthAttributeSchemaFilter>();
});

That's all, now you will have the same behaviour as when not using MinLengthAttribute and MaxLengthAttribute.

This functionality may be added to the respectively libraries in the future rendering the code here useless.