Walidacja w ASP .NET Core w duchu Fluent API

Development
  • Marcin Golonka
  • 02-12-2022

W poprzednim artykule pochyliłem się nad walidowaniem danych za pomocą wbudowanego rozwiązania - atrybutów z przestrzeni nazw Data Adnotations. Ale jak poradzić sobie gdy chcemy bardziej rozbudowanie walidować pola, lub chcemy zmieniać reguły walidacyjne w trakcie działania naszej aplikacji? Rozwiązaniem jest biblioteka Fluent Validation, która jest pod opieką .NET Foundation.

Wzorzec Fluent

Fluent Interface - koncept sięgający roku 2005, kiedy to Eric Evans i Martin Fowler przedstawili go światu podczas swoich warsztatów z Domain Driven Design. Wzorzec ten pozwala programiście modyfikować/konfigurować obiekt za pomocą łańcuchowego wywoływania metod.

Wraz z pojawieniem się biblioteki LINQ oraz Entity Framework, wzorzec ten zadomowił się w świecie platformy .NET.

Zalety

Gdy mówimy o zaletach Fluent API, nie sposób wymienić znaczne poprawienie czytelności kodu, poprzez sprowadzenie go do domenowego ciągu przyczynowo-skutkowego. Kolejnym plusem, który szeroko jest używany jako argument, to, że podejście Fluent chowa szczegóły implementacyjne, skupiając się na opisywaniu biznesowych reguł.

Przykładowe porównanie interfejsu Fluent, z podejściem klasycznym:

//old approach
var order = new Order();
order.Items.Add(new Item("Item 1",1));
order.Items.Add(new Item("Item 2",1));
order.User = new User();

//Fluent
var order2 = new Order();
order2
			.AddProduct("Item 1",1)
			.AddProduct("Item 2",1)
			.WithUser();

Wady

W kontrapunkcie do zalet spotykamy się z argumentami, że kod pisany za pomocą podejścia Fluent jest trudny w debugowaniu czy logowaniu poszczególnych stanów obiektów. Kolejną niedogodnością, jest utrudniona implementacja w językach silnie typowanych, ponieważ w przypadku dziedziczenia, klasa dziedzicząca musi przesłonić wszystkie metody Fluent, i zwrócić swoją instancję.

Przykład problemu, wynikającego ze zmiany typu:


class A {
    public A DoMagic() {  }
}
class B : A{
    public B DoMagic() { super.DoMagic(); return this; } // Must change return type to B.
    public B DoMoreMagic() {return this;}
}
class C : A{
    public C DoMoreMagic() {return this;}
}

var b = new B();
b.DoMagic().DoMoreMagic(); //it works!

var c = new C();
c.DoMagic().DoMoreMagic(); //kaboom!

Fluent API - Definicja funkcji walidacyjnych

Warto pochylić się nad biblioteką Fluent Validation, która jest kluczem tego artykułu. Gdyby tak jednak opisać reguły za pomocą definicji Fluent? W przypadku Fluent Validation jest to trywialne.

Przykładowy kod reguł sprawdzających wraz z wywołaniem, dla klasy POCO Person:

public class Person 
{
  public int Id { get; set; }
  public string Name { get; set; }
  public string Email { get; set; }
  public int Age { get; set; }
  public string PostCode {get;set;}
}

public class PersonValidator : AbstractValidator<Person> 
{
  public PersonValidator() 
  {
    RuleFor(x => x.Id).NotNull();
    RuleFor(x => x.Name).Length(0, 10);
    RuleFor(x => x.Email).EmailAddress()
                         .WithMessage("Please ensure that you have entered your Email");
    RuleFor(x => x.Age).InclusiveBetween(18, 60);
    RuleFor(x => x.PostCode).Must(BePolishPostcode);
  }

  private bool BePolishPostcode(string postcode)
  {
     //...
  }
}

var _validator = new PersonValidator();
var results = await _validator.ValidateAsync(person);

if(!results.IsValid) 
{
  foreach(var failure in results.Errors)
  {
    Console.WriteLine("Property " + failure.PropertyName + " failed validation. Error was: " + failure.ErrorMessage);
  }
}

Jak widać, możemy wydzielić nasz walidator do oddzielnej klasy, a dzięki temu, że klasa Abstract Validator, implementuje interfejs IValidator<T> możemy również taki walidator zarejestrować w Dependency Injection.

Oczywiście, możliwości biblioteki nie kończą się na prostym sprawdzaniu reguł dla pól w klasie i zwracaniu błędów. Walidatory możemy definiować warunkowo:

RuleFor(person => p.Age).GreaterThan(30).When(p => p.Name.Contains("Old"));

A co jeżeli potrzebujemy zdefiniować własny walidator, bo wbudowane nie spełniają naszych wymagań? Wystarczy że zbudujemy metodę rozszerzającą, zwracającą IRuleBuilderOptions

//taken from docs -> https://docs.fluentvalidation.net/en/latest/custom-validators.html
public static class MyValidators {
    public static IRuleBuilderOptions<T, IList<TElement>> ListMustContainFewerThan<T, TElement>(this IRuleBuilder<T, IList<TElement>> ruleBuilder, int num) {
        return ruleBuilder.Must(list => list.Count < num).WithMessage("The list contains too many items");
    }
}

Po więcej funkcjonalności odsyłam do dokumentacji projektu.

REST API

Użycie Fluent Validation w kontekście interfejsu RES, zaimplementujemy na przykładzie prostego projektu kalkulatora wystawiającego 3 endpointy :

  • Dodawanie
  • Dzielenie
  • Odejmowanie

Konfiguracja projektu

Zaczniemy od stworzenia projektu oraz zainstalowania biblioteki:

dotnet new webapi -minimal
dotnet add package FluentValidation

Zdefiniujemy rekordy odpowiedzialne za requesty do Endpointów:

public abstract record CalcRequest(double A, double B);
public record AddRequest(double A, double B): CalcRequest(A,B);
public record SubRequest(double A, double B): CalcRequest(A,B);
public record DivRequest(double A, double B): CalcRequest(A,B);

Kolejnym krokiem jest zdefiniowanie walidatorów:

public class CalcRequestValidator : AbstractValidator<CalcRequest>
{
    public CalcRequestValidator()
    {
        RuleFor(x => x.A).NotEmpty();
        RuleFor(x => x.B).NotEmpty();
    }
}

public class AddRequestValidator : AbstractValidator<AddRequest>
{
    public AddRequestValidator()
    {
        Include(new CalcRequestValidator());
        //my dummy rule, just for demo purposes
        RuleFor(x => x.B).GreaterThan(10);
    }
}

public class SubRequestValidator : AbstractValidator<SubRequest>
{
    public SubRequestValidator()
    {
        Include(new CalcRequestValidator());
        //my dummy rule, just for demo purposes
        RuleFor(x => x.B).GreaterThanOrEqualTo(-10);
    }
}

public class DivRequestValidator : AbstractValidator<DivRequest>
{
    public DivRequestValidator()
    {
        Include(new CalcRequestValidator());
        //my dummy rule, just for demo purposes
        RuleFor(x => x.B).NotEqual(0);
    }
}

Użycie w API ASP REST

Gdy przygotowaliśmy sobie bazę projektu, czas zacząć na użycie.

Pierwszym krokiem będzie zarejestrowanie naszych walidatorów kontenerze Dependency Injection:

builder.Services.AddScoped<IValidator<CalcRequest>, CalcRequestValidator>();
builder.Services.AddScoped<IValidator<AddRequest>, AddRequestValidator>();
builder.Services.AddScoped<IValidator<DivRequest>, DivRequestValidator>();
builder.Services.AddScoped<IValidator<SubRequest>, SubRequestValidator>();

Teraz pozostaje nam napisać końcówki REST. W moim przypadku użyłem konceptu Minimal API. Ważne jest, by w kontrolery/metody minimal api wstrzyknąć IValidator<T>:

app.MapPost("/calc/add", async ([FromServices] IValidator<AddRequest> validator, [FromBody] AddRequest req) =>
    {
        var valResults = await validator.ValidateAsync(req);
        if (valResults.IsValid == false)
        {
            return Results.ValidationProblem(valResults.ToDictionary());
        }

        return Results.Ok(req.A + req.B);
    })
    .WithName("CalcAdd")
    .ProducesValidationProblem(400)
    .Produces(200)
    .WithOpenApi();

app.MapPost("/calc/div", async ([FromServices] IValidator<DivRequest> validator, [FromBody] DivRequest req) =>
    {
        var valResults = await validator.ValidateAsync(req);
        if (valResults.IsValid == false)
        {
            return Results.ValidationProblem(valResults.ToDictionary());
        }

        return Results.Ok(req.A / req.B);
    })
    .WithName("CalcDiv")
    .ProducesValidationProblem(400)
    .Produces(200)
    .WithOpenApi();

app.MapPost("/calc/sub", async ([FromServices] IValidator<SubRequest> validator, [FromBody] SubRequest req) =>
    {
        var valResults = await validator.ValidateAsync(req);
        if (valResults.IsValid == false)
        {
            return Results.ValidationProblem(valResults.ToDictionary());
        }

        return Results.Ok(req.A - req.B);
    })
    .WithName("CalcSub")
    .ProducesValidationProblem(400)
    .Produces(200)
    .WithOpenApi();

Oczywiście, to jest tylko modelowy przykład, by pokazać zasadę. Produkcyjnie, warto byłoby zredukować powtarzalny kod.

Po uruchomieniu projektu i wykonaniu przykładowego zapytania, powinniśmy otrzymać wyniki walidacji:

validation

Cały kod aplikacji demo dostępny na Github

Podsumowanie

Reasumując, gdy oczekujemy czegoś więcej w porównaniu do walidacji atrybutami, oraz cenimy sobie możliwość konfiguracji walidatorów w kodzie, to projekt Fluent Validation, jest idealnym rozwiązaniem naszych problemów.

Jednak decyzję co do rozwiązania walidacyjnego, wybieramy jak zwykle pod zastosowanie i projekt. Czasami podejście Data Annotations może być dla naszego projektu w pełni wystarczające.