Walidacja opcji

Walidacja opcji

Wstęp

Kiedy tworzysz opcje do swojego programu, warto dodatkowo je walidować. Pewnie, że nie wszystkie. Jednak daje to większe poczucie spokoju, zwłaszcza kiedy aplikacja chodzi na różnych środowiskach (chociażby produkcyjne i deweloperskie). Jeśli ktoś przez pomyłkę źle skonfiguruje system, to nie będzie on działał poprawnie. Co więcej, przez długi czas możesz się o tym nie dowiedzieć. Niepoprawne opcje mogą przez dłuższy czas nie dawać o sobie znaku. Walidacja ich od razu może rozwalić program. Od razu będzie wiadomo, że coś jest nie tak.

W tym artykule pokażę Ci jak możesz walidować swoje opcje zarówno za pomocą adnotacji, jak i fluent validation.

Jeśli nie wiesz jak konfigurować opcje w .NET, koniecznie przeczytaj ten artykuł: Konfiguracja i opcje programu w .NET.

Przykładowy projekt

Do tego artykułu przygotowałem przykładowy projekt, który możesz pobrać z GitHuba: https://github.com/AdamJachocki/OptionsValidation/tree/master

Podstawy walidacji

.NET umożliwia Ci walidację opcji na kilka różnych sposobów. Możesz sprawdzać typowymi adnotacjami (DataAnnotations) w klasie modelu opcji. Pisałem już o tym w artykule o walidacji.

Załóżmy więc, że mamy taki prosty model opcji:

public class SimpleOptions
{
    [EmailAddress]
    public string SenderEmail { get; set; }
    [Required]
    public string SmtpAddress { get; set; }
}

Jak widać, walidujemy tutaj za pomocą adnotacji. Pole SenderEmail musi być adresem e-mail, natomiast pole SmtpAddress jest wymagane.

Teraz, żeby uruchomić walidację, trzeba nieco inaczej skonfigurować te opcje niż w sposób domyślny opisany w tym artykule. Teraz zamiast metody Configure, użyjemy AddOptions, które zwraca obiekt klasy OptionBuilder, który z kolei umożliwia walidacje:

services.AddOptions<SimpleOptions>()
	.Bind(Configuration.GetSection("SimpleOptions"))
	.ValidateDataAnnotations();

Zauważ, że używając OptionBuildera, trzeba użyć metody Bind do powiązania tych opcji i na koniec ValidateDataAnnotations, co uruchomi walidację tych opcji, używając adnotacji. Tylko adnotacji. Pamiętaj o tym.

Teraz, jeśli jakieś opcje nie będą spełniały założeń, podczas ich wstrzykiwania pójdzie wyjątek. Np. spójrz na taki appsettings.json:

 {
   "SimpleOptions": {
    "SenderEmail": "admin@example.com"
  }
}

Jak widzisz, nie ma tutaj w ogóle pola SmtpAddress, które jest wymagane w naszym modelu. Teraz, jeśli chcielibyśmy takie opcje odczytać np.:

[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
    private readonly SimpleOptions _simpleOptions;
    public TestController(IOptions<SimpleOptions> simpleOptions)
    {
        _simpleOptions = simpleOptions.Value;
    }
}

to w linijce 8 dostaniemy wyjątek. Takich opcji nie można pobrać, bo nie spełniają warunków.

Problemem jest to, że program musi dojść do tego miejsca, żeby opcje zostały zwalidowane. Na szczęście w .NET6 można sprawdzić konkretne opcje już podczas uruchamiania aplikacji, co jest naprawdę mega użyteczne. Piszę o tym później.

Oczywiście sam możesz pisać własne atrybuty walidacyjne, o czym pisałem tutaj. Wystarczy napisać klasę dziedziczącą po ValidationAttribute.

To prosta walidacja. Nie można za jej pomocą zrobić bardziej wyrafinowanych sprawdzeń. A jeśli można to jest to uciążliwe. Dlatego dla takich scenariuszy przychodzi kolejna możliwość…

Własny walidator

Wystarczy stworzyć własny walidator – klasę, która implementuje interfejs IValidateOptions. Nic nie stoi na przeszkodzie, żeby Twój model ten interfejs implementował, jednak z punktu widzenia czystości kodu, to nie jest dobre rozwiązanie. Pamiętaj o tym.

Stworzę zatem osobną klasę, która będzie walidować taki model:

public class ApiOptions
{
    public string ClientId { get; set; }
    public string ClientSecret { get; set; }
    public string ClientUri { get; set; }
}

Te przykładowe opcje pozwalają się łączyć z hipotetycznym API. Założenie jest takie, że albo podajemy ClientId i ClientSecret (który musi być odpowiedniej długości), albo podajemy ClientUri. Napiszmy teraz walidator do tego. Zacznijmy od pustego:

public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
    public ValidateOptionsResult Validate(string? name, ApiOptions options)
    {
        
    }
}

Jak widzisz, interfejs IValidateOptions posiada tylko jedną metodę do implementacji. W parametrze name dostaniesz nazwę tych opcji, jeśli używasz named options. Natomiast w parametrze options otrzymasz cały model odczytany z konfiguracji. I teraz możesz go sprawdzić np. w taki najprostszy sposób:

public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
    public ValidateOptionsResult Validate(string? name, ApiOptions options)
    {
        bool isIdAndSecret = IsIdAndSecret(options);
        bool isUri = IsUri(options);

        if (isIdAndSecret && isUri)
            return ValidateOptionsResult.Fail("Nie możesz jednocześnie podać ClientUri i sekretów");

        if (!isIdAndSecret && !isUri)
            return ValidateOptionsResult.Fail("Musisz podać jakieś dane do połączenia z API");

        if (isIdAndSecret && options.ClientSecret.Length < 5)
            return ValidateOptionsResult.Fail("Client secret jest za krótki");

        return ValidateOptionsResult.Success;
    }

    private bool IsIdAndSecret(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
    }

    private bool IsUri(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientUri);
    }
}

Po prostu wykonujemy kilka prostych sprawdzeń i albo zwracamy ValidateOptionsResult.Success, albo Fail. W przypadku, jeśli walidacja się nie powiedzie, zachowanie będzie identyczne jak przy walidacji adnotacjami. Program się wywali na próbie pobrania opcji.

Teraz tylko trzeba to zarejestrować w nieco inny sposób. Możemy posłużyć się zarówno OptionsBuilderem jak i konfiguracją, jednak trzeba dodatkowo zarejestrować takiego walidatora:

builder.Services.Configure<ApiOptions>(builder.Configuration.GetSection("ApiOptions"));
builder.Services.AddSingleton<IValidateOptions<ApiOptions>, ApiOptionsValidator>();

Nowość w .NET8

W .NET8 wprowadzono budowniczego ValidateOptionsResultBuilder, którym możesz sobie zbudować cały rezultat jeśli chcesz. Dzięki temu możesz zwrócić kilka błędów. Powyższy kod mógłby wyglądać tak:

public class ApiOptionsValidator : IValidateOptions<ApiOptions>
{
    public ValidateOptionsResult Validate(string? name, ApiOptions options)
    {
        bool isIdAndSecret = IsIdAndSecret(options);
        bool isUri = IsUri(options);

        ValidateOptionsResultBuilder builder = new();

        if (isIdAndSecret && isUri)
            builder.AddError("Nie możesz jednocześnie podać ClientUri i sekretów");

        if (!isIdAndSecret && !isUri)
            builder.AddError("Musisz podać jakieś dane do połączenia z API");

        if (isIdAndSecret && options.ClientSecret.Length < 5)
            builder.AddResult(ValidateOptionsResult.Fail("Client secret jest za krótki"));

        return builder.Build();
    }

    private bool IsIdAndSecret(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientId) && !string.IsNullOrWhiteSpace(options.ClientSecret);
    }

    private bool IsUri(ApiOptions options)
    {
        return !string.IsNullOrWhiteSpace(options.ClientUri);
    }
}

Zaznaczyłem znaczące linie. Jak widzisz, możesz do buildera dodawać zarówno errory jak i całe obiekty ValidateOptionsResult.

Dzięki temu możesz pokazać wszystkie problemy związane z opcjami, a nie tylko jeden.

Walidacja w OptionsBuilderze

Ten sposób walidacji zostawiam raczej jako ciekawostkę. W małych systemach pewnie się sprawdzi, natomiast w większych lepiej go unikać.

Można napisać kod walidacyjny podczas rejestrowania opcji:

builder.Services.AddOptions<ApiOptions>()
    .Bind(builder.Configuration.GetSection("ApiOptions"))
    .Validate(options =>
    {
        if (string.IsNullOrWhiteSpace(options.ClientUri)
            && string.IsNullOrWhiteSpace(options.ClientId)
            && string.IsNullOrWhiteSpace(options.ClientSecret))
            return false;

        return true;
    });

To tylko fragment wcześniejszej walidacji. Musiałbym napisać resztę przypadków, ale to nie ma sensu (bo to tylko przykład). Tutaj metoda Validate przyjmuje delegat – funkcję, która zwraca bool, a w parametrze ma model opcji.

Dlaczego to nie ma sensu? To chyba widać. W przypadku większej ilości opcji lub bardziej wyrafinowanych walidacji w kodzie po prostu zrobi się burdel i całość stanie się mało czytelna.

Tak jak mówiłem wcześniej – w małych, szybkich projektach to się może sprawdzić. Natomiast w większych raczej nie.

Walidacja przy starcie systemu

Domyślny mechanizm będzie walidował opcje dopiero w momencie próby ich pobrania: var myOptions = options.Value. Natomiast możesz sobie życzyć, żeby opcje były sprawdzane podczas uruchamiania programu. Plusem tego jest to, że od razu dowiesz się, że coś jest nie tak, bo apka wywali się podczas uruchamiania. Minus? Aplikacja będzie potrzebować nieco więcej czasu, żeby się uruchomić, ponieważ będzie sprawdzać opcje, które wskażesz. Myślę jednak, że warto to zrobić, bo od razu dostajesz wiedzę, że coś jest źle skonfigurowane.

Wystarczy, że wywołasz metodę ValidateOnStart z OptionsBuilder:

builder.Services.AddOptions<SimpleOptions>()
    .Bind(builder.Configuration.GetSection("SimpleOptions"))
    .ValidateDataAnnotations()
    .ValidateOnStart();

Pamiętaj, że metoda ValidateOnStart doszła dopiero w .NET6. Dlatego jeśli masz projekt we wcześniejszej wersji przemyśl migrację do .NET6.

Walidacja typu FLUENT

Na koniec pokażę jak zaprząc do walidacji opcji znaną i lubianą bibliotekę open source – FluentValidation. Od razu zaznaczam, że ten artykuł nie jest kursem ani nawet nie muska działania tej biblioteki. Jeśli wiesz, co ona robi, ten akapit może Ci się przydać. W innym przypadku spróbuj się z nią najpierw zapoznać.

FluentValidation umożliwia walidacje w sposób „fluent” modeli. Jednak standardowo nie obsługuje opcji. Można to w dość prosty sposób zmienić.

Spójrzmy na przykładowy model opcji:

public class FluentApiOptions
{
    public string ClientId { get; set; }
    public string ClientSecret { get; set; }
    public string ClientUri { get; set; }
    public string ApiUrl { get; set; }
}

Założenia będą takie jak w poprzednim przykładzie, tzn. podajemy albo clientId i secret, albo clientUri. Jedno z dwóch. Dodatkowo zawsze musi być ApiUrl.

Walidator Fluenta

Napiszmy do tego standardowy walidator fluentowy:

public class FluentApiOptionsValidator: AbstractValidator<FluentApiOptions>
{
    public FluentApiOptionsValidator()
    {
        RuleFor(x => x.ApiUrl)
            .NotEmpty();

        RuleFor(x => x.ClientUri)
            .NotEmpty()
            .When(x => string.IsNullOrWhiteSpace(x.ClientId) && string.IsNullOrWhiteSpace(x.ClientSecret))
            .WithMessage("Jeśli nie podajesz clientId i sekretu, musisz podać ClientUri")
            .MinimumLength(5)
            .WithMessage("ClientUri jest za krótkie");

        RuleFor(x => x.ClientId)
            .NotEmpty()
            .When(x => string.IsNullOrWhiteSpace(x.ClientUri))
            .WithMessage("Musisz podać ClientId i sekret, jeśli nie podajesz ClientUri");

        RuleFor(x => x.ClientSecret)
            .NotEmpty()
            .When(x => !string.IsNullOrWhiteSpace(x.ClientId))
            .WithMessage("Brak client secret");
    }
}

I dopiero teraz zacznie się zabawa.

Mając już walidator do konkretnego modelu, musimy teraz stworzyć swój własny walidator opcji – ten, implementujący interfejs IValidateOptions. Dlaczego?

Integracja z opcjami

Jak już mówiłem, FluentValidation nie jest domyślnie zintegrowany z mechanizmem opcji w .NET. A szkoda, bo mógłby być. Zatem sami musimy sobie taką integrację zapewnić. I tutaj przychodzi z pomocą IValidateOptions. Utworzymy generyczny walidator, żeby można go było używać z każdym typem opcji. To w najprostszej postaci może wyglądać tak:

public class GenericFluentValidator<TOptions> : IValidateOptions<TOptions>
    where TOptions : class
{
    private readonly IServiceProvider _serviceProvider;
    private readonly string _name;

    public GenericFluentValidator(string name, IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
        _name = name;
    }

    public ValidateOptionsResult Validate(string? name, TOptions options)
    {
        if (_name != null && _name != name)
            return ValidateOptionsResult.Skip;

        using var scope = _serviceProvider.CreateScope();
        var validator = scope.ServiceProvider.GetRequiredService<IValidator<TOptions>>();

        ValidationResult res = validator.Validate(options);
        if (res.IsValid)
            return ValidateOptionsResult.Success;

        var errorArray = res.Errors.Select(e => e.ErrorMessage).ToArray();
        var msg = string.Join(Environment.NewLine, errorArray);

        return ValidateOptionsResult.Fail(msg);              
    }
}

To na pierwszy rzut oka może okazać się zawiłe, ale jest naprawdę bardzo proste.

Pomińmy na razie konstruktor – w jakiś sposób dostaniemy IServiceProvider i nazwę (jeśli używamy named options). Przejdźmy od razu do metody Validate.

Najpierw sprawdzamy, czy używamy named options i czy to odpowiedni do tego walidator. Jeśli nie, to olewamy.

Następnie tworzymy sobie scope’a, żeby pobrać z niego serwis implementujący IValidator<TOptions>. A teraz pytanie – co to takiego? Interfejs IValidator<T> pochodzi z FluentValidation. Wszystkie walidatory ten interfejs implementują. A więc po prostu szukamy walidatora dla konkretnego typu.

Gdy już mamy go, to w linijce 21 uruchamiamy walidację. Jeśli się udała, zwracamy sukces. Jeśli nie, zwracamy listę błędów z tego walidatora w postaci jednego stringa.

Rejestracja

Teraz, jak już wiesz, trzeba zarejestrować ten GenericFluentValidator:

builder.Services.AddSingleton<IValidateOptions<FluentApiOptions>>(sp =>
{
    return new GenericFluentValidator<FluentApiOptions>("", sp);
});

Po prostu dodajemy go tak jak w poprzednich przykładach, tyle że z wykorzystaniem fabryki – dzięki czemu możemy przekazać IServiceProvidera. Parametrem name na razie się nie przejmuj, zajmiemy się nim później.

Na koniec wystarczy zarejestrować konkretny walidator modelu:

builder.Services.AddSingleton<IValidator<FluentApiOptions>, FluentApiOptionsValidator>();

No i wszystko śmiga. Ale przyznasz, że żeby ogarnąć jeden model w taki sposób, trzeba się sporo napisać. Właściwie upierdliwe są te rejestracje. Ale jest na to metoda…

Upraszczamy rejestracje

Posłużymy się extensioniem, żeby ułatwić sobie pracę. Całą rejestrację przeniesiemy do extensiona. Przy okazji załatwi nam to problem named options:

public static class OptionsBuilderExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions, TValidator>(this OptionsBuilder<TOptions> builder)
        where TOptions : class
        where TValidator: class, IValidator<TOptions> 
    {
        builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
        {
            return new GenericFluentValidator<TOptions>(builder.Name, sp);
        });

        builder.Services.AddSingleton<IValidator<TOptions>, TValidator>();

        return builder;
    }
}

Dzięki takiemu rozwiązaniu, model opcji możemy zarejestrować w taki sposób:

builder.Services.AddOptions<FluentApiOptions>()
    .BindConfiguration("FluentApiOptions")
    .ValidateFluentValidation<FluentApiOptions, FluentApiOptionsValidator>()
    .ValidateOnStart();

Jeśli uważasz, że to wciąż kupa roboty, można to upraszczać dalej.

FluentValidation ma oficjalne rozszerzenie do DependencyInjection. Jeśli używasz FV, to pewnie tego rozszerzenia też:

dotnet add package FluentValidation.DependencyInjectionExtensions

W tym pakiecie znajdują się metody, które automatycznie rejestrują wszystkie walidatory ze wskazanego assembly. Więc teraz wystarczy uprościć nasze rozszerzenie i wywalić z niego rejestrację walidatora:

public static class OptionsBuilderExtensions
{
    public static OptionsBuilder<TOptions> ValidateFluentValidation<TOptions>(this OptionsBuilder<TOptions> builder)
        where TOptions : class
    {
        builder.Services.AddSingleton<IValidateOptions<TOptions>>(sp =>
        {
            return new GenericFluentValidator<TOptions>(builder.Name, sp);
        });

        return builder;
    }
}

A na koniec zarejestrować opcje:

builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
builder.Services.AddOptions<FluentApiOptions>()
    .BindConfiguration("FluentApiOptions")
    .ValidateFluentValidation()
    .ValidateOnStart();

Teraz jest łatwo, miło i prosto.

I tutaj rodzi się pytanie, czy walidowanie opcji za pomocą FluentValidation ma sens i czy nie jest to przerost formy nad treścią. Jak zwykle – to zależy. Jeśli szybciej/lepiej pracuje Ci się z FluentValidation i widzisz zysk w takim sprawdzaniu, zamiast pisać własny kod walidujący, to na pewno ma to sens, a czas włożony w konfigurację tego ustrojstwa szybko się zwróci. Zwłaszcza, że jest już sporo gotowych walidatorów na „dzień dobry”. A jak widzisz, konfiguracja nie jest aż taka straszna.

Nowości w .NET8

Walidacja opcji bez użycia refleksji – zgodność z AOT

.NET8 przynosi pewną, małą nowość. Kod, który używa refleksji (na przykład ten standardowy sposób walidacji powyżej), nie jest zgodny z AOT. Dlatego też nie moglibyśmy używać walidacji opcji w kompilacji AOT.

Możemy napisać częściowego walidatora, którego kod zostanie wygenerowany automagicznie i ten kod nie będzie już używał refleksji.

Brzmi jak kupa roboty? Może i tak, ale spójrz na to:

public class MyAppConfig
{
    [EmailAddress]
    [Required]
    public string SmtpAdress {  get; set; }
    [Range(1, 10)]
    public int TraceLevel { get; set; }
}

To jest model, który będziemy walidować. A walidator będzie wyglądał tak:

[OptionsValidator]
public partial class MyAppConfigValidator: IValidateOptions<MyAppConfig>
{

}

I to jest dokładnie tyle. Dobrze widzisz. Tutaj są istotne dwie rzeczy:

  • klasa musi być oznaczona jako partial
  • klasa musi posiadać atrybut [OptionsValidator]

W innym wypadku po prostu się nawet nie skompiluje.

W ostatnim kroku musimy ją jeszcze zarejestrować:

builder.Services.AddSingleton<IValidateOptions<MyAppConfig>, MyAppConfigValidator>();

W efekcie zostanie wygenerowany kod dla klasy MyAppConfigValidator, który będzie miał zaszytą całą logikę walidacji w sobie. I to wszystko zadzieje się bez wykorzystania refleksji. Dzięki temu możesz tego używać w kompilacjach AOT.

Bindowanie opcji bez użycia refleksji – zgodność z AOT

Jeśli chodzi o rejestracje opcji, tj. Configure(TOptions), Bind i Get, to standardowo była do tego wykorzystywana refleksja. W .NET8 w aplikacjach internetowych domyślnie konfiguracja jest realizowana przez generator kodu. Czyli jest to zgodne z AOT i nie wymaga żadnych zmian.

Jeśli jednak chcesz być zgodny z AOT i nie tworzysz aplikacji webowej, musisz na takie działanie jawnie wyrazić zgodę. Wystarczy dodać ustawienie w projekcie:

<PropertyGroup>
    <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

A czy ty walidujesz swoje opcje? Daj znać w komentarzu 🙂


Dziękuję Ci za przeczytanie tego artykułu. Wierzę, że walidacja opcji stała się dla Ciebie jasna i będziesz jej używać. Jeśli znalazłeś w artykule jakiś błąd lub czegoś nie rozumiesz, koniecznie daj znać w komentarzu.

No i podziel się tym artykułem z kimś, komu uważasz że się przyda 🙂

Podziel się artykułem na:
Walidacja w BLAZOR!

Walidacja w BLAZOR!

Jeśli trafiłeś tu tylko po to, żeby dowiedzieć się, jak walidować złożony obiekt, zobacz końcówkę artykułu.

Kiedyś napisałem co nieco o walidacji w .NetCore MVC. Możesz te artykuły przeczytać tu i tu. Powinieneś je przeczytać, ponieważ mówią również o podstawach walidacji jako takiej (w tym o data annotation validation, którym będziemy się tu posługiwać).

Wchodząc w świat Blazor, bardzo szybko będziesz chciał tworzyć formularze i je walidować. Na szczęście w Blazor jest to chyba jeszcze prostsze niż w Razor.

Pierwszy formularz

Blazor ma wbudowany komponent, który nazywa się EditForm. Jak można się domyślić, jest to po prostu formularz. Ma on kilka ciekawych możliwości, w tym artykule skupimy się jednak tylko na walidacji.

Na początek stwórzmy bardzo prosty model – Customer. Tę klasę utworzymy oczywiście w osobnym pliku Customer.cs.

public class Customer
{
	[Required]
	[StringLength(50, MinimumLength = 5)]
	public string Name { get; set; }
}

Jest klasa z jednym polem i dodanymi adnotacjami:

  • Pole jest wymagane
  • Musi mieć długość minimum 5 znaków i maksimum 50.

Teraz utwórzmy w pliku .razor stronę z formularzem:

@page "/"
@using Models

<EditForm Model="Customer" OnValidSubmit="SubmitForm">
    <div>
        <label for="name">Imię i nazwisko:</label>
        <InputText id="name" @bind-Value="Customer.Name" />
    </div>
    <div>
        <button type="submit">Zapisz</button>
    </div>    
</EditForm>

@code
{
    Customer Customer = new Customer();

    async Task SubmitForm()
	{

	}
}

Spójrz, co mamy w sekcji code. Tworzymy nowy obiekt dla naszego modelu. To jest ważne, ponieważ ten obiekt powiążesz z formularzem.

Teraz spójrz wyżej na komponent EditForm. To jest właściwie najbardziej podstawowa konstrukcja formularza. Mamy właściwość Model, która określa OBIEKT powiązany (zbindowany) z formularzem. Pamiętaj, że to jest obiekt utworzony w sekcji code, a nie nazwa klasy.

Dalej mamy zdarzenie OnValidSubmit. Przypisujemy tutaj metodę, która wykona się po wysłaniu formularza, gdy będzie on prawidłowy. Z tego wynika, że ta metoda nie zostanie wykonana, jeśli formularz będzie zawierał błędy (są inne zdarzenia, które do tego służą).

Dalej w formularzu masz komponent InputText, który określa powiązanie z odpowiednim polem modelu – za pomocą @bind-value – czyli typowy binding z Blazor.

No i na koniec mamy guzik do wysłania formularza – pamiętaj, że w formularzu możesz mieć TYLKO JEDEN guzik typu submit.

Dodajemy walidację do strony

Przede wszystkim trzeba powiedzieć formularzowi, żeby walidował model za pomocą DataAnnotations (można to zrobić inaczej, ale w tym artykule skupiamy się na prostych podstawach). W tym celu trzeba mu dorzucić komponent DataAnnotationsValidator:

<EditForm Model="Customer" OnValidSubmit="SubmitForm">
    <DataAnnotationsValidator/>
    <div>
        <label for="name">Imię i nazwisko:</label>
        <InputText id="name" @bind-Value="Customer.Name" />
    </div>
    <div>
        <button type="submit">Zapisz</button>
    </div>    
</EditForm>

Właściwie to załatwia sprawę, formularz może być już walidowany. Sprawdź to. Jeśli spróbujesz wysłać pusty formularz, wymagane pole zaświeci się na czerwono.

Komunikaty błędów

Pewnie chciałbyś uzyskać jakiś komunikat błędu? Można to zrobić na kilka sposobów. Najprościej – dodaj komponent ValidationSummary – czyli podsumowanie walidacji.

<EditForm Model="Customer" OnValidSubmit="SubmitForm">
    <DataAnnotationsValidator />
    <ValidationSummary />
    <div>
        <label for="name">Imię i nazwisko:</label>
        <InputText id="name" @bind-Value="Customer.Name" />
    </div>
    <div>
        <button type="submit">Zapisz</button>
    </div>
</EditForm>

Możesz go dodać w dowolnym miejscu formularza – tutaj pojawią się po prostu komunikaty o błędach. Uruchom teraz i zobacz, co się stanie, gdy podasz nieprawidłowe dane:

Widzimy opis błędu i podświetlone konkretne pole. Przy czym zwróć uwagę, że opis błędu wskazuje konkretnie na pole o nazwie „Name” – to zostało wzięte z modelu. Można to zmienić. Wszystkie rodzaje inputów (a jest ich kilka) w EditForm mają właściwość DisplayName. Dodaj ją:

<InputText id="name" @bind-Value="Customer.Name" DisplayName="imię i nazwisko"/>

To jest po prostu przyjazna nazwa pola dla walidacji. Niestety w mojej wersji Blazor (wrzesień 2021) ta właściwość nie chce działać. Ale nic to. I tak w prawdziwym świecie posłużymy się odpowiednią adnotacją w modelu:

public class Customer
{
	[Required(ErrorMessage = "Musisz wypełnić imię i nazwisko")]
	[StringLength(50, MinimumLength = 5, ErrorMessage = "Imię i nazwisko musi być dłuższe niż 5 znaków i krótsze niż 50")]
	public string Name { get; set; }
}

Pisałem już o tych mechanizmach w artykule o własnej walidacji w .NetCore, więc nie będę się tutaj powtarzał.

Wymagany checkbox

W artykule o własnej walidacji w .NetCore pisałem też o tym, jak zrobić wymagany checkbox. Np do akceptacji regulaminu. Wtedy trzeba było się nieźle nakombinować. Dzisiaj jest dużo prościej i zdecydowanie bardziej przyjaźnie. Spójrz na model:

public class Customer
{
	[Required(ErrorMessage = "Musisz wypełnić imię i nazwisko")]
	[StringLength(50, MinimumLength = 5, ErrorMessage = "Imię i nazwisko musi być dłuższe niż 5 znaków i krótsze niż 50")]
	public string Name { get; set; }

	[Required]
	[Range(typeof(bool), "true", "true", ErrorMessage = "Musisz zaakceptować plitykę prywatności")]
	public bool PrivacyAgreed { get; set; }
}

I pole w formularzu:

<EditForm Model="Customer" OnValidSubmit="SubmitForm">
    <DataAnnotationsValidator />
    <ValidationSummary />
    <div>
        <label for="name">Imię i nazwisko:</label>
        <InputText id="name" @bind-Value="Customer.Name"/>
    </div>
    <div>
        <InputCheckbox id="agreement" @bind-Value="Customer.PrivacyAgreed" />
        <label for="agreement">Zgoda na politykę prywatności</label>
    </div>
    <div>
        <button type="submit">Zapisz</button>
    </div>
</EditForm>

I już. Wszystko załatwione adnotacjami.

Nie wiem jak Ty, ale ja nie lubię widzieć całej listy rzeczy, które zostały źle wprowadzone. Lubię, jak każdy błąd jest wyświetlony przy konkretnym polu. Można to zrobić bardzo prosto.

Błędy przy konkretnych polach

W tym momencie możesz pozbyć się komponentu ValidationSummary. To on właśnie pokazuje pełną listę błędów. Pamiętaj jednak, żeby zostawić DataAnnotasionsValidator, który odpowiada za walidację.

Teraz do każdego walidowanego pola możesz dodać komponent ValidationMessage:

<EditForm Model="Customer" OnValidSubmit="SubmitForm">
    <DataAnnotationsValidator />
    <div>
        <label for="name">Imię i nazwisko:</label>
        <InputText id="name" @bind-Value="Customer.Name"/>
        <ValidationMessage For="() => Customer.Name" />
    </div>
    <div>
        <InputCheckbox id="agreement" @bind-Value="Customer.PrivacyAgreed" />
        <label for="agreement">Zgoda na politykę prywatności</label>
        <ValidationMessage For="() => Customer.PrivacyAgreed"/>
    </div>
    <div>
        <button type="submit">Zapisz</button>
    </div>
</EditForm>

Oczywiście te komponenty możesz umieścić gdziekolwiek w formularzu. Za pomocą właściwości For określasz, do którego pola się odnoszą. Zwróć uwagę, że przekazujesz tam lambdę, a nie nazwę pola

Wszystko fajnie, tylko że brzydko. A co, jeśli chcielibyśmy trochę popracować nad wyglądem komunikatów? Można to zrobić. W standardowym projekcie znajduje się plik site.css (w katalogu wwwroot/css). Jak sobie popatrzysz do środka, zobaczysz taki styl:

.validation-message {
    color: red;
}

To właśnie odpowiada za wygląd komunikatów. Możemy to zmienić, np:

.validation-message {
    color: #8a0000;
    font-size: smaller;
    font-weight: bold;
    margin-bottom: 20px;
}

Teraz, to wygląda tak:

Walidacja obiektu złożonego

Co ma każdy klient? Każdy klient ma swój adres. Stwórzmy teraz bardzo prosty model adresu:

public class Address
{
	[Required(ErrorMessage = "Musisz podać nazwę ulicy")]
	public string StreetName { get; set; }
	[Required(ErrorMessage = "Musisz podać numer ulicy")]
	public string StreetNumber { get; set; }
	[Required(ErrorMessage = "Musisz podać miasto")]
	public string City { get; set; }
}

I dodajmy go do modelu klienta, a także do formularza:

public class Customer
{
	[Required(ErrorMessage = "Musisz wypełnić imię i nazwisko")]
	[StringLength(50, MinimumLength = 5, ErrorMessage = "Imię i nazwisko musi być dłuższe niż 5 znaków i krótsze niż 50")]
	public string Name { get; set; }

	[Required]
	[Range(typeof(bool), "true", "true", ErrorMessage = "Musisz zaakceptować plitykę prywatności")]
	public bool PrivacyAgreed { get; set; }

    public Address Address { get; set; } = new Address();
}

Formularz:

<EditForm Model="Customer" OnValidSubmit="SubmitForm">
    <DataAnnotationsValidator />
    <div>
        <label for="name">Imię i nazwisko:</label>
        <InputText id="name" @bind-Value="Customer.Name" />
        <ValidationMessage For="() => Customer.Name" />
    </div>
    <div>
        <InputCheckbox id="agreement" @bind-Value="Customer.PrivacyAgreed" />
        <label for="agreement">Zgoda na politykę prywatności</label>
        <ValidationMessage For="() => Customer.PrivacyAgreed" />
    </div>

    <div>
        <label for="street">Ulica:</label>
        <InputText id="street" @bind-Value="Customer.Address.StreetName" />
        <ValidationMessage For="() => Customer.Address.StreetName" />
    </div>

    <div>
        <label for="street_no">Nr ulicy:</label>
        <InputText id="street_no" @bind-Value="Customer.Address.StreetNumber" />
        <ValidationMessage For="() => Customer.Address.StreetNumber" />
    </div>

    <div>
        <label for="city">Miasto:</label>
        <InputText id="city" @bind-Value="Customer.Address.City" />
        <ValidationMessage For="() => Customer.Address.City" />
    </div>

    <div>
        <button type="submit">Zapisz</button>
    </div>
</EditForm>

I co? I nie działa…

Okazuje się, że Blazor ma pewne problemy z takimi polami, chociaż być może jest to celowe działanie. Jeszcze dosłownie niedawno trzeba było tworzyć obejścia. Jednak teraz mamy to PRAWIE w standardzie:

  1. Pobierz NuGet: Microsoft.AspNetCore.Components.DataAnnotations.Validation. UWAGA! Na wrzesień 2021 ten pakiet nie jest jeszcze oficjalnie wydany. Jest to prerelease. A więc możliwe, że będziesz musiał zaznaczyć opcję Include Prerelase w NuGet managerze:
  1. W swoim modelu Customer oznacz pole Address atrybutem: ValidateComplexType. Pamiętaj, że jest to składnik pobranego właśnie NuGeta. Jeśli więc masz modele w innym projekcie, musisz tam też pobrać ten pakiet.
  2. W razor zmień DataAnnotationsValidator na ObjectGraphDataAnnotationsValidator

I to na tyle. Teraz wszystko już działa. ObjectGraphAnnotationsValidator sprawdzi walidacje wszystkich zwykłych pól, jak i tych „kompleksowych”.

Wbudowane kontrolki

Powiem jeszcze na koniec o kontrolkach formularzy wbudowanych w Blazor. Oczywiście możemy tworzyć własne jeśli będzie taka potrzeba (czasami jest), jednak do dyspozycji na starcie mamy:

  • InputText
  • InputCheckbox
  • InputDate
  • InputFile
  • InputNumber
  • InputRadio
  • InputRadioGroup
  • InputSelect
  • InputTextArea

To tyle, jeśli chodzi o podstawy walidacji w Blazor. Jeśli masz jakieś pytania lub znalazłeś błąd w artykule, podziel się w komentarzu.

Podziel się artykułem na:
Własna walidacja w .NetCore – czy użytkownik wyraził zgodę na regulamin?

Własna walidacja w .NetCore – czy użytkownik wyraził zgodę na regulamin?

Pokażę Ci jak zrobić własną walidację na bardzo użytecznym przykładzie. Zrobimy mechanizm sprawdzenia, czy użytkownik wyraził zgodę na regulamin. Jeśli nie wiesz, jak działa walidacja w .NetCore, sprawdź najpierw ten artykuł.

Stworzenie własnej walidacji składa się z dwóch kroków.

  • utworzenie własnego atrybutu
  • dodanie sprawdzenia po stronie JavaScript.

Zasadniczo jest to dość proste, ale trzeba pamiętać o pewnej rzeczy… o tym później

Walidacja po stronie klienta

Required nie wystarczy

W .NET można tworzyć własne atrybuty. To wiadomo. Atrybut to po prostu klasa, która dziedziczy po specyficznej klasie i zawiera… konkretne atrybuty 🙂

Z jakiegoś powodu w .NetCore nie ma domyślnie możliwości sprawdzenia, czy checkbox został zaznaczony. Wydawać by się mogło, że wystarczy:

class RegisterUserViewModel
{
	[Required]
	public bool TermsAndConditions { get; set; }
}

Widziałem też takie przykłady:

class RegisterUserViewModel
{
	[Range(typeof(bool), "true", "true")]
	public bool TermsAndConditions { get; set; }
}

niestety z checkboxem i jego wartością jest nieco inaczej. I powinien zostać do tego stworzony nowy atrybut. Rozwiązanie, które tutaj podaję zaproponowałem do Microsoftu. Być może w kolejnych wersjach wdrożą coś analogicznego.

Dopisane: W Blazor można walidować checkbox za pomocą atrybutu Required

Tworzenie własnego atrybutu

Zatem stwórzmy własny atrybut walidacyjny. Takie atrybuty powinny dziedziczyć po klasie ValidationAttribute. Jest to klasa abstrakcyjna, która zawiera pewną wspólną logikę dla wszystkich walidacji.

ValidationAttribute zapewnia walidację po stronie serwera. Jeśli chcesz mieć zapewnioną dodatkowo walidację po stronie klienta, musisz zaimplementować interfejs IClientModelValidator.

IClientModelValidator

Tutaj na chwilę się zatrzymamy. Walidacja po stronie klienta zawsze odbywa się za pomocą JavaScript (lub podobnych). .NetCore to znacznie ułatwia, gdyż wprowadza własny prosty mechanizm. Spójrz na ten prosty formularz:

<form asp-action="Register" method="Post">
	<div class="form-group">
		<label for="email">Podaj swój email:</label>
		<input class="form-control" type="email" asp-for="UserName" />
		<span asp-validation-for="UserName" class="text-danger"></span>
	</div>
</form>

Widzisz tutaj pomocnicze tagi: asp-for i asp-validation-for. W wynikowym HTMLu będzie to wyglądało mniej-więcej tak:

<div class="form-group">
	<label for="email">Podaj swój email:</label>
	<input class="form-control" type="email" data-val="true" data-val-email="The Email field is not a valid e-mail address." data-val-required="The UserName field is required." id="UserName" name="UserName" value="">
	<span class="text-danger field-validation-valid" data-valmsg-for="UserName" data-valmsg-replace="true"></span>
</div>

Jak widzisz, tagi pomocnicze trochę tutaj nagrzebały w kodzie. Nagrzebały pod kątem Microsoftowej biblioteki jQuery Unobtrusive Validation. Ta biblioteka, można powiedzieć, „szuka” atrybutów data-val (skrót od „data validation”), których wartość jest ustawiona na true. Oznacza to, że takie pole podlega walidacji. Następnie tworzone są odpowiednie komunikaty, gdy walidacja się nie powiedzie:

  • data-val-email – komunikat dla atrybutu [EmailAddress]
  • data-val-required – komunikat dla atrybutu [Required]

Te komunikaty są później umieszczane w elemencie, który ma data-valmsg-for i data-valmsg-replace (true).

To się dzieje automatycznie – Microsoft porobił takie mechanizmy. Tutaj wchodzi interfejs IClientModelValidator. Zajmijmy się najpierw nim:

Implementacja IClientModelValidator

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, AllowMultiple = false)]
public class CheckBoxCheckedAttribute : ValidationAttribute, IClientModelValidator
{
	public void AddValidation(ClientModelValidationContext context)
	{
		if (context == null)
		{
			throw new ArgumentNullException(nameof(context));
		}

		MergeAttribute(context.Attributes, "data-val", "true");
		MergeAttribute(context.Attributes, "data-val-chboxreq", "Nie zaznaczyłeś checkboxa!");
	}
	
	private static void MergeAttribute(IDictionary<string, string> attributes, string key, string value)
	{
		if (!attributes.ContainsKey(key))
		{
			attributes.Add(key, value);
		}
	}
}

context.Attributes to słownik, który zawiera atrybuty dodane już do pola

Użycie atrybutu

Utworzyliśmy klasę CheckBoxCheckedAttribute, implementując interfejs IClientModelValidator.
Teraz tak. Tag pomocniczy asp-for sprawdza wszystkie walidacje na danym polu, czyli jeśli mamy model:

class RegisterUserViewModel
{
	[CheckBoxChecked]
	public bool TermsAndConditions { get; set; }
}

wtedy asp-for weźmie wszystkie klasy atrybutów dla danego pola, które implementują interfejs IClientModelValidator i uruchomi metodę AddValidation.

To, co „zwróci” ta metoda, będzie dopisane do pola . Jak widzisz, zadaniem metody AddValidation jest właściwie tylko odpowiednie dodanie atrybutów do pola input, a więc:

  • data-val usatwione na true
  • data-val-chboxreq – którego wartością jest odpowiedni komunikat. Czyli teraz dopiszemy checkboxa w formularzu:
<form asp-action="Register" method="Post">
	<div class="form-group">
	    <div class="form-check">
	        <input type="checkbox" class="form-check-input" asp-for="TermsAndConditions" />
	        <label class="form-check-label" asp-for="TermsAndConditions">Akceptuję warunki regulaminu(acceptLabel)</label>
	    </div>
	    <span asp-validation-for="TermsAndConditions" class="text-danger"></span>
	</div>	
</form>

A ostatecznie pole input będzie wyglądało podobnie do tego:

<input type="checkbox" class="form-check-input" data-val="true" data-val-chboxreq="Nie zaznaczyłeś checkboxa!" />

Zauważ, że klasa CheckBoxCheckedAttribute dodała w metodzie AddValidation te dwa atrybuty (data-val i data-val-chboxreq), które są wymagane do poprawnego działania walidacji po stronie klienta.

Oczywiście nie musisz pisać nazywać tego data-val-chboxreq. Ważne, żeby było data-val- i w miarę unikalna, jednoznaczna końcówka. Równie dobrze mógłbyś napisać: data-val-checkbox-zaznaczony i sprawdzać później wartość tego atrybutu.

Czyli podsumowując tę część:

Tag asp-for powoduje wywołanie metody AddValidation z interfejsu IClientModelValidator, która jest odpowiedzialna za dodanie atrybutów walidacyjnych do pola . Oczywiście nic nie stoi na przeszkodzie, żebyś dodał tam jeszcze inne atrybuty. Pamiętaj, że musisz atrybut implementujący IClientModelValidator dodać do odpowiedniego pola w swoim modelu (viewmodelu).

OK, skoro już wiesz do czego służy IClientModelValidator, to teraz polecimy dalej. Pierwsza część walidacji jest zrobiona. Teraz druga. JavaScript.

Walidacja w JavaScript

Jak już mówiłem, Microsoft ma tą swoją bibliotekę jQuery Unobtrusive Validation, która działa razem z jQuery Validation.

Ten fragment kodu możesz dodać albo tylko na stronie z formularzem, albo lepiej – w widoku _ValidationScriptsPartial. Wtedy będzie dostępny zawsze, gdy dodajesz jakąś walidację:

<script>
	$.validator.addMethod("chboxreq", function (value, element, param) {
	    if (element.type != "checkbox")
	        return false;
	
	    return element.checked;
	});
	
	$.validator.unobtrusive.adapters.addBool("chboxreq");	
</script>
  • najpierw dodajemy metodę do walidatora jQuery.
  • metoda ma zostać uruchomiona, gdy trafi na atrybut data-val-chboxreq. Zauważ, że w parametrze podajemy tylko tę ostatnią część – chboxreq.
  • funkcja przyjmuje 3 parametry – wartość (atrybut value elementu HTML), element – czyli element HTML i param – dodatkowy parametr.

Jak widzisz funkcja walidująca jest bardzo prosta – najpierw jest sprawdzenie, czy element jest checkboxem, potem funkcja sprawdza, czy checkbox jest zaznaczony.

Aby to wszystko zadziałało, trzeba jeszcze dodać ten „adapter”, używając $.validator.unobtrusive.adapters
Nie będę tutaj opisywał walidatorów w jQuery, bo:

  1. Mógłbym zaciemnić obraz
  2. Nie znam się na nich
  3. Możesz sam wyszukać w dokumentacjach, jeśli będziesz chciał robić jakieś dziwne rzeczy.

I teraz pewna uwaga, o której pisałem na początku. Ten cały kod nie chciał mi kiedyś zadziałać i męczyłem się chyba 2 godziny zanim wpadłem dlaczego (oczywiście nie byłbym sobą, gdybym nie zgłosił tego do Microsoftu :)). Ten kod nie zadziała z poziomu TypeScript. Nie wiem, czemu. Po prostu MUSI być bezpośrednio w JavaScript.

Walidacja po stronie serwera

Aby wszystko zadziałało poprawnie, musimy dodać jeszcze walidację po stronie serwera. Zauważ, że walidacja kliencka została zrobiona w JavaScript. Walidacja po stronie serwera odbywa się już bezpośrednio w klasie CheckBoxCheckedAttribute. Wystarczy przesłonić metodę IsValid:

public override bool IsValid(object value)
{
	return (value is bool && (bool)value);
}

To tyle.

Komunikaty o błędach

Teraz mała uwaga na koniec. Klasa ValidationAttribute zawiera już pewne mechanizmy, które pomagają.
Jak już wiesz, każdy atrybut walidacyjny z .NET ma pewne właściwości: ErrorMessage, ErrorMessageResourceName, ErrorMessageResourceType… Bierze się to właśnie z klasy ValidationAttribute. Zatem klasa CheckBoxCheckedAttribute też ma te właściwości. Więc możesz napisać:

class RegisterUserViewModel
{
	[CheckBoxChecked(ErrorMessage = "Musisz zaakceptować warunki")]
	public bool TermsAndConditions { get; set; }
}

lub (w aplikacji wielojęzycznej):

class RegisterUserViewModel
{
	[CheckBoxChecked(ErrorMessageResourceName = nameof(LangRes.ValidationError_AcceptTerms), ErrorMessageResourceType = typeof(LangRes))]
	public bool TermsAndConditions { get; set; }
}

Jeśli nie wiesz, jak zrobić lokalizację do swojego programu, sprawdź ten artykuł

wOczywiście musisz pamiętać, żeby w metodzie AddValidation zwrócić poprawny komunikat. I teraz uwaga. Niezależnie od tego, czy posłużysz się ErrorMessage, czy ErrorMessageResourceName i ErrorMessageResourceType, klasa ValidationAttribute przechowa już dla Ciebie konkretny komunikat. Trzyma to we właściwości ErrorMessageString, a więc zamiast komunikatu bezpośrednio w kodzie powinieneś się posłużyć tą właściwością:

public void AddValidation(ClientModelValidationContext context)
{
	if (context == null)
	{
		throw new ArgumentNullException(nameof(context));
	}

	MergeAttribute(context.Attributes, "data-val", "true");
	MergeAttribute(context.Attributes, "data-val-chboxreq", ErrorMessageString);
}

Przejdźmy na kolejny poziom

Jeśli nie do końca rozumiesz ten artykuł albo chcesz czegoś więcej, przy okazji napisałem jak zrobić walidator do sprawdzania rozmiaru przesyłanego pliku. Zachęcam do zapoznania się z tym artykułem o przesyłaniu plików w Razor, Blazor i WebApi.

To tyle.
Jeśli masz jakieś wątpliwości lub znalazłeś błąd w artykule, podziel się w komentarzu.

Podziel się artykułem na:
Walidacja danych w asp mvc .netcore 3

Walidacja danych w asp mvc .netcore 3

Wstęp

Ten artykuł opisuje czym jest walidacja danych i jak ją zastosować poprawnie w .Net. Jeśli trafiłeś na ten artykuł, szukając, jak w razor zrobić wymaganego checkboxa, to sprawdź ten artykuł.

Na szybko

Atrybuty walidacyjne w modelu:

  • Porównanie dwóch pól – [Compare(„InnePole”)]
  • Maksymalna ilość znaków – [MaxLength(50)]
  • Minimalna ilość znaków – [MinLength(10)]
  • Sprawdzenie wieku – [Range(Minimum = 18)]
  • Pole wymagane – [Required]

Trochę teorii

Czym jest walidacja danych

Walidacja to po prostu sprawdzenie poprawności danych podanych przez użytkownika. Walidacja jest ściśle powiązana z jakimś formularzem, który użytkownik wypełnia. Może być to po prostu rejestracja nowego konta. Wtedy taka walidacja polega na sprawdzeniu, czy użytkownik wypełnił wszystkie wymagane pola, czy jego hasło i nazwa użytkownika spełniają nasze założenia (np. musi być wielka litera albo nazwa użytkownika musi być adresem e-mail) no i oczywiście, czy zaakceptował naszą politykę prywatności i regulamin 🙂

Walidacja danych jest potrzebna żeby nie dopuścić do sytuacji, w której w bazie danych znajdują się głupoty. Zapewnia też większą spójność danych, a także chroni przed pewnymi atakami.

Także każdy formularz wypełniany przez użytkownika powinien być zwalidowany. Pamiętaj, że użytkownik może wpisać wszystko, co mu się podoba. To na Tobie w pewnym sensie leży odpowiedzialność sprawdzenia, czy to co wpisał ma sens.

Są dwa „tryby” walidacji. Po stronie serwera i po stronie klienta.

Walidacja danych po stronie klienta

Walidacja po stronie klienta następuje przed wysłaniem danych do serwera. Czyli np. w przeglądarce internetowej lub aplikacji. Mamy formularz rejestracji, użytkownik wciska guzik „Rejestruj” i w tym momencie musimy sprawdzić poprawność danych. Jeśli dane nie są poprawne, wtedy pokazujemy komunikat. Jeśli uznamy, że są ok – wysyłamy je do serwera. W aplikacjach internetowych takim sprawdzeniem zajmuje się np. JavaScript. Czyli musimy napisać odpowiedni kod w tym… bleee… języku, który sprawdzi nam poprawność wpisanych danych.

Walidacja danych po stronie serwera

No i tutaj mamy to, co backendowcy lubią najbardziej. Czyli dostajemy dane od klienta i za chwilę wrzucimy je do bazy danych. Ale, ale… Jeden z moich profesorów na studiach mawiał:

„Kto szybko daje, ten dwa razy daje”

Zatem nie możemy do końca ufać danym, które otrzymaliśmy. Musimy sprawdzić je drugi raz. I tu wchodzi walidacja danych po stronie serwera – dopiero jeśli tutaj upewnimy się, że wszystko jest ok, możemy wbić dane do bazy lub zrobić z nimi coś innego.

Czyli krótko podsumowując, proces powinien wyglądać tak:

  • Użytkownik wklepuje dane i wciska guzik „OK”
  • Następuje walidacja po stronie klienta (JavaScript)
  • Jeśli walidacja jest ok, to wysyłamy dane do serwera, jeśli nie, to mówimy użytkownikowi, że coś zje… źle wprowadził
  • Po stronie serwera odbieramy dane i SPRAWDZAMY JE JESZCZE RAZ
  • Jeśli dane są ok, to wbijamy je do bazy danych i odpowiadamy klientowi: „Ok, wszystko się udało”. Jeśli dane są złe, odpowiadamy: „Hola hola, coś tu źle wprowadziłeś”.

Taka podwójna walidacja danych nie jest w prawdzie konieczna. Możesz tworzyć systemy jak Ci się podoba. Ale jeśli chcesz ograniczyć dziury, błędy i podatności na ataki w swoim systemie, podwójna walidacja jest obowiązkiem. Pamiętaj, że nie zawsze dostaniesz dane z własnej aplikacji. Czasem ktoś po prostu wyśle „na pałę”. Dlatego walidacja po stronie serwera jest konieczna. Nie opłaca się też nikomu wysyłać na serwer danych, które wiadomo, że są niepoprawne. Bardziej opłaca się sprawdzić je po stronie klienta przed wysłaniem.

Trochę praktyki

Walidacja danych w .NetCore

Na szczęście .NetCore ma pewne mechanizmy do walidacji danych, które teraz pokrótce Ci pokażę. Walidacja w .NetCore składa się z trzech etapów:

  • odpowiednie przygotowanie modelu
  • wywołanie walidacji po stronie klienta
  • wywołanie walidacji po stronie serwera

Przygotowanie modelu

Załóżmy, że mamy klasę, która przechowuje dane rejestracyjne użytkownika (model), możemy jej poszczególne właściwości ubrać w konkretne atrybuty. To jest tzw. „annotation validation„, czyli
walidacja obsługiwana za pomocą „adnotacji”.

Spójrzmy na tę „gołą” klasę:

class RegisterUserViewModel
{
    public string UserName { get; set; }
	public string Password { get; set; }
	public string RepeatedPassword { get; set; }
}

Musimy się upewnić, że:

  • wypełniona jest nazwa użytkownika
  • wypełnione jest hasło
  • podane hasła są identyczne

Za pierwsze 2 założenia odpowiada atrybut Required

//using System.ComponentModel.DataAnnotations;

class RegisterUserViewModel
{
    [Required]
	public string UserName { get; set; }
	
	[Required]
	public string Password { get; set; }
	
	[Required]
	public string RepeatedPassword { get; set; }
}

Teraz, gdy uruchomimy proces walidacji, sprawdzi on te pola. To wygląda mniej więcej tak:

Walidator: Cześć obiekcie, jakie masz pola?
Obiekt: No hej, mam UserName, Password i PasswordRepeated
Walidator: Ok, jakie masz atrybuty walidacyjne na polu UserName?
Obiekt: Required
Walidator: Hej wielki walidatorze Required! Czy pole UserName w danym obiekcie spełnia Twoje założenia?

Wtedy walidator Required sprawdza. Taki walidator mógłby wyglądać w taki sposób (zakładając, że byłby tylko dla stringa, ale jest dla innych typów też):

return !string.IsNullOrWhitespace(value);

Walidator zrobi analogiczne sprawdzenie przy pozostałych polach.

Ok, teraz w drugim kroku chcemy, żeby pole UserName było poprawnym adresem e-mail. Można się do tego posłużyć atrybutem…. EmailAddress:

class RegisterUserViewModel
{
    [Required]
	[EmailAddress]
	public string UserName { get; set; }
	
	[Required]
	public string Password { get; set; }
	
	[Required]
	public string RepeatedPassword { get; set; }
}

(jak widzisz, jedno pole może mieć wiele atrybutów walidacyjnych)

Walidacja adresu e-mail

Poświęciłem na to osobny akapit (chociaż zastanawiam się nad artykułem). Zasadniczo wiemy, jakie są poprawne adresy e-mail:

  • a@b.c
  • jkowalski@gmail.com
  • user@serwer.poczta.pl

Natomiast niepoprawnymi mogą być:

  • @@@@
  • jkowalski(at)gmail.com
  • pitu-pitu@pl

Po staremu

Do wersji .NetFramework 4.7.2 atrybut EmailAddress działał tak, że sprawdzał adres e-mail za pomocą wyrażeń regularnych. Wyrażenia regularne mają jedną mała wadę – są stosunkowo drogie, jeśli chodzi o zasoby – wolne. To jest pewna furtka dla ataku DoS (denial of service). Atak ten polega na przeciążeniu serwera, żeby nie służył już innym użytkownikom.

Okazało się, że duża ilość stringów, a co gorsza dużych stringów, przepychana przez ten walidator, może właśnie mieć takie działanie. Wyrażenia regularne żrą sporo, więc duża ilość dużych stringów może
zablokować serwer. Dlatego Microsoft zmienił trochę sposób działania tego walidatora.

Po nowemu

każdy string, który ma tylko jedną małpę i nie jest ona ani na końcu, ani na początku jest poprawnym adresem e-mail, czyli:

  • a@b – poprawny
  • ja@cie.krece – poprawny
  • @blbla – niepoprawny
  • abc@ – niepoprawny

No i tutaj właśnie zapala się lampka: „a@b” ma być poprawnym e-mailem? No właśnie nie do końca. Ale to sprawdzenie miało być szybkie. Jest szybkie. Ale skoro jest szybkie, to jest też proste. Zasadniczo sprawdza czy podany string MOŻE być poprawnym adresem e-mail.

Można to jednak zmienić. W pliku appsettings trzeba dodać taką linijkę:

<add key="dataAnnotations:dataTypeAttribute:disableRegEx" value="false"/>

Wtedy sprawdzenie adresu e-mail będzie po staremu za pomocą wyrażeń regularnych. Stosuj tylko wtedy, kiedy wiesz co robisz. W gruncie rzeczy to użytkownik powinien podać Ci swój właściwy adres e-mail.

Sprawdzenie hasła

Ok, skoro załatwiliśmy już e-mail, sprawdźmy teraz czy użytkownik podał dwa razy to samo hasło, czy może coś znowu schrzanił:

class RegisterUserViewModel
{
    [Required]
	[EmailAddress]
	public string UserName { get; set; }
	
	[Required]
	public string Password { get; set; }
	
	[Required]
	[Compare("Password")]
	public string RepeatedPassword { get; set; }
}

Jak widzisz odpowiada za to atrybut Compare. W parametrze (otherProperty) podajemy nazwę pola z tej klasy, z którą ma zostać to pole porównane. Tutaj „Password”. Czyli porównamy pole „RepeatedPassword” z polem „Password”. Tylko tutaj też uwaga. Jeśli chodzi o porównanie dwóch stringów, to wykorzystywana jest tu metoda Equals z klasy string. Ta metoda nie jest wrażliwa na ustawienia językowe. Tzn. że w pewnych językach i w pewnych warunkach może stwierdzić, że dwa różne stringi są takie same lub dwa takie same stringi są różne.

Sprawdzenie wieku

Teraz dodatkowo możemy upewnić się, czy użytkownik jest pełnoletni. Do tego może posłużyć atrybut Range, który oznacza, że wartość powinna być z konkretnego zakresu. A więc użytkownik może na przykład podać swój wiek:

class RegisterUserViewModel
{
    [Required]
	[EmailAddress]
	public string UserName { get; set; }
	
	[Required]
	public string Password { get; set; }
	
	[Required]
	[Compare("Password")]
	public string RepeatedPassword { get; set; }
	
	[Required]
	[Range(18, 50)]
	public int Age { get; set; }
}

W powyższym przykładzie sprawdzamy, czy wiek użytkownika jest między 18 a 50 lat. Atrybut Range musi być zastosowany z typem int lub datą. Przy czym stosowanie go z datą nie ma raczej uzasadnienia,
ponieważ musielibyśmy wklepać ją na sztywno, co może przysporzyć sporych problemów w przyszłości. Dlatego stosuj Range raczej do zmiennych int. Możesz podać też samo minimum lub maksimum:

class RegisterUserViewModel
{
    [Required]
	[EmailAddress]
	public string UserName { get; set; }
	
	[Required]
	public string Password { get; set; }
	
	[Required]
	[Compare("Password")]
	public string RepeatedPassword { get; set; }
	
	[Required]
	[Range(Minimum = 18)]
	public int Age { get; set; }
}

(analogicznie istnieje właściwość Maximum)

Pewnie chciałoby się zapytać – jak atrybut range traktuje swoje minimum i maksimum. Czy od minimum, czy minimum jest wykluczone? Możesz sam to sprawdzić, pisząc odpowiedni kod, do czego Cię zachęcam 🙂 Jeśli jednak trafiłeś tutaj tylko po to, żeby dowiedzieć się tej konkretnie rzeczy, to już mówię – minimum i maksimum są dopuszczone. Czyli minimum jest pierwszą liczbą, która przechodzi sprawdzenie, a maksimum – ostatnią.

Jest jeszcze kilka atrybutów, które mogą Ci się przydać. Każdy z nich jest opisany na stronach Microsoftu:

https://docs.microsoft.com/pl-pl/dotnet/api/system.componentmodel.dataannotations.validationattribute?view=net-5.0

Walidacja danych po stronie serwera – jak?

Generalnie w .NetCore nie ma chyba nic prostszego. Robi się to w kontrolerze (to jedna z możliwości) – niezależnie od tego, czy pracujesz nad WebApi, czy nad stroną (Razor Views). Działa to tak:

//przykład dla RazorView, dla WebAPI jest to analogicznie
public class MyController: Controller
{
	[HttpPost]
	public async Task<IActionResult> RegisterUser(RegisterUserViewModel model)
	{
	    if(!ModelState.IsValid)
			return BadRequest();
	}
}

I w zasadzie to wszystko. Kontroler ma taki obiekt ModelState, który przechowuje informacje na temat poprawności przekazanego modelu. Właściwość IsValid określa, czy model jest poprawnie wypełniony (true), czy nie (false). Możesz też poznać wszystkie błędy obecne w modelu, ale uwaga. Na tym etapie (tuż przed dodaniem na serwer) raczej nie informowałbym użytkownika o szczegółowych błędach (chociaż to zależy od Ciebie – Ty wiesz co klient usiłuje zrobić i jak istotne i tajne powinny być dane w każdym przypadku).

UWAGA! .NET8 automatycznie sprawdza poprawność modelu w klasie kontrolera dziedziczącej po ControllerBase.

Jesteśmy w końcu na serwerze, a ktoś te dane na serwer musiał wysłać. Więc albo zrobił to źle klient – wtedy musimy poprawić klienta (bo np. zabrakło walidacji po stronie klienta), albo ktoś próbuje nam w jakiś sposób zaszkodzić. Przy WebAPI jest jeszcze inna opcja – ktoś po prostu tworzy aplikację i nie poradził sobie z poprawnym wysłaniem danych… No cóż… Musi doczytać w dokumentacji.

Jeśli chcesz dowiedzieć się, jak automatycznie walidować ModelState, sprawdź ten artykuł.

Walidacja w RazorPages wygląda też analogicznie. Tutaj obiekt ModelState też istnieje z tym, że w klasie PageModel, np:

public class MyPage: PageModel
{
	public async Task<IActionResult> OnPostAsync()
    {
        if (!ModelState.IsValid)
			return BadRequest();
			
        return Page();
    }
}

Walidacja danych po stronie klienta

Tutaj kwestia jest też zasadniczo prosta, jeśli chodzi przynajmniej o walidację typową – dostępną w .NetCore.
Gdzieś tam na początku mówiłem, że walidacja po stronie klienta wymaga JavaScriptu. I to niestety prawda. Na szczęście Microsoft stworzył taką bibliotekę jQuery unobtrusive validation.
Ona jest stworzona w taki sposób, żeby współdziałać z widokami dzięki TagHelperom.

Jeśli nie wiesz, czym są TagHelpers, to dosłownie „Tagi pomocnicze” – tagi w sensie tagi html. Przeczytaj ten artykuł, żeby dowiedzieć się więcej.

Przygotowanie

UWAGA! Żeby to zadziałało, musisz dodać do strony 3 skrypty:

<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.1/jquery.validate.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"></script>

(pamiętaj o odpowiednich wersjach bibliotek :))

Pierwszy z nich masz raczej na „dzień dobry” w widoku _Layout.cshtml, pozostałe w _ValidationScriptsPartial.cshtml. Więc domyślnie wystarczyłoby, żebyś dodał na początku strony:

<partial name="_ValidationScriptsPartial" />

Walidacja formularza

Na początek przekazać do widoku konkretny model:

@model RegisterUserViewModel

Następnie musimy zwalidować konkretne pola w formularzu, np:

<form asp-action="Register" method="Post">
	<div class="form-group">
		<label for="email">Podaj swój email:</label>
		<input class="form-control" type="email" asp-for="UserName" />
		<span asp-validation-for="UserName" class="text-danger"></span>
	</div>
</form>

Powyżej masz fragment formularza rejestracyjnego. Jeśli nie znasz bootstrap, to tłumaczę pokrótce:
pierwszy div – tworzy „grupę” kontrolek – w tym przypadku label, input i jakiś span (Etykietę, pole do wprowadzenia danych i jakiś span).

Etykieta to po prostu tekst zachęty: „Podaj swój email:”.

asp-for

Teraz pole do wprowadzenia danych – input. Tutaj pojawiła się nowa rzecz – tag pomocniczy „asp-for”. Jeśli wpiszesz sobie asp-for, to Intellisense pokaże Ci wszystkie pola w Twoim modelu. Skąd wie, co jest Twoim modelem? No przecież mu pokazałeś na początku widoku:

@model RegisterUserViewModel

asp-for tworzy pewnego rodzaju powiązanie między kontrolką HTML, a polem w Twoim modelu. Czyli to, co użytkownik wpisze do tej kontrolki, AUTOMAGICZNIE trafi do pola UserName w Twoim modelu. Niczego nie musisz przepisywać. No złoto…

Ale to nie wszytko. Zapewnia to też walidację. Czyli automagicznie zostanie sprawdzone Twoje pole pod kątem poprawności (w tym wypadku Required i EmailAddress).

Komunikaty o błędach

Jeśli walidacja przejdzie, to formularz zostanie wysłany, jeśli nie, no to jakoś użytkownikowi wypadałoby powiedzieć, że znowu coś schrzanił. I od tego mamy ten tajemniczy SPAN.

Zauważ na początek jedną rzecz – span ma tag otwarcia i zamknięcia. Nie możesz napisać tak:

<span asp-validation-for... />

bez tagu zamknięcia coś może nie zadziałać (może być różnie na różnych wersjach). Więc musi być tag zamknięcia.

Ten SPAN wyświetli informacje, jeśli pole zostanie błędnie wypełnione (nie przejdzie walidacji). Tag „asp-validation-for” mówi po prostu dla jakiego pola ma pokazać się informacja o błędzie. Żeby nie zrobić użytkownikowi mindfucka, podaliśmy tutaj pole UserName. Klasa text-danger to jest po prostu bootstrapowa klasa, która powoduje, że wyświetlony tekst będzie w kolorze czerwonym.


Czyli podsumowując:

  • label – etykieta dla pola, mówiąca użytkownikowi co ma wpisać
  • input z tagiem asp-for – pole do wpisania
  • span z tagiem asp-validation-for – informacja w przypadku błędu.

No właśnie, ale jaka informacja? .NetCore pokaże po prostu domyślne info takie, jakie zaprogramowali w Microsofcie. Ale MOŻESZ ustawić własne powiadomienia. Wróćmy do modelu:

class RegisterUserViewModel
{
    [Required(ErrorMessage="Nie, nie, nie. To pole MUSISZ wypełnić")]
	[EmailAddress(ErrorMessage="A takiego! Nie podałeś prawidłowego e-maila")]
	public string UserName { get; set; }
	
	[Required]
	public string Password { get; set; }
	
	[Required]
	[Compare("Password")]
	public string RepeatedPassword { get; set; }
	
	[Required]
	[Range(Minimum = 18)]
	public int Age { get; set; }
}

WSZYSTKIE atrybuty walidacyjne mają właściwość ErrorMessage, do której możesz wpisać komunikat błędu. Komunikaty mogą być też lokalizowane, np:

[Required(ErrorMessageResourceName = nameof(LangRes.Validation_RequiredField), ErrorMessageResourceType = typeof(LangRes))]

I teraz tak. ErrorMessageResourceType to jest Twój typ z zasobami językowymi, który posiada klucz Validation_RequiredField – ten klucz, który używasz.
Jeśli nie wiesz, jak tworzyć wersje językowe, przeczytaj ten artykuł.

Jest jeszcze jeden sposób na pokazanie błędów w widoku. Możesz pokazać wszystkie błędy jeden po drugim, zamiast konkretnych błędów pod konkretnymi kontrolkami. Wystarczy zrobić to:

<form asp-action="Register" method="Post">
	<div asp-validation-summary="All"></div>
	<div class="form-group">
		<label for="email">Podaj swój email:</label>
		<input class="form-control" type="email" asp-for="UserName" />
		<span asp-validation-for="UserName" class="text-danger"></span>
	</div>
</form>

Wtedy komunikaty o błędach pojawią się na tym dodatkowym divie w postaci listy.

To właściwie już tyle jeśli chodzi o podstawy walidacji w .NetCore.
Gratulacje, dotarłeś do końca 🙂

Jeśli masz jakieś wątpliwości lub znalazłeś błąd w artykule, podziel się w komentarzu.

Akceptacja regulaminu – wymagany checkbox

W każdym portalu, wymagana jest akceptacja regulaminu. I sprawa wydaje się prosta, ale z jakiegoś powodu nie jest (osobiście zgłosiłem to do MS wraz z rozwiązaniem). Przeczytaj ten artykuł, żeby dowiedzieć się, jak wymusić zaznaczenie checkboxa przez użytkownika w .NetCore

Podziel się artykułem na: