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.
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.
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();
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!
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.
Użycie Fluent Validation w kontekście interfejsu RES, zaimplementujemy na przykładzie prostego projektu kalkulatora wystawiającego 3 endpointy :
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);
}
}
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:
Cały kod aplikacji demo dostępny na Github
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.