When building a web API with ASP.NET in most cases I want to generate Swagger (a.k.a. OpenAPI, what a boring name). Since records became a thing in C# I almost exclusively use positional records for my DTOs. I just assumed that
Swashbuckle handles everything perfectly. It was pointed out to me that our Swagger does not mark non-nullable properties as required and I then realized that in Swagger just like in JS non-nullable and required were not the same thing. I started digging and I found out that the OpenAPI specs allows for much more information around validation like a regex pattern, min and max value and a couple more that are rarer. So why are these not generated for my project?
Positional records generate properties for each constructor parameter. You can put validation attributes on the constructor parameters and ASP.NET will use them for validation just like validation attributes on properties. Sadly the OpenAPI support in ASP.NET does not expose those, it seems that it only looks at properties. There is a way to put the attribute on the property like this
record Person([property: MinLength(3)] string Name)
and the attribute will appear on the property however in this case ASP.NET throws an exception telling you that the attribute must be on the constructor parameter.
After some digging I am now using the following Swashbuckle filter to expose the validation attributes in the Swagger
public class PositionalRecordsSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema model, SchemaFilterContext context)
{
ConstructorInfo[] constructors = context.Type.GetConstructors();
if (constructors.Length == 1)
{
ParameterInfo[] parameters = constructors[0].GetParameters();
foreach (KeyValuePair<string, OpenApiSchema> modelProperty in model.Properties)
{
ParameterInfo? constructorParameter = parameters.FirstOrDefault(p => String.Equals(p.Name, modelProperty.Key, StringComparison.OrdinalIgnoreCase));
if (constructorParameter is not null)
{
if (constructorParameter.ParameterType == typeof(string))
{
foreach (Attribute attribute in constructorParameter.GetCustomAttributes())
{
if (attribute is MinLengthAttribute minLength)
{
modelProperty.Value.MinLength = minLength.Length;
}
else if (attribute is MaxLengthAttribute maxLength)
{
modelProperty.Value.MaxLength = maxLength.Length;
}
else if (attribute is StringLengthAttribute stringLength)
{
modelProperty.Value.MinLength = stringLength.MinimumLength;
modelProperty.Value.MaxLength = stringLength.MaximumLength;
}
else if (attribute is RegularExpressionAttribute regex)
{
modelProperty.Value.Pattern = regex.Pattern;
}
}
}
if (IsNumeric(constructorParameter.ParameterType))
{
RangeAttribute? rangeAttribute = constructorParameter.GetCustomAttribute<RangeAttribute>();
if (rangeAttribute is not null)
{
decimal? minValue = GetDecimalValue(rangeAttribute.Minimum);
decimal? maxValue = GetDecimalValue(rangeAttribute.Maximum);
if (minValue is not null)
{
modelProperty.Value.ExclusiveMinimum = rangeAttribute.MinimumIsExclusive;
modelProperty.Value.Minimum = minValue;
}
if (maxValue is not null)
{
modelProperty.Value.ExclusiveMaximum = rangeAttribute.MaximumIsExclusive;
modelProperty.Value.Maximum = maxValue;
}
}
}
}
}
}
}
private static bool IsNumeric(Type? type)
{
if (type is null)
{
return false;
}
return Type.GetTypeCode(type) switch
{
TypeCode.Byte => true,
TypeCode.SByte => true,
TypeCode.UInt16 => true,
TypeCode.UInt32 => true,
TypeCode.UInt64 => true,
TypeCode.Int16 => true,
TypeCode.Int32 => true,
TypeCode.Int64 => true,
TypeCode.Decimal => true,
TypeCode.Double => true,
TypeCode.Single => true,
//Support for int?, double?, decimal? etc.
TypeCode.Object => type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>) && IsNumeric(Nullable.GetUnderlyingType(type)),
_ => false,
};
}
private static decimal? GetDecimalValue(object? value)
{
if (value is null)
{
return null;
}
return Convert.ToDecimal(value);
}
}
It uses reflection to check if the underlying type has a single constructor and then tries to match its parameters. I have not done more work to detect records since I've read that the intent of records is to be undistinguishable from a class where someone typed out all the related boilerplate. I don't know who would put validation attributes on a regular constructor but if they do they are more likely to be happy with them exposed in the Swagger than vice versa. For the range attributes I do a check to see if the type is numeric. If you are going to use this implementation note that I only support what I needed for my API. For example I didn't deal with BigInteger properties and Range attributes on DateTime. This code also does not deal with less popular attributes like the AllowedValues and DeniedValues.
There is an
open issue on the Swashbuckle repo for supporting this but after digging in I think that the best place to fix it is in ASP.NET Core's Open API support.
Oh and you probably want to make the non-nullable properties required so here is another filter
public class RequiredPropertiesSchemaFilter : ISchemaFilter
{
public void Apply(OpenApiSchema model, SchemaFilterContext context)
{
IEnumerable<string> additionalRequiredProps = from prop in model.Properties
where !prop.Value.Nullable && !model.Required.Contains(prop.Key)
select prop.Key;
foreach (string propKey in additionalRequiredProps)
{
model.Required.Add(propKey);
}
}
}
You add Swashbuckle filters in the startup configuration like this
services.AddSwaggerGen(swaggerGenOptions =>
{
swaggerGenOptions.SupportNonNullableReferenceTypes();
swaggerGenOptions.SchemaFilter<RequiredPropertiesSchemaFilter>();
swaggerGenOptions.SchemaFilter<PositionalRecordsSchemaFilter>();
});