domaindrivendev / Swashbuckle.WebApi

Seamlessly adds a swagger to WebApi projects!
BSD 3-Clause "New" or "Revised" License
3.07k stars 679 forks source link

Enums redefined in multiple places in Swagger JSON #1146

Open viggykuppu opened 7 years ago

viggykuppu commented 7 years ago

Hi, I'm running into an issue where the same enum is defined in multiple places throughout the Swagger spec.

For example, if I have models A & B that both have a field of type C (enum), then in the definitions for both A & B, they both define their field of type C inline with all of the values of C. Ideally, I'd like them both to reference a separate model for C instead of redefining the enum again. Is this possible?

heldersepu commented 7 years ago

Interesting! Can you provide a sample project that reproduces this issue?

viggykuppu commented 7 years ago

I can provide snippets of code.

We have an Enumerations.cs file where we store all our enums in a static class called Enumerations, in this we have the following enum defined: public enum ObjectType { Unknown = -1, Message = 0, Task = 1, Event = 2, Document = 3, Asset = 4, AssetVersion = 5, InventoryItem = 6, AssetVersionInventoryItem = 7, User = 8, UserGroup = 9, Storyboard = 10, Tag = 11, Project = 12, Employee = 13, Calendar = 14, Account = 15, DocumentType = 16, AccountUser = 17, AccountRole = 18 }

We then have a model defined where we reference that enumeration in two different contexts: [DataMember] public Enumerations.ObjectType? ParentObjectTypeID { get; set; } public override Enumerations.ObjectType ObjectType { get { return Enumerations.ObjectType.Message; } set { } }

The resulting swagger JSON for that model then looks like this: "ObjectTypeID":{"enum":["Unknown","Message","Task","Event","Document","Asset","AssetVersion","InventoryItem","AssetVersionInventoryItem","User","UserGroup","Storyboard","Tag","Project","Employee","Calendar","Account","DocumentType","AccountUser","AccountRole"],"type":"string"},"ParentObjectID":{"format":"int32","type":"integer"},"ParentObjectTypeID":{"enum":["Unknown","Message","Task","Event","Document","Asset","AssetVersion","InventoryItem","AssetVersionInventoryItem","User","UserGroup","Storyboard","Tag","Project","Employee","Calendar","Account","DocumentType","AccountUser","AccountRole"],"type":"string"}

heldersepu commented 7 years ago

a sample project will be a lot easier for anyone to troubleshot...

heldersepu commented 7 years ago

I think this is related to your question: https://stackoverflow.com/questions/32255384/swagger-reusing-an-enum-definition-as-query-parameter

According to the accepted answer it is not possible, that goes against the Swagger 2.0 specifications. But the next version will fix that...

viggykuppu commented 7 years ago

Thanks Helder! I'm looking at the link, but in our case, we're using the enums in the body and not in the query string like that thread specified. Is this also not supported in swagger?

I will work on getting you a sample project, but this task is being put on the back burner since it seems a bit complex and doesn't offer a ton of value yet. Thank you very much for looking into this thus far!

mina-skunk commented 6 years ago

I also need a solution to this. Here is my sample.

If you go to swagger/v1/swagger.json you get

    "definitions": {
        "ModelA": {
            "type": "object",
            "properties": {
                "option": {
                    "format": "int32",
                    "enum": [
                        0,
                        1,
                        2
                    ],
                    "type": "integer"
                }
            }
        },
        "ModelB": {
            "type": "object",
            "properties": {
                "option": {
                    "format": "int32",
                    "enum": [
                        0,
                        1,
                        2
                    ],
                    "type": "integer"
                }
            }
        }
    },

The desired result would be

    "definitions": {
        "ModelA": {
            "type": "object",
            "properties": {
                "option": {
                    "$ref": "#/definitions/MyEnum"
                }
            }
        },
        "ModelB": {
            "type": "object",
            "properties": {
                "option": {
                    "$ref": "#/definitions/MyEnum"
                }
            }
        },
        "MyEnum": {
            "format": "int32",
            "enum": [
                0,
                1,
                2
            ],
            "type": "integer"
        }
    },

I tired c.MapType<MyEnum>(() => new Schema { Ref = "#/definitions/MyEnum" }); but only got

    "definitions": {
        "ModelA": {
            "type": "object",
            "properties": {
                "option": {
                    "$ref": "#/definitions/MyEnum"
                }
            }
        },
        "ModelB": {
            "type": "object",
            "properties": {
                "option": {
                    "$ref": "#/definitions/MyEnum"
                }
            }
        }
    },
YZahringer commented 6 years ago

Here is a SchemaFilter to define Enums to definitions:

public void Apply(Schema model, SchemaFilterContext context)
{
    if (model.Properties == null)
        return;

    var enumProperties = model.Properties.Where(p => p.Value.Enum != null)
        .Union(model.Properties.Where(p => p.Value.Items?.Enum != null)).ToList();
    var enums = context.SystemType.GetProperties()
        .Select(p => Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType.GetElementType() ??
                        p.PropertyType.GetGenericArguments().FirstOrDefault() ?? p.PropertyType)
        .Where(p => p.GetTypeInfo().IsEnum)
        .ToList();

    foreach (var enumProperty in enumProperties)
    {
        var enumPropertyValue = enumProperty.Value.Enum != null ? enumProperty.Value : enumProperty.Value.Items;

        var enumValues = enumPropertyValue.Enum.Select(e => $"{e}").ToList();
        var enumType = enums.SingleOrDefault(p =>
        {
            var enumNames = Enum.GetNames(p);
            if (enumNames.Except(enumValues, StringComparer.InvariantCultureIgnoreCase).Any())
                return false;
            if (enumValues.Except(enumNames, StringComparer.InvariantCultureIgnoreCase).Any())
                return false;
            return true;
        });

        if (enumType == null)
            throw new Exception($"Property {enumProperty} not found in {context.SystemType.Name} Type.");

        if (context.SchemaRegistry.Definitions.ContainsKey(enumType.Name) == false)
            context.SchemaRegistry.Definitions.Add(enumType.Name, enumPropertyValue);

        var schema = new Schema
        {
            Ref = $"#/definitions/{enumType.Name}"
        };
        if (enumProperty.Value.Enum != null)
        {
            model.Properties[enumProperty.Key] = schema;
        }
        else if (enumProperty.Value.Items?.Enum != null)
        {
            enumProperty.Value.Items = schema;
        }
    }
}
heldersepu commented 6 years ago

@YZahringer Sorry but that SchemaFilter does not follow the ISchemaFilter implementation: https://github.com/domaindrivendev/Swashbuckle/blob/master/Swashbuckle.Core/Swagger/ISchemaFilter.cs

    public interface ISchemaFilter
    {
        void Apply(Schema schema, SchemaRegistry schemaRegistry, Type type);
    }
YZahringer commented 6 years ago

@heldersepu sorry, I use AspNetCore version: https://github.com/domaindrivendev/Swashbuckle.AspNetCore/blob/master/src/Swashbuckle.AspNetCore.SwaggerGen/Generator/ISchemaFilter.cs but it also works with few adaptations.

heldersepu commented 6 years ago

I tried, but I think it will be more than a "few adaptations" more like a total re-write

mina-skunk commented 6 years ago

@YZahringer I can't seem to get your EnumDefinitionsSchemaFilter to work for lists/arrays of enums. example I get:

"PropName": {
    "$ref": "#/definitions/MyEnum"
}

Where I expect:

"PropName": {
    "type": "array",
    "items": {
        "$ref": "#/definitions/MyEnum"
    }
}
YZahringer commented 6 years ago

@gatimus I fixed in updated code above

Sbaia commented 6 years ago

Hi, @YZahringer good code!! I only add a .Distinct() method in enum Linq Select, otherwise SingleOrDefault throw an Exception, if object type contains more than one property of same enum type. @heldersepu I've adapted the code for SwashBuckle so:


public void Apply(Schema model, SchemaRegistry schemaRegistry, Type type)
        {
            if (model.properties == null)
                return;

            var enumProperties = model.properties.Where(p => p.Value.@enum != null)
                .Union(model.properties.Where(p => p.Value.items?.@enum != null)).ToList();
            var enums = type.GetProperties()
                .Select(p => Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType.GetElementType() ??
                                p.PropertyType.GetGenericArguments().FirstOrDefault() ?? p.PropertyType)
                .Where(p => p.IsEnum)
                .Distinct()
                .ToList();

            foreach (var enumProperty in enumProperties)
            {
                var enumPropertyValue = enumProperty.Value.@enum != null ? enumProperty.Value : enumProperty.Value.items;

                var enumValues = enumPropertyValue.@enum.Select(e => $"{e}").ToList();
                var enumType = enums.SingleOrDefault(p =>
                {
                    var enumNames = Enum.GetNames(p);
                    if (enumNames.Except(enumValues, StringComparer.InvariantCultureIgnoreCase).Any())
                        return false;
                    if (enumValues.Except(enumNames, StringComparer.InvariantCultureIgnoreCase).Any())
                        return false;
                    return true;
                });

                if (enumType == null)
                    throw new Exception($"Property {enumProperty} not found in {type.Name} Type.");

                if (schemaRegistry.Definitions.ContainsKey(enumType.Name) == false)
                    schemaRegistry.Definitions.Add(enumType.Name, enumPropertyValue);

                var schema = new Schema
                {
                   @ref = $"#/definitions/{enumType.Name}"
                };
                if (enumProperty.Value.@enum != null)
                {
                    model.properties[enumProperty.Key] = schema;
                }
                else if (enumProperty.Value.items?.@enum != null)
                {
                    enumProperty.Value.items = schema;
                }
            }
        }
michauzo commented 6 years ago

Thank you to all working on the code above. It works great! However, when I used it I spotted on more issue with enums. If an enum is used as a parameter type to a controller method then the enum will be duplicated. When I use NSwag for client code generation I get duplicated enums. I know that it's a problem of OpenApi v2 specification, however the specification allows for extensions and the NSwag makes use of x-schema extension property in parameters. That means we can help NSwag in handling enum parameters by pointing them to enum definitions by $ref. The solution to add x-schema extension is applied in similar way to the one above. We just need to apply an OperationFilter in here. The class needs to implement IOperationFilter. And the code is as follows:

public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription)
{
    if (operation.parameters == null)
        return;

    for (int i = 0; i < operation.parameters.Count; ++i)
    {
        var parameter = operation.parameters[i];
        if (parameter.@enum == null)
            continue;

        var enumType = apiDescription.ParameterDescriptions[i].ParameterDescriptor.ParameterType;

        if (schemaRegistry.Definitions.ContainsKey(enumType.Name) == false)
            schemaRegistry.Definitions.Add(enumType.Name, schemaRegistry.GetOrRegister(enumType));

        var schema = new Schema
        {
            @ref = $"#/definitions/{enumType.Name}"
        };
        parameter.vendorExtensions.Add("x-schema", schema);
    }
}
radziksh commented 5 years ago

@Sbaia thank you very much, your code works for me and saved me a lot of time!

smstuebe commented 5 years ago

Hi I adopted the version from @Sbaia and @YZahringer a bit. The improvement is, that schema properties like required, description etc. stay part of the property instead of getting moved to the enum spec.

public void Apply(Schema model, SchemaRegistry schemaRegistry, Type type)
{
    if (model.properties == null)
        return;

    var enumProperties = model.properties.Where(p => p.Value.@enum != null)
        .Union(model.properties.Where(p => p.Value.items?.@enum != null)).ToList();
    var enums = type.GetProperties()
        .Select(p => Nullable.GetUnderlyingType(p.PropertyType) ?? p.PropertyType.GetElementType() ??
                        p.PropertyType.GetGenericArguments().FirstOrDefault() ?? p.PropertyType)
        .Where(p => p.IsEnum)
        .Distinct()
        .ToList();

    foreach (var enumProperty in enumProperties)
    {
        var enumPropertyValue = enumProperty.Value.@enum != null ? enumProperty.Value : enumProperty.Value.items;

        var enumValues = enumPropertyValue.@enum.Select(e => $"{e}").ToList();
        var enumType = enums.SingleOrDefault(p =>
        {
            var enumNames = Enum.GetNames(p);
            if (enumNames.Except(enumValues, StringComparer.InvariantCultureIgnoreCase).Any())
                return false;
            if (enumValues.Except(enumNames, StringComparer.InvariantCultureIgnoreCase).Any())
                return false;
            return true;
        });

        if (enumType == null)
            throw new Exception($"Property {enumProperty} not found in {type.Name} Type.");

        var enumSchema = new Schema
        {
            @enum = enumPropertyValue.@enum,
            type = enumPropertyValue.type
        };
        var @ref = $"#/definitions/{enumType.Name}";

        if (schemaRegistry.Definitions.ContainsKey(enumType.Name) == false)
            schemaRegistry.Definitions.Add(enumType.Name, enumSchema);

        if (enumProperty.Value.@enum != null)
        {
            model.properties[enumProperty.Key].@enum = null;
            model.properties[enumProperty.Key].type = null;
            model.properties[enumProperty.Key].@ref = @ref;
        }
        else if (enumProperty.Value.items?.@enum != null)
        {
            enumProperty.Value.items.@enum = null;
            enumProperty.Value.items.type = null;
            enumProperty.Value.items.@ref = @ref;
        }
    }
}
serega9i commented 5 years ago

Nice. But not with additional properties. For example, type: IDictionary <string, EnumType>. This type will be converted to additionalProperties.enum or additionalProperties.items.enum.

simonbq commented 5 years ago

My team and I ended up using c.SchemaRegistryOptions.UseReferencedDefinitionsForEnums = true; which worked brilliantly in our case. We then combined that option with adding all our enum names that we wanted shared references for to the excludedTypeNamesoption in NSwag, which we use to generate C# clients. We also adjusted additionalNamespaceUsages and in the clients we added references to the DLLs containing our shared enums. Haven't seen anyone else on the internet post about this game-changing option so I thought I'd drop it in here.

xJonathanLEI commented 5 years ago

My team and I ended up using c.SchemaRegistryOptions.UseReferencedDefinitionsForEnums = true; which worked brilliantly in our case.

Thanks! This flag works perfectly.

In case someone is also looking for this, it's called Resuable Enums in OpenAPI 3.0

ferreirix commented 5 years ago

Thank you to all working on the code above. It works great! However, when I used it I spotted on more issue with enums. If an enum is used as a parameter type to a controller method then the enum will be duplicated. When I use NSwag for client code generation I get duplicated enums. I know that it's a problem of OpenApi v2 specification, however the specification allows for extensions and the NSwag makes use of x-schema extension property in parameters. That means we can help NSwag in handling enum parameters by pointing them to enum definitions by $ref. The solution to add x-schema extension is applied in similar way to the one above. We just need to apply an OperationFilter in here. The class needs to implement IOperationFilter. And the code is as follows:

public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription)
{
    if (operation.parameters == null)
        return;

    for (int i = 0; i < operation.parameters.Count; ++i)
    {
        var parameter = operation.parameters[i];
        if (parameter.@enum == null)
            continue;

        var enumType = apiDescription.ParameterDescriptions[i].ParameterDescriptor.ParameterType;

        if (schemaRegistry.Definitions.ContainsKey(enumType.Name) == false)
            schemaRegistry.Definitions.Add(enumType.Name, schemaRegistry.GetOrRegister(enumType));

        var schema = new Schema
        {
            @ref = $"#/definitions/{enumType.Name}"
        };
        parameter.vendorExtensions.Add("x-schema", schema);
    }
}

Improved a bit upon, taking into consideration nullable enums types.

Here is my version for asp.net core :

public void Apply(Operation operation, OperationFilterContext context)
{
    if (operation.Parameters == null)
        return;

    for (int i = 0; i < operation.Parameters.Count; ++i)
    {
        //Body parameters are handled by options.SchemaRegistryOptions.UseReferencedDefinitionsForEnums = true;
        var parameter = operation.Parameters[i] as NonBodyParameter;

        if (parameter?.Enum == null)
            continue;

        var enumType = context.ApiDescription.ParameterDescriptions[i].ParameterDescriptor.ParameterType;

        if (enumType.IsGenericType && enumType.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            enumType = enumType.GetGenericArguments()[0];
        }

        if (!context.SchemaRegistry.Definitions.ContainsKey(enumType.Name))
            context.SchemaRegistry.Definitions.Add(enumType.Name, context.SchemaRegistry.GetOrRegister(enumType));

        var schema = new Schema
        {
            Ref = $"#/definitions/{enumType.Name}"
        };

        parameter.Extensions.Add("x-schema", schema);
    }
}
pontusdacke commented 5 years ago

I modified the code from @michauzo for a version that removes duplicates, and keeps the correct name and value of the enumeration

public void Apply(Operation operation, SchemaRegistry schemaRegistry, ApiDescription apiDescription)
{
    if (operation.parameters == null)
    {
        return;
    }

    for (int i = 0; i < operation.parameters.Count; ++i)
    {
        var parameter = operation.parameters[i];

        if (parameter.@enum == null)
        {
            continue;
        }

        var enumType = apiDescription.ParameterDescriptions[i].ParameterDescriptor.ParameterType;

        if (!schemaRegistry.Definitions.ContainsKey(enumType.Name))
        {
            schemaRegistry.Definitions.Add(enumType.Name, schemaRegistry.GetOrRegister(enumType));
        }

        if (schemaRegistry.Definitions.TryGetValue(enumType.Name, out var enumSchema))
        {
            if (enumSchema.vendorExtensions == null)
            {
                enumSchema.vendorExtensions = new Dictionary<string, object>();
            }

            if (!enumSchema.vendorExtensions.TryGetValue("x-enumNames", out _))
            {
                enumSchema.vendorExtensions.Add("x-enumNames", enumSchema.@enum.Select(x => x.ToString()).ToArray());
            }
        }

        var schema = new Schema
        {
            @ref = $"#/definitions/{enumType.Name}"
        };

        parameter.vendorExtensions.Add("x-schema", schema);
    }
}