DEV Community

Isaac Ojeda
Isaac Ojeda

Posted on • Updated on

[Parte 2] ASP.NET: Validaciones con FluentValidation

Introducción

En el post anterior vimos como configurar y empezar a usar CQRS en un proyecto Web API en ASP.NET Core.

Hasta donde nos quedamos, funciona bien, pero tenemos funcionalidad que no está faltando y no estamos aprovechando. Es mi intención seguir escribiendo para terminar con un proyecto muy bien establecido.

Lo que vamos hacer en este post es agregar validación de solicitudes utilizando FluentValidation. Esta librería es una chulada y mezclado con MediatR es aun mejor (aunque realmente, se puede usar sin MediatR sin problema).

El código actualizado para este post, lo puedes encontrar aquí -> DevToPosts/MediatrValidationExample at post-part2 · isaacOjeda/DevToPosts (github.com).

Validando Requests

MediatR permite implementar el patrón decorador (llamado por MediatR como Behaviours).

Un Behaviour nos permite agregar eso mismo, comportamientos a nuestro pipeline de ejecución del mediador, agregando funcionalidad.

Es decir, al ejecutar un IRequest<T>, podemos decir que haga x o y antes de ejecutar el Handler determinado por el mediador.

Lo que vamos hacer aquí, es agregar un comportamiento que nos valide el IRequest<T> que se está por ejecutar por el mediador. Obviamente, si hay errores de validación, recehazaremos la ejecución y lanzaremos una excepción.

Validando CreateProductCommand

Desde el post anterior ya hemos agregado las librerías que necesitamos, así que lo único que tenemos que hacer es agregar un validador al comando que ya tenemos en CreateProductCommand:

public class CreateProductValidator : AbstractValidator<CreateProductCommand>
{
    public CreateProductValidator()
    {
        RuleFor(r => r.Description).NotNull();
        RuleFor(r => r.Price).NotNull().GreaterThan(0);
    }
}
Enter fullscreen mode Exit fullscreen mode

Como su nombre mismo lo dice, nos permite agregar validaciones al estilo Fluent. Significa que podemos agregar las reglas de validación necesarias utilizando funciones encadenadas.

Por ahora esto no hará nada, tenemos que configurar que MediatR sea el que realice esta validación y no el controlador. ¿Por qué? por que queremos diseñarlo de forma que no dependamos de la presentación (en este caso, Web API), generalmente hacemos uso de Data Annotations (incluso FluentValidation) y estas validaciones ocurren en el controlador. Realmente a veces no es malo, pero si todo el business logic lo hace el Application Core (MediatR), es mejor.

Mover de Web API Controllers a Minimal APIs separado de esta forma, no habría ningún problema, ya que las validaciones del request ocurren en Application Core.

Nota 💡: Estoy hablando como si estuvieramos realizando un proyecto con estructura Clean Architecture, realmente por ahora no, pero los conceptos y las ideas las mantenemos para poder usar este mismo conocimiento en cualquier tipo proyecto.

Custom Exceptions

Para agregar validaciones de todo tipo, haremos uso de Exceptions personalizadas, para actuar según el error que suceda.

using FluentValidation.Results;

namespace MediatrValidationExample.Exceptions;
public class ValidationException : Exception
{
    public ValidationException()
        : base("One or more validation failures have occurred.")
    {
        Errors = new Dictionary<string, string[]>();
    }

    public ValidationException(IEnumerable<ValidationFailure> failures)
        : this()
    {
        Errors = failures
            .GroupBy(e => e.PropertyName, e => e.ErrorMessage)
            .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray());
    }

    public IDictionary<string, string[]> Errors { get; }
}
Enter fullscreen mode Exit fullscreen mode

Esta Exception sirve para guardar los errores de validación que pueden ocurrir al querer procesesar una solicitud.

Basicamente se respeta el estilo que ya ofrece la Web API al utilizar el atributo [ApiController]. Dado que ahora lo vamos a hacer con MediatR, tenemos que implementarlo nosotros.

Nota 💡: Esto podría considerarse la primera desventaja de usar CQRS y MediatR, agregar complejidad. Web API ya cuenta con esta funcionalidad, pero dado queremos manejarlo desde el mediador, tenemos que implementarlo nosotros. La buena noticia es que solo se hace una vez y ya.

namespace MediatrValidationExample.Exceptions;
public class NotFoundException : Exception
{
    public NotFoundException()
        : base()
    {
    }

    public NotFoundException(string message)
        : base(message)
    {
    }

    public NotFoundException(string message, Exception innerException)
        : base(message, innerException)
    {
    }

    public NotFoundException(string name, object key)
        : base($"Entity \"{name}\" ({key}) was not found.")
    {
    }
}
Enter fullscreen mode Exit fullscreen mode

Esta excepción nos servirá para cuando se busque un Entity y este no exista.

namespace MediatrValidationExample.Exceptions;
public class ForbiddenAccessException : Exception
{
    public ForbiddenAccessException() : base() { }
}
Enter fullscreen mode Exit fullscreen mode

Esta excepción nos servirá para cuando se intente eliminar algo (o en general, cualquier acción) y el usuario no tiene permisos (spoiler: no lo usaremos en este post).

Utilizando NotFound en GetProductQuery

Ahora que ya tenemos excepciones custom, podemos empezarlas a usar. Un ejemplo claro es usar NotFoundException cuando se esté buscando un producto por ID, si este no existe, se regresa el error. Entonces, actualizamos lo siguiente en GetProductQuery:

var product = await _context.Products.FindAsync(request.ProductId);

if (product is null)
{
   throw new NotFoundException(nameof(Product), request.ProductId);
   // Opt 2: throw new NotFoundException();
}

return new GetProductQueryResponse
{
    Description = product.Description,
    ProductId = product.ProductId,
    Price = product.Price
};
Enter fullscreen mode Exit fullscreen mode

La intención es especificar en la respuesta de error que fue lo que se intentó buscar y con que ID. No es necesario siempre ponerlo así.

Validation Behaviour (decorator pattern)

Aquí crearemos un decorador que envolverá cualquier ejecución del handler

Image description

Los Behaviors de MediatR tienen el mismo funcionamiento que hace un Middleware en asp.net. Ejecuta algo y delega la ejecución al siguiente (y queda en espera de la respuesta).

Podemos usar decorators para una infinidad de cosas y MediatR nos permite configurar los que queramos sin ningún problema.

Image description

Fuente: VerticalSlideArchitecure by Jimmy Bogard

Nuestro decorador lo que hará es validar el IRequest<T> antes de que su Handler sea ejecutado, y sí hay un problema de validación, lanzará la excepción correspondiente (ValidationException).

using FluentValidation;
using MediatR;
using ValidationException = MediatrValidationExample.Exceptions.ValidationException;

namespace MediatrValidationExample.Behaviours;

public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
     where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        if (_validators.Any())
        {
            var context = new ValidationContext<TRequest>(request);

            var validationResults = await Task.WhenAll(
                _validators.Select(v =>
                    v.ValidateAsync(context, cancellationToken)));

            var failures = validationResults
                .Where(r => r.Errors.Any())
                .SelectMany(r => r.Errors)
                .ToList();

            if (failures.Any())
                throw new ValidationException(failures);
        }
        return await next();
    }
}
Enter fullscreen mode Exit fullscreen mode

Lo que hace este decorador es recibir el Request y validarlo con todos los posibles validadores de FluentValidation que se encuentran en el proyecto.

Si recuerdan, al agregar un validador, heredamos de la clase AbstractValidator<T>. Estos son registrados en el contenedor de dependencias (cosa que haremos mas adelante) y simplemente valida el request.

Si hay errores en el request (según FluentValidation lo determine), se lanzará la excepción de validación.

Si dejamos todo así como está, nuestra aplicación tendrá una Unhandled Exception y realmente no queremos eso.

Queremos poder procesar bien este tipo de Excepciones personalizadas para que regresen una respuesta según el tipo de excepción.

Para eso, utilizaremos un Action Filter de MVC (sí, esta parte es la única acoplada a la presentación, pero podríamos tal vez usar un Unhandled Exception Middelware que se encargue de eso).

Nota 💡: Si quieres aprender más de MediatR y Behaviors, visita este enlace Behaviors · jbogard/MediatR Wiki (github.com)

Custom Exception Filter

No queremos que estas excepciones personalizadas sean algo que "truene" la aplicación. Queremos que sean procesadas según el tipo de excepción y regresen un error siguiendo el estandard Problem Details.

using MediatrValidationExample.Exceptions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace MediatrValidationExample.Filters;
public class ApiExceptionFilterAttribute : ExceptionFilterAttribute
{
    public override void OnException(ExceptionContext context)
    {
        switch (context.Exception)
        {
            case ValidationException validationEx:
                HandleValidationException(context, validationEx);
                break;
            case NotFoundException notFoundEx:
                HandleNotFoundException(context, notFoundEx);
                break;
            case ForbiddenAccessException:
                HandleForbiddenAccessException(context);
                break;
            default:
                HandleUnknownException(context);
                break;
        }

        base.OnException(context);
    }


    private void HandleValidationException(ExceptionContext context, ValidationException exception)
    {
        var details = new ValidationProblemDetails(exception.Errors)
        {
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
        };

        context.Result = new BadRequestObjectResult(details);

        context.ExceptionHandled = true;
    }

    private void HandleInvalidModelStateException(ExceptionContext context)
    {
        var details = new ValidationProblemDetails(context.ModelState)
        {
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.1"
        };

        context.Result = new BadRequestObjectResult(details);

        context.ExceptionHandled = true;
    }

    private void HandleNotFoundException(ExceptionContext context, NotFoundException exception)
    {
        var details = new ProblemDetails()
        {
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.4",
            Title = "The specified resource was not found.",
            Detail = exception.Message
        };

        context.Result = new NotFoundObjectResult(details);

        context.ExceptionHandled = true;
    }

    private void HandleForbiddenAccessException(ExceptionContext context)
    {
        var details = new ProblemDetails
        {
            Status = StatusCodes.Status403Forbidden,
            Title = "Forbidden",
            Type = "https://tools.ietf.org/html/rfc7231#section-6.5.3"
        };

        context.Result = new ObjectResult(details)
        {
            StatusCode = StatusCodes.Status403Forbidden
        };

        context.ExceptionHandled = true;
    }

    private void HandleUnknownException(ExceptionContext context)
    {
        if (!context.ModelState.IsValid)
        {
            HandleInvalidModelStateException(context);
            return;
        }

        var details = new ProblemDetails
        {
            Status = StatusCodes.Status500InternalServerError,
            Title = "An error occurred while processing your request.",
            Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
        };

        context.Result = new ObjectResult(details)
        {
            StatusCode = StatusCodes.Status500InternalServerError
        };

        context.ExceptionHandled = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

¿Qué hace este Exception Filter?

Cuando ocurre un error no manejado en la API se ejecutará este Exception Filter. Lo que queremos hacer con esto es regresar un Problem Detail que es un estandard definido aquí -> RFC 7807 - Problem Details for HTTP APIs (ietf.org).

No necesitas leerlo, pero por si te preguntas donde salen los tipos de errores mostrados a continuación, vienen de ahí.

Tenemos tres Exception Handlers para nuestros tres custom exceptions

  • HandleValidationException: Regresa un HTTP 400 indicando los errores encontrados en la solicitud
    {
      "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
      "title": "One or more validation errors occurred.",
      "status": 400,
      "errors": {
        "Price": [
          "'Price' debe ser mayor que '0'."
        ]
      }
    }
Enter fullscreen mode Exit fullscreen mode
  • HandleNotFoundException: Regresa un HTTP 404 para cualquier Entity que no se llegara a encontrar
    {
      "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
      "title": "The specified resource was not found.",
      "status": 404,
      "detail": "Entity \"Product\" (2323) was not found."
    }
Enter fullscreen mode Exit fullscreen mode
  • HandleForbiddenAccessException: Regresa un HTTP 403 y será usado principalmente cuando una acción no está permitida para el usuario. En este caso no lo usamos, pero seguro se usará en tu aplicación cuando cuente con usuarios y roles.

  • HandleUnknownException: Siempre pueden ocurrer excepciones que realmente no sabemos que son (como la maldición del NullReferenceException).

Actualizando Program.cs

Para que todo lo que hemos hecho entre en función, tenemos que registrar las dependencias de FluentValidation, el Behaviour de MediatR y el Exception Filter en el Service Collection de la API.

// código omitido ...
builder.Services.AddControllers(options =>
    options.Filters.Add<ApiExceptionFilterAttribute>())
        .AddFluentValidation();
builder.Services.Configure<ApiBehaviorOptions>(options =>
    options.SuppressModelStateInvalidFilter = true);
builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>)); // Update
// ... código omitido
Enter fullscreen mode Exit fullscreen mode

Modificamos la configuración default de ApiBehaviorOptions para que la validación automatica que agrega [ApiController] ya no entre en vigor, ya que queremos usar nuestra validación custom.

El Exception Filter se agrega de forma global al registrar los Controllers, aunque sin problema, los podemos agregar individualmente con [ApiException] en cada controlador (pero pues, no es práctico).

También se registran todos los validadores que heredan de AbstractValidator y los servicios necesarios de FluentValidation.

De esta forma, ya deberías de poder correr la API y empezar a hacer pruebas con Products que no existen o tratando de crear alguno que no cumpla con las validaciones.

Conclusión

Aunque ya podemos aceptar que estamos agregando complejidad a nuestra API, podemos asegurar que habrá beneficios en esos proyectos que crecen indefinidamente.

Las validaciones de los Requests son parte del Application Core, no deberían hacerse desde la capa de presentación, MediatR y FluentValidation realmente nos salvan de este problema. Aunque parezca complicado hacer esto, se realiza una sola y vez y ya cualquier developer hará uso de ellas simplemente usando los AbstractValidators.

Referencias

Discussion (0)