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
yRole
se usan como tipo en propiedades deUser
, 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
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"
]
},
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.
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!