Una de las grandes características de netcore es que viene por defecto con la librería de swagger instalada, y no solo eso, sino que además combina perfectamente con el lenguaje, porque es capaz de generar nuestro swaggerfile de forma automática, sin que nosotros tengamos que tocar nada.
Índice
Pero, tiene un problema, y es que no permite, o no lo hace de una forma muy sencilla incluir Headers en la propia request.
1 - Presentación del problema
Si bien es cierto, que en este ejemplo voy a reducir al absurdo el problema, la premisa es simple, poder añadir headers dentro de nuestras llamadas a través de swagger;
Esto nos va a permitir poder testear manualmente de una forma más fácil y sencilla utilizando el propio swagger, ya que por defecto, es imposible añadir dicha información.
Por ejemplo en el post anterior de esta serie, vimos cómo modificar la respuesta de nuestra API dependiendo del atributo accept-language a través del JsonConverter, lo que vamos a hacer en este post es mostrar cómo modificar swagger para que pida dicho atributo.
Además, podremos ponerlo como opcional u obligatorio.
2 - Crear un atributo para swagger
La forma en la que vamos a crear el atributo es a través de filtros, y este filtro lo podremos habilitar tanto en un único endpoint como en el controlador entero.
Además, el propio atributo no va a ser genérico, no es algo que podamos hacer de una forma genérica y mágica, lo que tendremos que hacer es un atributo por cada header que necesitemos, aunque sí podemos abstraer bastante código.
Todo este código está disponible en GitHub en el repositorio de WebPersonal (tienes el enlace al principio del post).
Lo primero que vamos a hacer es crear una carpeta que se llame Filters
, donde crearemos nuestros filtros.
El que vamos a crear ahora es para pedir un header así que lo vamos a crear como tal:
namespace WebPersonal.BackEnd.API.Filters
{
public class AcceptedLanguageHeader : Attribute
{
}
}
Para que aparezca en swagger el primero de los requisitos es que implemente la interfaz IOperationFilter
, la cual está dentro de la librería Swashbuckle.AspNetCore.SwaggerGen
.
Y nos pedirá que implementemos dicha interfaz:
public class AcceptedLanguageHeader : Attribute, IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
throw new NotImplementedException();
}
}
Por ahora el que nos interesa es el parámetro operation, ya que es el que va a modificar visualmente la interfaz, y accedemos dentro de operation a Parameters, y directamente añadimos uno nuevo:
public class AcceptedLanguageHeader : Attribute, IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
operation.Parameters.Add(new OpenApiParameter()
{
Name = "accept-language",
In = ParameterLocation.Header,
Required = false,
Schema = new OpenApiSchema() { Type = "string" }
});
}
}
Nota: como puedes observar hay una propiedad llamada In
, esta propiedad nos permite indicar donde va dicho parámetro, y podemos seleccionar tanto header
, como query
, path
, o las cookies
.
Y con esto, podemos configurar ya swagger para que nos muestre el atributo.
Si vamos a nuestro fichero startup
(o program.cs
, dependiendo de la versión que utilices) puedes ir a los services y añadir el siguiente código con lo que generará el atributo en la UI:
services.AddSwaggerGen(c =>
{
c.OperationFilter<AcceptedLanguageHeader>();
});
Pero esto no nos sirve, ya que estamos haciendo aparecer ese header en todos los endpoints, cuando en verdad solo lo necesitamos en uno solo.
3 - Añadir un atributo de swagger a un controlador
Aquí es donde entra el motivo de porque hemos creado nuestra clase AddAcceptedLanguageHeader
heredando de Attribute
. Es porque lo vamos a utilzar como tal.
Para los que no estéis muy familiarizados con atributos, podemos filtrarlos para que se puedan utilizar en diferentes tipos de elementos, en nuestro caso, vamos a indicar Class ya que un controlador es una clase y a los métodos, para que se pueda utilizar en los endpoints de forma individual.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AcceptedLanguageHeader : Attribute, IOperationFilter
{
...
}
Además, a partir de aquí es donde entra en juego el context dentro del apply, porque es donde vamos a tener acceso a la información del endpoint al que estamos llamando.
Lo que vamos a hacer es crear una clase estática que simplemente reciba OperationFilterContext
y nos devuelva un booelano
, el cual será verdadero si el endpoint al que llamamos, tiene el atributo especificado, y si es obligatorio, que por ahora es falso.
public class CustomAttribute
{
public readonly bool ContainsAttribute;
public readonly bool Mandatory;
public CustomAttribute(bool containsAttribute, bool mandatory)
{
ContainsAttribute = containsAttribute;
Mandatory = mandatory;
}
}
public static class OperationFilterContextExtensions
{
public static CustomAttribute RequireAttribute<T>(this OperationFilterContext context)
{
IEnumerable<IFilterMetadata> globalAttributes = context
.ApiDescription
.ActionDescriptor
.FilterDescriptors
.Select(p => p.Filter);
object[] controllerAttributes = context
.MethodInfo?
.DeclaringType?
.GetCustomAttributes(true) ?? Array.Empty<object>();
object[] methodAttributes = context
.MethodInfo?
.GetCustomAttributes(true)?? Array.Empty<object>();
List<T> containsHeaderAttributes = globalAttributes
.Union(controllerAttributes)
.Union(methodAttributes)
.OfType<T>()
.ToList();
return containsHeaderAttributes.Count == 0
? new CustomAttribute(false, false)
: new CustomAttribute(true, false);
}
}
Si estas utilizando NET6 o superior, puedes crear un record en vez de una clase para representar CustomAttribute
.
NOTA: Como sabes, cuando indicas algo en el controlador, se aplica a todos los endpoints dentro de ese controlador.
Ahora tenemos que modificar nuestra Filtro, para que lea esta información y actúe en consecuencia:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AcceptedLanguageHeader : Attribute, IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
CustomAttribute acceptedLanguageHeader = context.RequireAttribute<AcceptedLanguageHeader>();
if (!acceptedLanguageHeader.ContainsAttribute)
return;
operation.Parameters.Add(new OpenApiParameter()
{
Name = "accept-language",
In = ParameterLocation.Header,
Required = acceptedLanguageHeader.Mandatory,
Schema = new OpenApiSchema() { Type = "string" }
});
}
}
Si el endpoint al que estamos llamando, no contiene el atributo, directamente hacemos un return, lo que indica que NO va incluir el Header
en la Interfaz. En cambio si lo necesita añadiremos el parámetro. Además, modificamos la propiedad Required con la respuesta del método.
Ahora si ejecutamos la aplicación, ningún endpoint tendrá el campo para introducir el header.
Si queremos que aparezca debemos introducir el Attribute en el controlador:
[ApiController]
[AcceptedLanguageHeader] //HERE
[Route("api/[controller]")]
public class ExampleErrorController : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
return Result.Failure(Guid.Parse("ce6887fb-f8fa-49b7-bcb4-d8538b6c9932"))
.ToActionResult();
}
}
3.1 - Especificar si un atributo es obligatorio en swagger
Ahora lo que vamos a hacer es indicar si el header tiene que ser obligatorio o no.
Para ello lo primero que vamos a hacer es crear una interfaz, la cual únicamente va a tener una propiedad, llamada IsMandatory
:
public interface ICustomAttribute
{
public bool IsMandatory { get; }
}
Y lo que haremos es implementar dicha interfaz en cada uno de nuestros atributos personalizados.
A si mismo, pasaremos en el constructor del filtro, si es verdadero o falso:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AcceptedLanguageHeader : Attribute, ICustomAttribute, IOperationFilter
{
public static string HeaderName = "accept-language";
public bool IsMandatory { get; }
public AcceptedLanguageHeader(bool isMandatory = false)
{
IsMandatory = isMandatory;
}
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
CustomAttribute acceptedLanguageHeader = context.RequireAttribute<AcceptedLanguageHeader>();
if (!acceptedLanguageHeader.ContainsAttribute)
return;
operation.Parameters.Add(new OpenApiParameter()
{
Name = HeaderName,
In = ParameterLocation.Header,
Required = acceptedLanguageHeader.Mandatory,
Schema = new OpenApiSchema() { Type = "string" }
});
}
}
Ahora debemos modificar nuestra clase, para que obligue al tipo genérico que hemos indicado, que sea un ICustomAttribute
y además modificamos el ultimo return para que compruebe dicha propiedad:
public static CustomAttribute RequireAttribute<T>(this OperationFilterContext context)
where T : ICustomAttribute
{
....
return containsHeaderAttributes.Count == 0
? new CustomAttribute(false, false)
: new CustomAttribute(true, containsHeaderAttributes.First().IsMandatory);
}
Finalmente modificamos el endpoint para que marque el header como obligatorio:
[ApiController]
[Route("api/[controller]")]
public class ExampleErrorController : ControllerBase
{
[AcceptedLanguageHeader(true)]
[HttpGet]
public IActionResult Get()
{
return Result.Failure(Guid.Parse("ce6887fb-f8fa-49b7-bcb4-d8538b6c9932"))
.ToActionResult();
}
}
Ahora podemos observar en la interfaz que nos lo manda como obligatorio:
NOTA: si tienes el atributo especificado tanto a nivel de controlador como a nivel de método el que tiene preferencia es el del endpoint.
4 - Middleware para evitar hackeos
Uno de los problemas de esta solución es que solo funciona en swagger, asi que si queremos forzar esta solución a nivel API debemos implementar un middleware que realice una funcionalidad similar, comprobar si existe o no.
namespace WebPersonal.BackEnd.API.Middlewares
{
public class CustomHeaderValidatorMiddleware
{
private readonly RequestDelegate _next;
private readonly string _headerName;
public CustomHeaderValidatorMiddleware(RequestDelegate next, string headerName)
{
_next = next;
_headerName = headerName;
}
public async Task Invoke(HttpContext context)
{
if (IsHeaderValidated(context))
{
await _next.Invoke(context);
}
else
{
throw new Exception($"the header {_headerName} is mandatory and it is missing");
}
}
private bool IsHeaderValidated(HttpContext context)
{
Endpoint? endpoint = context.GetEndpoint();
if (endpoint == null)
return true;
bool isRequired = IsHeaderRequired(endpoint);
if (!isRequired)
return true;
bool isIncluded = IsHeaderIncluded(context);
if (isRequired && isIncluded)
return true;
return false;
}
private bool IsHeaderIncluded(HttpContext context)
=> context.Request.Headers.Keys.Select(a=>a.ToLower()).Contains(_headerName.ToLower());
private static bool IsHeaderRequired(Endpoint endpoint)
{
var attribute = endpoint.Metadata.GetMetadata<ICustomAttribute>();
return attribute is { IsMandatory: true };
}
}
}
No entraré mucho en detalles, pero básicamente una sucesión de Ifs
la cual comprobará si el header que mandamos en el constructor es necesario, si lo contiene y si no lo contiene devuelve una excepción.
Ahora únicamente nos queda especificar el middleware en el la request pipeline, ósea en el pogram.cs
:
app.UseMiddleware<CustomHeaderValidatorMiddleware>(AcceptedLanguageHeader.HeaderName);
Si hacemos una prueba sin enviar el header nos devolverá la excepción:
En otro post quizá veamos como hacer todo más bonito (unificando la respuesta de la API), pero para evitar hackeos, esta solución es más que suficiente.
Conclusión
En este post hemos visto cómo añadir un header dentro de la interfaz de swagger
Hemos aprendido a especificar dicho header únicamente en ciertos endpoints
También cómo especificar un header en swagger como opcional u obligatorio.