Enumerados en Swagger con Minimal APIs

En este post veremos como ajustar la pareja Swagger/Minimal API para hacer uso de enumerados y que, tanto el fichero de especificación como la UI de Swagger les hagan honor.

En mi caso, todo parte de querer usar la constante del enumerado en vez de su valor primitivo. Es decir, mejor /users?type=Employee que /users?type=1. Esto aplica tanto en la entrada como en la salida.

Para nuestro ejemplo estamos usando .NET 7, Minimal APIs (sin controladores) y Swashbuckle.

Además de Swashbuckle (que viene de serie en la plantilla de dotnet) tenemos a NSwag como alternativa.

La API inicial es esta:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

app.MapGet("/users", (Status status) =>
{
    var roles = Enum.GetValues(typeof(Role));
    var random = new Random();
    return Enumerable.Range(1, 2).Select(index =>
        new User
        {
            Id = index,
            Status = status,
            Role = (Role)roles.GetValue(random.Next(roles.Length))! 
        });
});

app.MapGet("/companies", (CompanySize companySize) =>
{
    return new[] { 1, 2 };
});

app.UseSwagger();
app.UseSwaggerUI();

app.Run();

public class User
{
    public int Id { get; set; }
    public Status Status { get; set; }
    public Role Role { get; set; }
}

public enum Role
{
    Admin,
    Moderator,
    Guest
}

public enum Status
{
    Active,
    Inactive,
    Suspended
}

public enum CompanySize
{
    Small,
    Medium,
    Large
}
  • Status se usa como parámetro de entrada.
  • Status y Role se usan como tipo en propiedades de User, que es a su vez un tipo de salida.
  • CompanySize se usa como parámetro de entrada.

El primer problema es con la entrada, ni Status ni CompanySize hacen honor al enumerado. Como puedes ver, el tipo es string.

   "parameters": [
                    {
                        "name": "status",
                        "in": "query",
                        "required": true,
                        "style": "form",
                        "schema": {
                            "type": "string"
                        }
                    }
                ],

status_by_default

Status sí se está generando como parte de User. Sin embargo, de CompanySize no hay rastro alguno en la especificación.

{        
    "components": {
        "schemas": {
            "Role": {
                "enum": [
                    0,
                    1,
                    2
                ],
                "type": "integer",
                "format": "int32"
            },
            "Status": {
                "enum": [
                    0,
                    1,
                    2
                ],
                "type": "integer",
                "format": "int32"
            },
            "User": {
                "type": "object",
                "properties": {
                    "id": {
                        "type": "integer",
                        "format": "int32"
                    },
                    "status": {
                        "$ref": "#/components/schemas/Status"
                    },
                    "role": {
                        "$ref": "#/components/schemas/Role"
                    }
                },
            }
        }
    }
}

En cualquier caso, las siguientes peticiones son válidas:

  • users?status=Inactive
  • users?status=1
  • companies?companySize=Medium
  • companies?companySize=1

También lo es users?status=99 (que claramente no es un valor válido del enumerado), pero no users?status=ESTADO_INVALIDO (aquí sí da error durante la deserialización).

Para solucionar esto, te va a tocar validar el enumerado, bien con tu propio código o con algo similar a IsInEnum de FluentValidation.

if (Enum.IsDefined(typeof(Status), status))
{
    
}

En cuanto a la salida, de serie nos devolverá el valor numérico del enumerado. Para cambiarlo y que devuelva la constante podemos configurar la serialización.

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

Esto conlleva que cuando vayas a usar HttpClient para deserializar la salida (por ejemplo en algún test), igualmente tendrás que añadir JsonStringEnumConverter.

[Fact]
public async Task return_a_list_of_users()
{
    var client = _factory.CreateClient();
    var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
    {
        Converters =
        {
            new JsonStringEnumConverter()
        }
    };
    var users = await client.GetFromJsonAsync<IEnumerable<User>>($"users?status={Status.Inactive}", options);
}

En relación a Swagger, puedes probar con Unchase Swashbuckle Asp.Net Core Extensions y el método AddEnumsWithValuesFixFilters(), pero sólo ayuda en parte y no soluciona el problema.

builder.Services.AddSwaggerGen(options =>
{
    options.AddEnumsWithValuesFixFilters();
});

Añade description y x-enumNames a la especificación, pero esto es sólo informativo, no hay ningún cambio de comportamiento en la UI.

"Status": {
    "enum": [
        0,
        1,
        2
    ],
    "type": "integer",
    "description": "\n\n0 = Active\n\n1 = Inactive\n\n2 = Suspended",
    "format": "int32",
    "x-enumNames": [
        "Active",
        "Inactive",
        "Suspended"
    ]
},

status_with_unchase

Toca, tristemente, bajar al barro y personalizar la generación del fichero de especificación.

Esto lo haremos con 3 clases:

using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;

namespace WebApplication1;

public class EnumSchemaFilter : ISchemaFilter
{
    public void Apply(OpenApiSchema schema, SchemaFilterContext context)
    {
        if (!context.Type.IsEnum)
        {
            return;
        }

        schema.Format = null;
        schema.Type = "string";
        
        schema.Enum.Clear();
        var names = Enum.GetNames(context.Type).ToList();
        names.ForEach(n => schema.Enum.Add(new OpenApiString($"{n}")));
        
        var xEnumNames = new OpenApiArray();
        xEnumNames.AddRange(names.Select(n => new OpenApiString(n)));
        schema.Extensions.Add("x-enumNames", xEnumNames);
    }
}

public class EnumParameterFilter : IParameterFilter
{
    public void Apply(OpenApiParameter parameter, ParameterFilterContext context)
    {
        var type = context.ParameterInfo.ParameterType;
        if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            {
                type = Nullable.GetUnderlyingType(type)!;
            }
        }

        if (!type.IsEnum)
        {
            return;
        }

        parameter.Schema.Reference = new OpenApiReference
        {
            Type = ReferenceType.Schema,
            Id = type.Name
        };
    }
}

public class EnumDocumentFilter : IDocumentFilter
{
    public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
    {
        var types = from apiDescription in context.ApiDescriptions
            from parameterDescription in apiDescription.ParameterDescriptions
            let type = parameterDescription.Type
            select type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)
                ? Nullable.GetUnderlyingType(type)
                : type;
        var enumTypes = from type in types
            where type.IsEnum
            select type;
        var enumTypesWithoutSchema =
            from type in enumTypes
            where !(from openApiSchema in context.SchemaRepository.Schemas
                select openApiSchema.Key).Contains(type.Name)
            select type;
        foreach (var enumType in enumTypesWithoutSchema)
        {
            swaggerDoc.Components.Schemas.Add(new KeyValuePair<string, OpenApiSchema>(enumType.Name, new OpenApiSchema()
            {
                Format = null,
                Type = "string",
                Enum = Enum.GetNames(enumType).Select(name => new OpenApiString($"{name}")).Cast<IOpenApiAny>()
                    .ToList(),
            }));
        }
    }
}

Con EnumSchemaFilter hemos modificado la sección components para que type sea string (además de agregar x-enumNames para que los generadores de código que usemos más adelante, pueden tener información extra):

"Status": {
    "enum": [
        "Active",
        "Inactive",
        "Suspended"
    ],
    "type": "string",
    "x-enumNames": [
        "Active",
        "Inactive",
        "Suspended"
    ]
},

Con EnumParameterFilter hemos modificado la sección parameters para que los tipos enumerados estén al tanto del tipo que hay en components (y no sean del tipo string):

"parameters": [
    {
        "name": "status",
        "in": "query",
        "required": true,
        "style": "form",
        "schema": {
            "$ref": "#/components/schemas/Status"
        }
    }
],

Sin embargo, ahora tenemos otro problema y es que CompanySize está haciendo referencia a un tipo que no está incluido en la especificación. Esto lo solucionamos con EnumDocumentFilter que generará en components los tipos que se han perdido por el camino (por ejemplo, CompanySize).

Infiero que estos tipos no se generan porque no se usan como tipos de salida, pero tampoco tengo certeza del porqué. Lo que está claro es que ahora mismo ese tipo no se está generando y no quiero tener un fichero de especificación incompleto/incorrecto.

"CompanySize": {
    "enum": [
        "Small",
        "Medium",
        "Large"
    ],
    "type": "string"
},

Ahora sí, tenemos una especificación completa y una UI de Swagger funcionando como un clavo.

status_with_customizations

status_schema_with_customizations

Además de tener una UI de Swagger totalmente funcional, el motivo de hacer todo esto es porque con el fichero de especificación más adelante querrás usarlo (probablemente) para generar código de cliente automáticamente, bien con Swagger Codegen, NSwag, AutoRest o cualquier otra herramienta.

En la situación inicial (sin personalización ninguna) la salida de Swagger Codegen para Typescript, por ejemplo, hubiera sido esta:

export type Status = 0 | 1 | 2;

export const Status = {
    NUMBER_0: 0 as Status,
    NUMBER_1: 1 as Status,
    NUMBER_2: 2 as Status
};

Sin embargo, después de aplicar las correcciones expuestas, tenemos esto otro:

export type Status = 'Active' | 'Inactive' | 'Suspended';

export const Status = {
    Active: 'Active' as Status,
    Inactive: 'Inactive' as Status,
    Suspended: 'Suspended' as Status
};

Claramente, mereció la pena el esfuerzo para tener un buen fichero de especificación.

Un saludo!


Ver también