19

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.

Roman Marusyk
  • 23,328
  • 24
  • 73
  • 116
crimbo
  • 10,308
  • 8
  • 51
  • 55

6 Answers6

14

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

}
Daniel Gimenez
  • 18,530
  • 3
  • 50
  • 70
  • Thank you for updating - my question and answer is a few years old, but it definitely is preferable to use the new language features. – crimbo Sep 02 '21 at 21:10
  • this isn't working for me. specifically i mark a field nullable with "?" and it is still added to the required list. i would expect it not to be added to the required list. – Mattias Martens Sep 07 '22 at 15:54
  • Update to my comment: i had turned off UseAllOfToExtendReferenceSchemas() because it was triggering a bug with OpenAPI generator. It turns out this cannot be omitted. but it also turns out the bug is specifically with OpenApi-typescript-fetch and not OpenApi-typescript. OpenApi-typescript has a bug with UseAllOfForInheritance() and it seems this one *can* be omitted without loss of correctness, at least in my case. – Mattias Martens Sep 09 '22 at 14:10
  • 1
    This was perfect! This means I don't have to plaster all properties in my solution with `[Required]` – KristianB Jan 17 '23 at 08:39
12

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.

crimbo
  • 10,308
  • 8
  • 51
  • 55
8

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.

Steve Pick
  • 186
  • 1
  • 5
3

I struggled with a similar problem for several days before I realized two important things.

  1. A property's nullability and its requiredness are completeley orthogonal concepts and should not be conflated.
  2. As great as C#'s new nullable feature is at helping you avoid null reference exceptions, it's still a compile-time feature. As far as the CLR is concerned, and therefore as far as the reflection API is concerned, all strings (indeed all reference types) are always nullable. Period.

The second point really caused problems for any schema filter I wrote because, regardless of whether I typed something as string or string?, the context parameter of the Apply function always had it's MemberInfo.Nullable property set to true.

So I came up with the following solution.

First, create Nullable attribute.

using System;

[AttributeUsage(AttributeTargets.Property)]
public class NullableAttribute : Attribute {

    public NullableAttribute(bool Property = true, bool Items = false) {
        this.Property = Property;
        this.Items = Items;
    }

    public bool Property { get; init; }
    public bool Items { get; init; }

}

Next, create NullableSchemaFilter.

using MicroSearch.G4Data.Models;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;

public class NullableSchemaFilter : ISchemaFilter {

    public void Apply(OpenApiSchema schema, SchemaFilterContext context) {
        var attrs = context.MemberInfo?.GetInlineAndMetadataAttributes();
        if (attrs != null) {
            foreach (var attr in attrs) {
                var nullableAttr = attr as NullableAttribute;
                if (nullableAttr != null) {
                    schema.Nullable = nullableAttr.Property;
                    if (schema.Items != null)
                        schema.Items.Nullable = nullableAttr.Items;
                }
            }
        }
    }

}

And, of course, you have to add the schema filter in your startup code.

services.AddSwaggerGen(config => {
    config.SchemaFilter<NullableSchemaFilter>();
});

The Nullable attribute takes two optional boolean parameters:

  1. Property controls if the property itself is nullable.
  2. Items controls if items in an array are nullable. Obviously, this only applies to properties that are arrays.

Examples:

// these all express a nullable string
string? Name { get; set; }
[Nullable] string? Name { get; set; }
[Nullable(true)] string? Name { get; set; }
[Nullable(Property: true)] string? Name { get; set; }

// non-nullable string
[Nullable(false)] string Name { get; set; }
[Nullable(Property: false)] string Name { get; set; }

// non-nullable array of non-nullable strings
[Nullable(false)] string[] Names { get; set; }
[Nullable(Property: false, Items: false) Names { get; set; }

// nullable array of non-nullable strings
[Nullable(Property: true, Items: false)] string[]? Names { get; set; }

// non-nullable array of nullable strings
[Nullable(Property: false, Items: true)] string?[] Names { get; set; }

// nullable array of nullable strings
[Nullable(Property: true, Items: true)] string?[]? Names { get; set; }

The [Required] attribute can be freely used together with the [Nullable] attribute when necessary. i.e. this does what you would expect.

[Nullable][Required] string? Name { get; set; }

I am using .NET 5 and Swashbuckle.AspNetCore 6.2.3.

Ferruccio
  • 98,941
  • 38
  • 226
  • 299
2

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);
        }
    }
}
StuS
  • 817
  • 9
  • 14
1

Or you can try this one

public class AssignPropertyRequiredFilter : ISchemaFilter {

    public void Apply(Schema schema, SchemaRegistry schemaRegistry, Type type) {

        var requiredProperties = type.GetProperties()
            .Where(x => x.PropertyType.IsValueType)
            .Select(t => char.ToLowerInvariant(t.Name[0]) + t.Name.Substring(1));

        if (schema.required == null) {
            schema.required = new List<string>();
        }
        schema.required = schema.required.Union(requiredProperties).ToList();
    }
}

and use

services.AddSwaggerGen(c =>
{   
    ...
    c.SchemaFilter<AssignPropertyRequiredFilter>();
});
Roman Marusyk
  • 23,328
  • 24
  • 73
  • 116
  • This is fine if your requirements are simple, but it misses Nullable properties, and adds ignored properties to the schema. – crimbo May 20 '19 at 15:52
  • +1 as this is the only answer using `SchemaRegistry`. Is the variant with SchemaFilterContext something that only works wit ASP.Core? I am still looking for a gooed solution for not .Core. – comecme Jul 11 '22 at 11:58