4

I'm using Optional<T> in my DTOs to implement Json Patch (details). The type T is stored in the 'Value' field of the Optional<T> class. The Optional class allows to differentiate between null and not provided.

public readonly struct Optional<T>
    {
        public Optional(T? value)
        {
            this.HasValue = true;
            this.Value = value;
        }

        public bool HasValue { get; }
        public T? Value { get; }
        public static implicit operator Optional<T>(T value) => new Optional<T>(value);
        public override string ToString() => this.HasValue ? (this.Value?.ToString() ?? "null") : "unspecified";
    }

The first DTOs I worked with held only primitive types, e.g.

public class PatchGroupDTO
    {
        public Optional<Guid?> SalesGroupId { get; init; }       
        public Optional<string?> Name { get; init; }
    }

The example that Swagger UI displays is wrong, because it displays the property "value":

{
  "salesGroupId": {
    "value": "3fa85f64-5717-4562-b3fc-2c963f66afa6"
  },
  "name": {
    "value": "string"
  }
}

Our solution was to map the types in Startup.cs.

services.AddSwaggerGen(c =>
  {
   c.SwaggerDoc("v1", new OpenApiInfo { Title = "xxx.API", Version = "v1" });
   c.MapType<Optional<Guid?>>(() => new OpenApiSchema { Type = "string", Format = "uuid" });
   c.MapType<Optional<string?>>(() => new OpenApiSchema { Type = "string" });
   c.MapType<Optional<bool?>>(() => new OpenApiSchema { Type = "boolean" });
   c.MapType<Optional<int?>>(() => new OpenApiSchema { Type = "integer" });
})

The example is then correctly displayed as:

{
  "salesGroupId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "name":"string"
}

In my latest DTO I needed nested objects. The simplified DTO looks like this:

public record UpdateCartDTO
{
   public Optional<AddressDTO?> InvoicingAddress { get; init; }
   public Optional<List<CommentDTO>?> Comments { get; init; }
   public Optional<List<string>?> ReservationCodes { get; init; }
};

The example will wrongly show the nested value property again:

{
  "invoicingAddress": {
    "value": {
      "type": "string",
      "receipient": "string",
      "deliveryInstructio": "string",
      "street": "string",
      "streetNumber": "string",
      "streetAffix": "string",
      "zip": "string",
      "city": "string",
      "state": "string",
      "isO3": "string"
    }
  },
"comments": {
    "value": [
      {
        "language": "string",
        "comment": "string"
      }
    ]
  },
  "reservationCodes": {
    "value": [
      "string"
    ]   
  }
}

List<string> can be mapped with:

c.MapType<Optional<List<string>?>>(
 () => new OpenApiSchema { Type = "array", Items = new OpenApiSchema { Type="string" } });

I have problems to map the nested objects. I tried:

c.MapType<Optional<AddressDTO?>>(() => new OpenApiSchema { Type = "object" });

But this only shows empty braces {}. The way I understand the api I would need to manually define all the types of the AddressDTO with Item = new OpenApiSchema{...}. Is there any solution that can just refer to the AddressDTO?

It is in general cumbersome to define a MapType for every T that is used inside Optional. Is there any way to have a generic mapping from Optional to T? Something like:

c.MapType<Optional<T?>>(() => new OpenApiSchema { Object= "T"});
M. Koch
  • 525
  • 4
  • 20

2 Answers2

1

The key is to use references to existing schemas to avoid having to redefine completely your object using OpenApiSchema.

You can use the code below to map Optional<AddressDTO?> :

s.MapType <Optional<AddressDTO?>>(() =>
    new OpenApiSchema
    {
        Type = "object",
        Reference = new OpenApiReference()
        {
            Type = ReferenceType.Schema,
            Id = nameof(AddressDTO)
        },
        Nullable = true
    });

The alternative for Optional<List<AddressDTO>?> :

s.MapType<Optional<List<AddressDTO>?>>(() =>
    new OpenApiSchema { 
        Type = "array",
        Items = new OpenApiSchema { 
            Type = "object",
            Reference = new OpenApiReference() { 
                Type = ReferenceType.Schema,
                Id = nameof(AddressDTO)
            }
        }, 
        Nullable = true 
    });

It assumes that AddressDTO has an existing schema that can be referenced. In my case, the Optional<AddressDTO?> was the only reference to this object and the schema for AddressDTO was not automatically generated.

So you can use the following DocumentFilter to generated the missing schema :

internal class AdditionalSchemasDocumentFilter : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        context.SchemaGenerator.GenerateSchema(typeof(AddressDTO), context.SchemaRepository);
    }
}

And then you call it this way in your Swagger configuration :

s.DocumentFilter<AdditionalSchemasDocumentFilter>();
0

You can try to make a DocumentFilter:

public class OptionalDocumentFilter : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        foreach (var schema in swaggerDoc.Components.Schemas)
        {
            RemoveOptionalLevelFromProperties(schema.Value, context);
        }

        RemoveOptionalSchemas(swaggerDoc);
    }

    private void RemoveOptionalLevelFromProperties(OpenApiSchema schema, DocumentFilterContext context)
    {
        if (schema.Properties == null)
        {
            return;
        }

        foreach (var property in schema.Properties.ToList())
        {
            if (property.Value.AllOf?.Count() > 0)
            {
                // swashbuckle uses allOf for references, so that custom coments can be included.
                for (int i = 0; i < property.Value.AllOf.Count; i++)
                {
                    var currentSchema = property.Value.AllOf[i];
                    if (IsReferenceToOptional(currentSchema.Reference.Id, context.SchemaRepository))
                    {
                        var optionalSchema = context.SchemaRepository.Schemas[currentSchema.Reference.Id];

                        if (!optionalSchema.Properties.TryGetValue("value", out var valueSchema))
                        {
                            throw new InvalidOperationException("Optional schema must have a value property.");
                        }

                        if (valueSchema.Reference != null)
                        {
                            // if the value of optional is a reference (i.e. a complex type), then just use it as all off.
                            property.Value.AllOf[i] = valueSchema;
                        }
                        else
                        {
                            // this is e.g. Optional<string>. We can't use AllOf here, so we must replace the whole property.
                            schema.Properties[property.Key] = valueSchema;
                        }
                    }
                }
            }
        }
    }

    private static bool IsReferenceToOptional(string referenceId, SchemaRepository schemaRepository)
    {
        var referencedSchema = schemaRepository.Schemas.First(x => x.Key == referenceId);
        return IsOptionalSchema(referencedSchema);
    }

    private static bool IsOptionalSchema(KeyValuePair<string, OpenApiSchema> referencedSchema)
    {
        return referencedSchema.Key.EndsWith("Optional") &&
            referencedSchema.Value.Properties.Count() == 2 &&
            referencedSchema.Value.Properties.Any(x => x.Key == "value") &&
            referencedSchema.Value.Properties.Any(x => x.Key == "hasValue");
    }

    private void RemoveOptionalSchemas(OpenApiDocument swaggerDoc)
    {
        swaggerDoc.Components.Schemas
            .Where(IsOptionalSchema)
            .ToList()
            .ForEach(schema => swaggerDoc.Components.Schemas.Remove(schema));
    }
}

Dont forget to register it:

builder.Services.AddSwaggerGen(options => 
{
    // ...
    options.UseAllOfToExtendReferenceSchemas();
    options.DocumentFilter<OptionalDocumentFilter>();
    // ...
});

You can also consider to mark everything else except Optional as required properties. To do that, update the code above like this:

if (property.Value.AllOf?.Count() > 0)
{
    // ...
}
else 
{
     if (!schema.Required.Contains(property.Key))
     {
         schema.Required.Add(property.Key);
     }
}
alesdvorak.cz
  • 133
  • 1
  • 10