Search code examples
c#asp.net-web-apiasp.net-coreswaggerswashbuckle

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.


Solution

  • 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.