21

I recently upgraded my API to a .net core 3.1 server using Swashbuckle 5 with the newtonsoft json nuget, which produces an openapi 3 schema. I then use NSwag to generate a C# API. Previously I had a .net core 2.2 server with swashbuckle 4, producing a swagger 2.0 api schema.

I have a generic response class for all responses, containing some metadata about the response like status code and a message, plus a Payload property of Generic type T containing the meat of the response.

When the response is an error code, I set the payload property to null. I am struggling to find a way to define my api so that swashbuckle and NSwag combined produce a C# api that will allow the payload property to be null on deserialization. (swagger 2.0 / swashbuckle 4 worked without issue).

Try as I might, the Payload property always gets the annotation [Newtonsoft.Json.JsonProperty("payload", Required = Newtonsoft.Json.Required.DisallowNull...] and the [System.ComponentModel.DataAnnotations.Required] annotation.

As I understand it, open API 3 now allows "$ref" properties to have the "nullable": true attribute in the schema definition. If I add this manually to my definition after it is created, NSwag correctly removes the Required attribute in the CSharp api and crucially sets the JsonProperty Required attribute to be "Default" (not required) instead of "DisallowNull".
However, nothing that I mark up the payload property with causes the nullable: true to appear in my schema json definition.

What I want is this:

"properties": {
          "payload": {
            "nullable": true, 
            "$ref": "#/components/schemas/VisualService.Client.Models.MyResultClass"
          },

What I get is this:

"properties": {
          "payload": {
            "$ref": "#/components/schemas/VisualService.Client.Models.MyResultClass"
          },

What would also work is setting the "nullable"=true on the definition of the referenced $ref object itself. I can't find a way to do this either.

I have tried the following remedies, to no success.

  1. Marking up the property in the dto class with JsonProperty in different ways:

    [JsonProperty(Required = Required.AllowNull)]
    public T Payload { get; set; }
    
    [AllowNull]
    public T Payload { get; set; }
    
    [MaybeNull]
    public T Payload { get; set; }
    
  2. Trying to tell Swashbuckle / Newtonsoft to use my custom Json Resolver as described in this github issue- doesn't seem to obey

    services.AddControllers()
                        .AddNewtonsoftJson(options =>
                        {                        options.SerializerSettings.ContractResolver = MyCustomResolver();

  1. I created my own custom attribute and filter to try to set the property as nullable

    [NullableGenericProperty]
    public T Payload { get; set; }
    
   [AttributeUsage(AttributeTargets.Property)]
    public class NullableGenericPropertyAttribute : Attribute
    {

    }

    public class SwaggerNullablePayloadFilter : ISchemaFilter
    {
        public void Apply(OpenApiSchema schema, SchemaFilterContext context)
        {
            if (schema?.Properties == null || context?.Type == null)
                return;

            var nullableGenericProperties = context.Type.GetProperties()
                .Where(t =>
                    t.GetCustomAttribute<NullableGenericPropertyAttribute>()
                    != null);

            foreach (var excludedProperty in nullableGenericProperties)
            {
                if (schema.Properties.ContainsKey(excludedProperty.Name.ToLowerInvariant()))
                {
                    var prop = schema.Properties[excludedProperty.Name.ToLowerInvariant()];

                    prop.Nullable = true;
                    prop.Required = new HashSet<string>() { "false" };
                }
            }
        }
    }

I had minor success with this one, in that adding the prop.Nullable = true; caused the attribute[System.ComponentModel.DataAnnotations.Required] to be removed from the c# api. However, the [Newtonsoft.Json.JsonProperty("payload", Required = Newtonsoft.Json.Required.DisallowNull...] still remained, so it didn't help that much. I added prop.Required = new HashSet<string>() { "false" }; as an additional try, but it doesn't seem to do anything.

I could downgrade to .net core 2.2 / swashbuckle 4 again but 2.2 is out of long term support and I want to stay at 3.1 if at all possible. I could also do a find and replace on my generated API client every time but I don't want to have to manually remember to do it every time I regenerate the api which can be several times a day in development cycles.

I've got a hacky workaround - which is that I'm intercepting the json response and adding the "nullable" = true on my server where it's needed, by using a regex match on the response Body json string, before serving it to the client. It's really hacky though and I'd like a native way to do this if it exists.

Any and all help appreciated!

Adam Diament
  • 4,290
  • 3
  • 34
  • 55
  • This might also help: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/issues/2036#issuecomment-894015122 – Royston46 Jan 21 '23 at 13:45

2 Answers2

37

There is a setting that accomplishes this:

UseAllOfToExtendReferenceSchemas 

It changes the schema to this, which nswag can use to allow nulls for $ref properties.

  "payload": {
    "required": [
      "false"
    ],
    "allOf": [
      {
        "$ref": "#/components/schemas/MyResultClass"
      }
    ],
    "nullable": true
  },

Use it like this:

        _ = services.AddSwaggerGen(setup =>
        {
            setup.SwaggerDoc("v1", new OpenApiInfo { Title = AppConst.SwaggerTitle, Version = "v1" });

            setup.UseAllOfToExtendReferenceSchemas();
            ...
Adam Diament
  • 4,290
  • 3
  • 34
  • 55
  • 1
    Kudos for posting this Q&A after resolving your GitHub issue! – schil227 Oct 09 '20 at 20:27
  • 8
    This did help, but I wish: 1. That I cold understand what this "allOf" business means. 2, That this behaviour of not requiring fields would be the default. 3. That I could specify this behaviour on the individual properties with a simple attribute like [AllowNull(bool)] – Greg Z. Apr 21 '21 at 16:11
0

I just created this piece of code that modifies my swagger autogenerated file depending if the property on my code is nullable or not.

var file = "/some/path/to/swagger.json"
var myAssembly = typeof(MyModel).Assembly; // I have all my models on the same assembly. Place any of your models in there in case all of your models are in the same assembly

var content = File.ReadAllText(file);

// make properties not nullable if they are not
JObject jObject = (JObject)Newtonsoft.Json.JsonConvert.DeserializeObject(content)!;
// Select a nested property using a single string:
JToken? jToken = jObject.SelectToken("components.schemas");
if (jToken is null) throw new Exception();

var typesOfInterest = myAssembly.GetTypes();
foreach (var t in typesOfInterest)
{
    if(t.IsEnum) continue;
    if(t.IsInterface) continue;

    var path = $"components.schemas.{t.Name}";
    var jModel = jObject.SelectToken(path);
    if (jModel is null) continue;

    Console.WriteLine($"Updating nullable properties on swagger for type {t.Name}");

    foreach (var prop in t.GetProperties())
    {
        var propPath = path + ".properties." + prop.Name.FirstCharToLowerCase();
        var jProp = jObject.SelectToken(propPath);
        if (jProp is null) continue;

        // determine if property is nullable
        bool isNullable = prop.IsNullable();

        var reference = jProp.SelectToken("$ref");
        if(reference != null)
        {
            Console.WriteLine($"Ignoring path '{propPath}' because its a reference. We will do that separately");
            continue;
        }

        JValue? jNullableToken = jProp.SelectToken("nullable") as JValue;

        if (isNullable)
        {
            if(jNullableToken is null)
            {
                if (Debugger.IsAttached) Debugger.Break();
                Console.WriteLine("Missing to make property nullable");
            }   
            else if(jNullableToken.Value is bool b && b == true)
            {
                // value is already true no need to change
            }
            else
            {
                // this never happens for some reason
                if (Debugger.IsAttached) Debugger.Break();

                jNullableToken.Replace(true);
            }
        }
        else
        {
            if (jNullableToken is null)
            {
                // if property does not exist by default it is NOT nullable so it is ok
            }
            else if (jNullableToken.Value is bool b && b == false)
            {
                // value is already false so no need to change
            }
            else
            {
                jNullableToken.Replace(false);
            }
        }
    }
}

string updatedJsonString = jObject.ToString();

// replace swagger.json file
File.WriteAllText(file, updatedJsonString);

And after that this are how my git changes look like:

enter image description here

Tono Nam
  • 34,064
  • 78
  • 298
  • 470