Zaawansowana walidacja danych z FluentValidation

W poprzednim poście (tutaj) pisałem o bibliotece FluentValidation i jak ją dodać do naszego projektu. W tym poście skupimy się na bardziej zaawansowanych funkcjonalnościach, jakie nam ta biblioteka udostępnia.

Walidacja złożonych właściwości

Załóżmy, że mamy obiekt, którego właściwością jest inny obiekt:

public class OtherObject
{
    public SomeObject SomeObject{ get; set; }
    ...
}

public class SomeObject
{
    ...
}

Co powinniśmy zrobić, jeśli podczas walidacji obiektu o typie OtherObject, chcielibyśmy sprawdzić również reguły walidacji dla obiektu SomeObject? Tu z pomocą przychodzi nam metoda SetValidator, która daje możliwość ustawienia walidatora dla danego pola.

W tym celu najpierw w konstruktorze naszego walidatora musimy dodać walidator obiektu, dla którego będziemy chcieli sprawdzić reguły walidacji. Następnie przekazujemy ten walidator do metody SetValidator:

public class OtherObjectValidator : AbstractValidator<OtherObject>
{
    public OtherObjectValidator(SomeObjectValidator someObjectValidator)
    {
        RuleFor(v => v.SomeObject).SetValidator(someObjectValidator);
        ...
    }
}

Dzięki temu za każdym razem gdy będziemy sprawdzać reguły walidacji dla obiektu OtherObject, automatycznie zostaną sprawdzone również reguły walidacji dla obiektu SomeObject.

Wstrzyknięcie walidatora

Alternatywą jest użycie metody InjectValidator. Spodoba się ona w szczególności osobom, które nie chcą wstrzykiwać walidatorów przez konstruktor. Ta metoda nie robi nic innego, jak sama wyszukuje (z zarejestrowanych walidatorów) odpowiedni walidator dla naszej właściwości i go ustawia. Oto przykład:

public class OtherObjectValidator : AbstractValidator<OtherObject>
{
    public OtherObjectValidator()
    {
        RuleFor(v => v.SomeObject).InjectValidator();
        ...
    }
}

Automatyczna walidacja złożonych właściwości

Oczywiście nie każdemu zawsze będzie chciało się pamiętać o tym, aby dla każdej złożonej właściwości ustawić walidator. Dlatego też biblioteka FluentValidation dostarcza nam globalny parametr, który mówi o tym, aby automatycznie wywoływały się walidatory dla właściwości zaawansowanych. Jeśli chcemy włączyć tę funkcję, to podczas rejestracji naszego walidatora, w metodzie AddFluentValidation wystarczy ustawić flagę ImplicitlyValidateChildProperties:

services
    .AddMvc(...)
    .AddFluentValidation(fv =>
    {
        fv.ImplicitlyValidateChildProperties = true;
        ...
    });

Teraz nie musimy już pamiętać o dodaniu walidatorów dla właściwości złożonych, ponieważ walidacje wywołają się automatycznie.

Wspólny walidator właściwości

Zdarzają się sytuacje, kiedy dane pole występuje w wielu obiektach i za każdym razem chcemy mieć te same reguły walidacji dla tego pola. Tu z pomocą przychodzi nam klasa PropertyValidator. Tworzymy walidator, który po niej dziedziczy i nadpisujemy metodę IsValid:

public class NameValidator : PropertyValidator
{
    ...    
    protected override bool IsValid(PropertyValidatorContext context)
    {
        return context.PropertyValue is string value &&
               string.IsNullOrEmpty(value) &&
               value.Length < 10;
    }
}

Następnie ten walidator możemy ustawić przy użyciu metody SetValidator:

RuleFor(v => v.Name).SetValidator(
    new NameValidator($"'{nameof(SomeObject.Name)}' is not valid."));

Największym minusem takiego rozwiązania jest to, że cały walidator jest traktowany jako jedna reguła i zawsze zwracamy ten sam komunikat. Nie ma możliwości rozbicia walidatora na kilka reguł i zwracania komunikatu w zależności od tego, która reguła nie została spełniona.

Walidacja listy

A co możemy zrobić w sytuacji, gdy nasz obiekt zawiera kolekcję obiektów i chcemy sprawdzić czy każdy element z kolekcji spełnia reguły walidacji? Dla takich właśnie przypadków przyda nam się metoda RuleForEach:

public class OtherObject
{
    public ICollection<SomeObject> SomeObjects { get; set; }
    ...
}

public class OtherObjectValidator : AbstractValidator<OtherObject>
{
    public OtherObjectValidator(SomeObjectValidator someObjectValidator)
    {
        RuleForEach(v => v.SomeObjects).SetValidator(someObjectValidator);
        ...
    }
}

Nie musimy się bać NullReferenceException, ponieważ jeśli lista będzie pusta lub niezainicjowana, to reguły walidacji nie zostaną uruchomione.

Oczywiście dla takiego przypadku ustawienie flagi ImplicitlyValidateChildProperties również spowoduje, że reguły walidacji zostaną uruchomione automatycznie dla każdego elementu z listy. Dodatkowo zamiast metody SetValidator, możemy użyć metodę InjectValidator.

Własny komunikat błędu walidacji

FluentValidation domyślnie zwraca swoje komunikaty błędu. Jeśli jednak mamy taką potrzebę, to dość łatwo możemy je nadpisać. Służy do tego metoda WithMessage:

RuleFor(v => v.Name)
    .NotEmpty()
        .WithMessage("What's your name?")
    .MaximumLength(10)
        .WithMessage(v => $"Is your name really {v.Name}? It's a bit too loooooong.");

Metoda WithMessage odnosi się tylko do poprzedzającej ją reguły walidacji.

Wspólne walidatory

Mogą zdarzyć się przypadki, że nasz model rozszerza jakiś inny model i i chcemy, aby oba miały te same reguły walidacji. Oczywiście zawsze możemy skopiować walidacje, ale jest też inny sposób. FluentValidation dostarcza nam metodę Include, która pozwala dołączyć inny walidator:

public class OtherObject : SomeObject
{
}

public class OtherObjectValidator : AbstractValidator<OtherObject>
{
    public OtherObjectValidator(SomeObjectValidator someObjectValidator)
    {
        Include(someObjectValidator);
    }
}

Tę metodę można również wykorzystać, jeśli z jakiegoś powodu chcemy rozbić nasz walidator na mniejsze walidatory. Wtedy w głównym walidatorze możemy zawrzeć pozostałe za pomocą metody Include.

Warunki walidacji

Czasem jest tak, że reguły walidacji powinny być sprawdzane tylko wtedy, kiedy są spełnione jakieś warunki. W takich sytuacjach możemy wykorzystać metodę When. Domyślnie ta metoda powoduje, że wszystkie reguły walidacji zdefiniowane dla danej właściwości zostaną sprawdzone tylko wtedy, kiedy warunek jest spełniony.

RuleFor(v => v.Name)
    .NotEmpty()
    .MaximumLength(10)
    .When(w => w.Age > 18);

W tym przypadku wszystkie reguły walidacji dla właściwości Name zostaną sprawdzone tylko, jeśli właściwość Age będzie większa niż 18.

Jeśli chcemy, aby When odnosiło się tylko do ostatniej reguły, to musimy podać dodatkowy argument: ApplyConditionTo.CurrentValidator – jego domyślna wartość to ApplyConditionTo.AllValidators, czyli zastosuj do wszystkich reguł.

RuleFor(v => v.Name)
    .NotEmpty().When(w => w.Age >= 18, ApplyConditionTo.CurrentValidator)
    .MaximumLength(10).When(w => w.Age < 18, ApplyConditionTo.CurrentValidator);

Teraz reguła sprawdzająca czy właściwość Name nie jest pusta zostanie sprawdzona wtedy, gdy właściwość Age będzie większa lub równe niż 18. Z kolei reguła sprawdzająca czy właściwość Name zawiera maksymalnie 10 znaków, zostanie sprawdzona tylko, jeśli właściwość Age będzie mniejsze niż 18.

Warunek dla wielu właściwości

When można również użyć jako zamiennik dla if. Przydaje się to w szczególności wtedy, kiedy chcemy wywołać inne reguły, gdy warunek jest spełniony, a inne kiedy jest nie. Kod jest wtedy dużo czytelniejszy, niż jakbyśmy mieli napisać: When(w => warunek) oraz When(w => !warunek).

When(w => w.Age > 18, () => 
{
    RuleFor(v => v.Name)
        .NotEmpty()
        .MaximumLength(10);
})
.Otherwise(() =>
{
    RuleFor(v => v.Age).GreaterThanOrEqualTo(18);
});

Tutaj ponownie wszystkie reguły walidacji dla właściwości Name zostaną sprawdzone tylko wtedy, jeśli właściwość Age będzie większa niż 18. W przeciwnym przypadku zostaną sprawdzone reguły walidacji dla właściwości Age.

Reguły zależne od siebie

Domyślnie reguły są od siebie niezależne i na siebie nie wpływają. Jednakże może się przytrafić sytuacja, kiedy będziemy chcieli, aby jakaś reguła była sprawdzana tylko wtedy, jeśli inna reguła jest spełniona. W takiej sytuacji możemy skorzystać z metody DependentRules. To jest w pewnym sensie alternatywa do When. Sprawdza się jednak lepiej w przypadkach, gdy mamy zdefiniowanych wiele warunku, które powinny zostać spełnione:

RuleFor(v => v.Name)
    .NotEmpty()
    .MaximumLength(10)
    .DependentRules(() => {
        RuleFor(v => v.Age).GreaterThanOrEqualTo(18);
    });

W takiej sytuacji reguła walidacji dla Age będzie sprawdzana dopiero wtedy, gdy wszystkie reguły walidacji dla Name zostaną spełnione.

Tryb sprawdzania reguł

FluentValidation udostępnia dwa tryby sprawdzania reguł:

  • Continue (domyślny) – zawsze sprawdzaj wszystkie reguły.
  • StopOnFirstFailure – przestań sprawdzać kolejne reguły, gdy jakaś reguła nie jest spełniona.

Tryb sprawdzania reguły możemy zmienić na StopOnFirstFailure zarówno dla wszystkich walidatorów, jak i dla konkretnego walidatora. Można również go ustawić tylko dla konkretnej właściwości konkretnego walidatora.

Jeśli chcemy to zrobić dla wszystkich walidatorów, to możemy ustawić flagę CascadeMode dla statycznej klasy ValidatorOptions. Najlepiej jest to zrobić podczas konfiguracji naszej aplikacji:

public void ConfigureServices(IServiceCollection services)
{
    ValidatorOptions.CascadeMode = CascadeMode.StopOnFirstFailure;
    ...
}

Jeśli chcemy to zrobić dla konkretnego walidatora, to w jego konstruktorze przed definicją reguł walidacji powinniśmy zmienić wartość właściwości CascadeMode:

public SomeObjectValidator()
{
    CascadeMode = CascadeMode.StopOnFirstFailure;
    ...
}

Jeśli chcemy to zrobić dla konkretnej właściwości konkretnego walidatora, to po użyciu metody RuleFor powinniśmy użyć metodę Cascade:

RuleFor(v => v.Name)
    .Cascade(CascadeMode.StopOnFirstFailure)
    .NotEmpty()
    .MaximumLength(10);

Wywołanie zwrotne

Czasem może zajść potrzeba, aby wykonało się coś dodatkowego, gdy jakaś reguła walidacji nie zostanie spełniona. FluentValidation udostępnia nam dwie metody: OnAnyFailure i OnFailure. Ta pierwsza zostanie wywołana, gdy którakolwiek z reguł nie zostanie spełniona. Ta druga, gdy reguła ją poprzedzająca nie zostanie spełniona:

RuleFor(v => v.Name)
    .NotEmpty()
    .OnFailure(o => Console.WriteLine("Someone forgot to add a name again..."))
    .MaximumLength(10)
    .OnAnyFailure(o => Console.WriteLine("Why it happened again?"));

W tym przypadku jeśli Name będzie puste, to wywołają się obie funkcje. Jeśli Name będzie miało powyżej 10 znaków, to wywoła się tylko druga funkcja.

Asynchroniczna walidacja

Zdarzają się sytuacje, kiedy podczas walidacji potrzebujemy sprawdzić coś w bazie albo wywołać jakąś asynchroniczną metodę. Tu z pomocą przychodzą nam asynchroniczne reguły walidacji, które pozwalają na wywołanie asynchronicznych metod:

public interface ISomeObjectRepository
{
    Task<bool> IsUniqueAsync(string name);
}

public class SomeObjectValidator : AbstractValidator<SomeObject>
{
    public SomeObjectValidator(ISomeObjectRepository repository)
    {
        RuleFor(v => v.Name)
            .NotEmpty()
            .MaximumLength(10)
            .MustAsync(async (r, cancellation) => await repository.IsUniqueAsync(r))
            .WithMessage($"'{nameof(SomeObject.Name)}' is not unique.");
        ...
    }
}

Gdy chcemy korzystać z asynchronicznej walidacji, musimy pamiętać o tym, aby zamiast metody Validate, używać metody ValidateAsync.

1 myśl na “Zaawansowana walidacja danych z FluentValidation”

  1. Pingback: dotnetomaniak.pl

Dodaj komentarz