Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swashbuckle: Make non-nullable properties required

Using Swashbuckle.AspNetCore in an ASP.NET Core webapp, we have response types like:

public class DateRange
{
    [JsonConverter(typeof(IsoDateConverter))]
    public DateTime StartDate {get; set;}

    [JsonConverter(typeof(IsoDateConverter))]
    public DateTime EndDate {get; set;}
}

When using Swashbuckle to emit the swagger API JSON, this becomes:

{ ...

  "DateRange": {
    "type": "object",
    "properties": {
      "startDate": {
        "format": "date-time",
        "type": "string"
      },
      "endDate": {
        "format": "date-time",
        "type": "string"
      }
    }
  }
...
}

The problem here is that DateTime is a value type, and can never be null; but the emitted Swagger API JSON doesn't tag the 2 properties as required. This behavior is the same for all other value types: int, long, byte, etc - they're all considered optional.

To complete the picture, we're feeding our Swagger API JSON to dtsgenerator to generate typescript interfaces for the JSON response schema. e.g. the class above becomes:

export interface DateRange {
    startDate?: string; // date-time
    endDate?: string; // date-time
}

Which is clearly incorrect. After digging into this a little bit, I've concluded that dtsgenerator is doing the right thing in making non-required properties nullable in typescript. Perhaps the swagger spec needs explicit support for nullable vs required, but for now the 2 are conflated.

I'm aware that I can add a [Required] attribute to every value-type property, but this spans multiple projects and hundreds of classes, is redundant information, and would have to be maintained. All non-nullable value type properties cannot be null, so it seems incorrect to represent them as optional.

Web API, Entity Framework, and Json.net all understand that value type properties cannot be null; so a [Required] attribute is not necessary when using these libraries.

I'm looking for a way to automatically mark all non-nullable value types as required in my swagger JSON to match this behavior.

like image 463
crimbo Avatar asked Oct 05 '17 00:10

crimbo


4 Answers

I found a solution for this: I was able to implement a Swashbuckle ISchemaFilter that does the trick. Implementation is:

/// <summary>
/// Makes all value-type properties "Required" in the schema docs, which is appropriate since they cannot be null.
/// </summary>
/// <remarks>
/// This saves effort + maintenance from having to add <c>[Required]</c> to all value type properties; Web API, EF, and Json.net already understand
/// that value type properties cannot be null.
/// 
/// More background on the problem solved by this type: https://stackoverflow.com/questions/46576234/swashbuckle-make-non-nullable-properties-required </remarks>
public sealed class RequireValueTypePropertiesSchemaFilter : ISchemaFilter
{
    private readonly CamelCasePropertyNamesContractResolver _camelCaseContractResolver;

    /// <summary>
    /// Initializes a new <see cref="RequireValueTypePropertiesSchemaFilter"/>.
    /// </summary>
    /// <param name="camelCasePropertyNames">If <c>true</c>, property names are expected to be camel-cased in the JSON schema.</param>
    /// <remarks>
    /// I couldn't figure out a way to determine if the swagger generator is using <see cref="CamelCaseNamingStrategy"/> or not;
    /// so <paramref name="camelCasePropertyNames"/> needs to be passed in since it can't be determined.
    /// </remarks>
    public RequireValueTypePropertiesSchemaFilter(bool camelCasePropertyNames)
    {
        _camelCaseContractResolver = camelCasePropertyNames ? new CamelCasePropertyNamesContractResolver() : null;
    }

    /// <summary>
    /// Returns the JSON property name for <paramref name="property"/>.
    /// </summary>
    /// <param name="property"></param>
    /// <returns></returns>
    private string PropertyName(PropertyInfo property)
    {
        return _camelCaseContractResolver?.GetResolvedPropertyName(property.Name) ?? property.Name;
    }

    /// <summary>
    /// Adds non-nullable value type properties in a <see cref="Type"/> to the set of required properties for that type.
    /// </summary>
    /// <param name="model"></param>
    /// <param name="context"></param>
    public void Apply(Schema model, SchemaFilterContext context)
    {
        foreach (var property in context.SystemType.GetProperties())
        {
            string schemaPropertyName = PropertyName(property);
            // This check ensures that properties that are not in the schema are not added as required.
            // This includes properties marked with [IgnoreDataMember] or [JsonIgnore] (should not be present in schema or required).
            if (model.Properties?.ContainsKey(schemaPropertyName) == true)
            {
                // Value type properties are required,
                // except: Properties of type Nullable<T> are not required.
                var propertyType = property.PropertyType;
                if (propertyType.IsValueType
                    && ! (propertyType.IsConstructedGenericType && (propertyType.GetGenericTypeDefinition() == typeof(Nullable<>))))
                {
                    // Properties marked with [Required] are already required (don't require it again).
                    if (! property.CustomAttributes.Any(attr =>
                                                        {
                                                            var t = attr.AttributeType;
                                                            return t == typeof(RequiredAttribute);
                                                        }))
                    {
                        // Make the value type property required
                        if (model.Required == null)
                        {
                            model.Required = new List<string>();
                        }
                        model.Required.Add(schemaPropertyName);
                    }
                }
            }
        }
    }
}

To use, register it in your Startup class:

services.AddSwaggerGen(c =>
                        {
                            c.SwaggerDoc(c_swaggerDocumentName, new Info { Title = "Upfront API", Version = "1.0" });

                            c.SchemaFilter<RequireValueTypePropertiesSchemaFilter>(/*camelCasePropertyNames:*/ true);
                        });

This results in the DateRange type above becoming:

{ ...
  "DateRange": {
    "required": [
      "startDate",
      "endDate"
    ],
    "type": "object",
    "properties": {
      "startDate": {
        "format": "date-time",
        "type": "string"
      },
      "endDate": {
        "format": "date-time",
        "type": "string"
      }
    }
  },
  ...
}

In the swagger JSON schema, and:

export interface DateRange {
    startDate: string; // date-time
    endDate: string; // date-time
}

in the dtsgenerator output. I hope this helps someone else.

like image 113
crimbo Avatar answered Oct 23 '22 09:10

crimbo


I was able to achieve the same effect as the accepted answer using the following schema filter and Swashbuckle 5.4.1:

public class RequireValueTypePropertiesSchemaFilter : ISchemaFilter
{
    private readonly HashSet<OpenApiSchema> _valueTypes = new HashSet<OpenApiSchema>();

    public void Apply(OpenApiSchema model, SchemaFilterContext context)
    {
        if (context.Type.IsValueType)
        {
            _valueTypes.Add(model);
        }

        if (model.Properties != null)
        {
            foreach (var prop in model.Properties)
            {
                if (_valueTypes.Contains(prop.Value))
                {
                    model.Required.Add(prop.Key);
                }
            }
        }
    }
}

This relies on the fact that the ISchemaFilter must be applied to the simple schemas of each property before it can be applied to the complex schema that contains those properties - so all we have to do is keep track of the simple schemas that relate to a ValueType, and if we later encounter a schema that has one of those ValueType schemas as a property, we can mark that property name as required.

like image 8
Steve Pick Avatar answered Oct 23 '22 09:10

Steve Pick


If you're using C# 8.0+ and have Nullable Reference Types enabled, then the answer can be even easier. Assuming it is an acceptable division that all non-nullable types are required, and all other types that are explicitly defined as nullable are not then the following schema filter will work.

public class RequireNonNullablePropertiesSchemaFilter : ISchemaFilter
{
    /// <summary>
    /// Add to model.Required all properties where Nullable is false.
    /// </summary>
    public void Apply(OpenApiSchema model, SchemaFilterContext context)
    {
        var additionalRequiredProps = model.Properties
            .Where(x => !x.Value.Nullable && !model.Required.Contains(x.Key))
            .Select(x => x.Key);
        foreach (var propKey in additionalRequiredProps)
        {
            model.Required.Add(propKey);
        }
    }
}

The Apply method will loop through each model property checking to see if Nullable is false and adding them to the list of required objects. From observation it appears that Swashbuckle does a fine job of setting the Nullable property based on if it a nullable type. If you don't trust it, you could always use Reflection to produce the same affect.

As with other schema filters don't forget to add this one in your Startup class as well as the appropriate Swashbuckle extensions to handle nullable objects.

services.AddSwaggerGen(c =>
{
    /*...*/
    c.SchemaFilter<RequireNonNullablePropertiesSchemaFilter>();
    c.SupportNonNullableReferenceTypes(); // Sets Nullable flags appropriately.              
    c.UseAllOfToExtendReferenceSchemas(); // Allows $ref enums to be nullable
    c.UseAllOfForInheritance();  // Allows $ref objects to be nullable

}
like image 9
Daniel Gimenez Avatar answered Oct 23 '22 11:10

Daniel Gimenez


Let me suggest solution based on json schema. This scheme was described in RFC, so it should works like common solution https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.1.1

public class AssignPropertyRequiredFilter : ISchemaFilter
{
    public void Apply(Schema schema, SchemaFilterContext context)
    {
        if (schema.Properties == null || schema.Properties.Count == 0)
        {
            return;
        }

        var typeProperties = context.SystemType.GetProperties(BindingFlags.Public | BindingFlags.Instance);
        foreach (var property in schema.Properties)
        {
            if (IsSourceTypePropertyNullable(typeProperties, property.Key))
            {
                continue;
            }

            // "null", "boolean", "object", "array", "number", or "string"), or "integer" which matches any number with a zero fractional part.
            // see also: https://json-schema.org/latest/json-schema-validation.html#rfc.section.6.1.1
            switch (property.Value.Type)
            {
                case "boolean":
                case "integer":
                case "number":
                    AddPropertyToRequired(schema, property.Key);
                    break;
                case "string":
                    switch (property.Value.Format)
                    {
                        case "date-time":
                        case "uuid":
                            AddPropertyToRequired(schema, property.Key);
                            break;
                    }
                    break;
            }
        }
    }

    private bool IsNullable(Type type)
    {
        return Nullable.GetUnderlyingType(type) != null;
    }

    private bool IsSourceTypePropertyNullable(PropertyInfo[] typeProperties, string propertyName)
    { 
        return typeProperties.Any(info => info.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)
                                        && IsNullable(info.PropertyType));
    }

    private void AddPropertyToRequired(Schema schema, string propertyName)
    {
        if (schema.Required == null)
        {
            schema.Required = new List<string>();
        }

        if (!schema.Required.Contains(propertyName))
        {
            schema.Required.Add(propertyName);
        }
    }
}
like image 2
StuS Avatar answered Oct 23 '22 11:10

StuS