Wprowadzenie
Jednym ze standardowych elementów prawie każdej aplikacji jest walidacja danych. Również Blazor ma wbudowane mechanizmy do walidacji danych, na przykład z wykorzystaniem atrybutów DataAnnotation. W tym wpisie będę chciał pokazać Ci, w jaki sposób wyświetlić w Blazor błędy walidacji pochodzące z Web API.
Gdzie walidować dane?
Na początku chciałbym się zastanowić nad tym, gdzie powinniśmy zrealizować walidację danych. W przypadku Blazora (czy innej dowolnej technologii frontendowej) możemy to zrobić w dwóch miejscach: po stronie klienta (Blazor) oraz po stronie serwera (Web API).
Niezależnie od tego, czy mamy walidację po stronie klienckiej, to zawsze musi być ona po stronie serwera. Użytkownik naszej aplikacji nie musi przecież korzystać z naszego interfejsu użytkownika. Może sam próbować wysyłać dane do Web API, korzystając na przykład z Postmana lub innego tego typu narzędzia, więc walidacja po stronie serwera zawsze musi występować. Niestety nie raz widziałem aplikacje, gdzie cała logika była po stronie klienta, a Web API przyjmowało wszystkie dane bez walidacji.
Związku z tym, że walidację mamy zawsze po stronie serwera, to osobiście na ogół rezygnuję z walidacji po stronie klienta. Zauważyłem, że tylko kwestią czasu jest sytuacja, w której reguły walidacji rozjeżdżają się między tym co jest po stronie Web API, a klientem. Szczególnie gdy niezależne osoby realizują frontend i backend. Takie podejście ma też swoje minusy, ale według mnie na ogół sprawdza się najlepiej.
Web API zwrócenie błędów walidacji
Kilka razy już poruszałem ten temat na blogu. Osobiście na ogół z każdej akcji mojego Web API zwracam instancję klasy Result. Opakowuje ona właściwą odpowiedz w informację, czy wywołanie się udało, czy nie. W przypadku błędu w odpowiedzi znajduje się szczegółowa informacja o nim. W przykładzie do tego wpisu użyję takiej klasy:
public class Result | |
{ | |
public bool Success { get; set; } | |
public IEnumerable<ErrorMessage> Errors { get; set; } | |
public static Result<T> Ok<T>(T value) | |
{ | |
return new Result<T>() | |
{ | |
Success = true, | |
Value = value | |
}; | |
} | |
public static Result<T> Failure<T>(string message) | |
{ | |
var result = new Result<T>(); | |
result.Success = false; | |
result.Errors = new List<ErrorMessage>() | |
{ | |
new ErrorMessage() | |
{ | |
Message = message | |
} | |
}; | |
return result; | |
} | |
public static Result<T> Failure<T>(IEnumerable<ErrorMessage> messages) | |
{ | |
var result = new Result<T>(); | |
result.Success = false; | |
result.Errors = messages; | |
return result; | |
} | |
} | |
public class Result<T> : Result | |
{ | |
public T Value { get; set; } | |
} | |
public class ErrorMessage | |
{ | |
public string PropertyName { get; set; } | |
public string Message { get; set; } | |
} |
Do właściwości Errors będą trafiać nam błędy walidacji, które następnie będziemy chcieli wyświetlić w widoku. Właściwość PropertyName z ErrorMessage posłuży nam później do wyświetlenia wiadomości przy kontrolce, która jest powiązana z daną właściwością.
Po stronie Web API przygotowałem bardzo prostą akcję, symulującą dodanie nowego produktu. W tym celu utworzyłem prosty obiekt komendy:
public class CreateProductCommand | |
{ | |
public string Name { get; set; } | |
} |
W kontrolerze do walidacji komendy użyłem Fluent Validation:
[ApiController] | |
public class CreateProduct : Controller | |
{ | |
private readonly IValidator<CreateProductCommand> _validator; | |
public CreateProduct(IValidator<CreateProductCommand> validator) | |
{ | |
_validator = validator; | |
} | |
[Route("api/products")] | |
public async Task<ActionResult> Create(CreateProductCommand command) | |
{ | |
var validationResult = await _validator.ValidateAsync(command); | |
if (validationResult.IsValid == false) | |
{ | |
return BadRequest(validationResult.ToResult()); | |
} | |
return Ok(Result.Ok(Guid.NewGuid())); | |
} | |
public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand> | |
{ | |
public CreateProductCommandValidator() | |
{ | |
RuleFor(c => c.Name) | |
.NotEmpty(); | |
} | |
} | |
} |
Kontroler ma jedną akcję, która w sumie tylko waliduje przekazany obiekt komendy i na podstawie tego zwraca informacje.
Blazor wywołanie Web API
Zanim przejdę jeszcze do samej walidacji, zobaczymy jeszcze, w jaki sposób wywołuje Web API z poziomu Blazora. Wykorzystuję tutaj własny interfejs IHttpService, w którym opakowuję cały kod związany z wywołaniem Web API (w tym na przykład ustawianie nagłówków z tokenem JWT):
public interface IHttpService | |
{ | |
Task<Result<T>> Get<T>(string path, CancellationToken cancellationToken); | |
Task<Result<T>> Post<T>(string path, object data, CancellationToken cancellationToken); | |
Task<Result<T>> Put<T>(string path, object data, CancellationToken cancellationToken); | |
Task<Result> Delete<T>(string path, CancellationToken cancellationToken); | |
} |
Usługa zawiera metody dla każdego typu żądania, jakie obsługuje w aplikacji. Zwróć uwagę, że metody zwracają obiekt klasy Result. Dzięki czemu później w widokach będę mógł łatwo sprawdzić, czy operacja się powiodła i na podstawie tego odpowiednio pokazać użytkownikowi komunikat z informacją.
Natomiast implementacja wygląda tak:
public class HttpService : IHttpService | |
{ | |
private readonly string _baseAddress; | |
public HttpService(IWebAssemblyHostEnvironment hostEnvironment) | |
{ | |
_baseAddress = hostEnvironment.BaseAddress; | |
} | |
public async Task<Result<T>> Get<T>(string path, CancellationToken cancellationToken) | |
{ | |
try | |
{ | |
return await CreateRequest(path) | |
.GetJsonAsync<Result<T>>(cancellationToken); | |
} | |
catch (FlurlHttpException ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
catch (Exception ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
} | |
public async Task<Result<T>> Post<T>(string path, object data, CancellationToken cancellationToken) | |
{ | |
try | |
{ | |
return await CreateRequest(path) | |
.PostJsonAsync(data, cancellationToken) | |
.ReceiveJson<Result<T>>(); | |
} | |
catch (FlurlHttpException ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
catch (Exception ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
} | |
public async Task<Result<T>> Put<T>(string path, object data, CancellationToken cancellationToken) | |
{ | |
try | |
{ | |
return await CreateRequest(path) | |
.PutJsonAsync(data, cancellationToken) | |
.ReceiveJson<Result<T>>(); | |
} | |
catch (FlurlHttpException ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
catch (Exception ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
} | |
public async Task<Result> Delete<T>(string path, CancellationToken cancellationToken) | |
{ | |
try | |
{ | |
return await CreateRequest(path) | |
.DeleteAsync(cancellationToken) | |
.ReceiveJson<Result>(); | |
} | |
catch (FlurlHttpException ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
catch (Exception ex) | |
{ | |
return await HandleException<T>(ex); | |
} | |
} | |
private IFlurlRequest CreateRequest(string path) | |
{ | |
var url = _baseAddress | |
.AppendPathSegment(path); | |
return new FlurlRequest(url); | |
} | |
private static async Task<Result<T>> HandleException<T>(FlurlHttpException ex) | |
{ | |
var result = await ex.GetResponseJsonAsync<Result>(); | |
if (result != null && result.Success == false) | |
{ | |
return Result.Failure<T>(result.Errors); | |
} | |
return await HandleException<T>(ex as Exception); | |
} | |
public static Task<Result<T>> HandleException<T>(Exception ex) | |
{ | |
return Task.FromResult(Result.Failure<T>("Przepraszamy nastąpił błąd...")); | |
} | |
} |
W implementacji wykorzystałem bibliotekę Flurl. Każde z żądań opakowane jest w try/catch, który odpowiednio później utworzy instancje klasy Result, nawet w sytuacji, gdy nastąpił błąd. Klasę tę z czasem można rozbudowywać o kolejne elementy, jak na przykład ponawianie operacji z wykorzystaniem biblioteki Polly.
Blazor walidacja
Jak widać powyżej, Web API zwraca nam informacje o operacji w formie obiektu Result. Dlatego po stronie widoków chcemy tak przygotować kod, aby w łatwy sposób wyświetlać błędy walidacji na podstawie obiektu Result. W tym celu przygotuję specjalny komponent (o nazwie FormValidator), który będzie odpowiedzialny za przekopiowanie błędów z obiektu Result do formularza.
W implementacji komponentu FormValidator użyję dwóch typów z Blazora:
- EditContext – jest to kontekst z formularza (później będziemy nasz komponent bindować z formularzem), który posłuży nam do powiadomienia formularza, że zmieniły się błędy walidacji
- ValidationMessageStore – jak sama nazwa mówi, ta klasa posłuży nam do przechowywania błędów walidacji, instancja tej klasy jest tworzona na podstawie EditContext
FormValidator będzie miał jeszcze dwie właściwości do wyświetlania wiadomości. Właściwość Message określi treść wiadomości, natomiast właściwość Type – typ (Success/Error). Kod C# komponentu wygląda tak:
public partial class FormValidator | |
{ | |
[CascadingParameter] | |
public EditContext CurrentEditContext { get; set; } | |
protected ValidationMessageStore ValidationMessageStore { get; set; } | |
public string Message { get; set; } | |
public MessageType Type { get; set; } | |
protected override void OnInitialized() | |
{ | |
base.OnInitialized(); | |
if (this.CurrentEditContext == null) | |
{ | |
throw new InvalidOperationException(); | |
} | |
ValidationMessageStore = new ValidationMessageStore(CurrentEditContext); | |
} | |
public void ShowError(Result result, object model) | |
{ | |
ClearMessages(false); | |
if (result.Success == false) | |
{ | |
ShowMessage("Błąd w formularzu", MessageType.Error); | |
ValidationMessageStore.Clear(); | |
foreach (var error in result.Errors.Where(e => string.IsNullOrEmpty(e.PropertyName) == false)) | |
{ | |
var fieldIdentifier = new FieldIdentifier(model, error.PropertyName); | |
ValidationMessageStore.Add(fieldIdentifier, error.Message); | |
} | |
} | |
CurrentEditContext.NotifyValidationStateChanged(); | |
} | |
public void ShowMessage(string message) | |
{ | |
ShowMessage(message, MessageType.Success); | |
} | |
public void ShowMessage(string message, MessageType type) | |
{ | |
if (type == MessageType.Success) | |
{ | |
ClearMessages(true); | |
} | |
Message = message; | |
Type = type; | |
StateHasChanged(); | |
} | |
public void ClearMessages(bool clearValidationMessages) | |
{ | |
if (clearValidationMessages) | |
{ | |
ValidationMessageStore.Clear(); | |
CurrentEditContext.NotifyValidationStateChanged(); | |
} | |
Message = string.Empty; | |
StateHasChanged(); | |
} | |
public enum MessageType | |
{ | |
Success, | |
Error | |
} | |
} |
Sam kod nie jest jakoś mocno skomplikowany. Metoda ShowError z 24 linijki otrzymuje na wejście obiekt klasy Result i jest odpowiedzialna za skopiowanie błędów do ValidationMessageStore. Istotna jest również ostatnia linijka (o numerze 41) tej metody. W niej następuje powiadomienie formularza, że zostały zmienione błędy walidacji poprzez wywołanie metody NotifyValidationStateChanged z klasy EditCotentext.
Zawartość pliku razor komponenty wygląda tak w przykładzie:
@if (string.IsNullOrEmpty(Message) == false) | |
{ | |
if (Type == MessageType.Success) | |
{ | |
<div class="alert alert-success" role="alert"> | |
@Message | |
</div> | |
} | |
else if (Type == MessageType.Error) | |
{ | |
<div class="alert alert-danger" role="alert"> | |
@Message | |
</div> | |
} | |
} |
Nie jest w żaden sposób powiązany z samą walidacją i służy tylko do wyświetlenia wiadomości we właściwości Message.
Bazowy widok
Aby ułatwić sobie korzystanie z komponentu FormValidator oraz dodania innych pomocnych rzeczy, w projektach tworzę bazowy widok. Z niego będą dziedziczyć wszystkie widoki w aplikacji.
Kod tego bazowego widoku wygląda tak:
public class BasePage : ComponentBase | |
{ | |
protected FormValidator FormValidator; | |
protected void ShowError(Result result, object model = null) | |
{ | |
FormValidator?.ShowError(result, model); | |
} | |
protected void ShowMessage(string message) | |
{ | |
FormValidator?.ShowMessage(message); | |
} | |
} |
W przykładzie zawiera on dwie metody ShowError oraz ShowMessage, który wywołują odpowiednie metody z FormValidator. Dzięki temu kod samego widoku, jak za chwilę zobaczysz, będzie bardzo prosty.
Z czasem do tego widoku bazowego można dodawać jakieś kolejne funkcjonalności, których nie ma bazowy komponent z Blazor.
Walidacja w formularzu
Na koniec przyszła pora na właściwy widok z formularzem. Dla przypomnienia będzie to widok dodania produktu. Plik razor widoku wygląda tak:
@inherits BasePage | |
@page "/products/create" | |
<h3>CreateProduct</h3> | |
<EditForm Model="Model" OnSubmit="OnSubmit"> | |
<FormValidator @ref="FormValidator" /> | |
<div class="row"> | |
<div class="col-12"> | |
<div class="form-group row"> | |
<label for="name" class="col-sm-3">Nazwa:</label> | |
<InputText id="name" class="form-control col-sm-8" @bind-Value="@Model.Name"></InputText> | |
<ValidationMessage class="offset-sm-3 col-sm-8" For="@(() => Model.Name)" /> | |
</div> | |
</div> | |
</div> | |
<div class="row"> | |
<div class="col-12"> | |
<button class="btn btn-primary">Zapisz</button> | |
</div> | |
</div> | |
</EditForm> |
Większość kodu jest standardowa dla obsługi formularzy, a dwa najbardziej istotne elementy to linijki:
- 1 – dziedziczenie po bazowej stronie z poprzedniego akapitu
- 7 – użycie FormValidator – atrybut ref spowoduje powiązanie tego komponentu z polem FormValidator z BasePage, dzięki czemu będziemy mogli do niego przekazać obiekt klasy Result
Natomiast kod C# widoku wygląda tak:
public partial class CreateProduct | |
{ | |
public CreateProductCommand Model = new CreateProductCommand(); | |
[Inject] | |
public IHttpService HttpService { get; set; } | |
private async Task OnSubmit() | |
{ | |
var result = await HttpService.Post<Result<Guid>>("api/products", Model, CancellationToken.None); | |
if (result.Success) | |
{ | |
ShowMessage("Dodano produkt"); | |
} | |
else | |
{ | |
ShowError(result, Model); | |
} | |
} | |
} |
Jak widać nie jest on skomplikowany. Pole Model zawiera komendę, która zostanie wysłana do Web API i jest zbindowana z formularzem. Wstrzykujemy wcześniej opisywaną usługę HttpService. Natomiast metoda OnSubmit, która wykona się podczas wysyłania formularza, wysyła do WebAPI komendę, w wyniku której otrzymujemy obiekt klasy Result, na podstawie którego później skorzystamy z jednej z metod z bazowej strony.
Prawda, że kod widoku jest prosty?
W efekcie, gdy będziemy próbowali przesłać w testowej aplikacji pusty formularz, zobaczymy coś takiego:
Wystarczy teraz tylko popracować nad wyglądem formularzy 🙂
Przykład
Na githubie tradycyjnie jest przykład do tego wpisu – https://github.com/danielplawgo/BlazorServerValidation. Po jego pobraniu możemy go od razu uruchomić.
Podsumowanie
Walidacja danych jest jednym z istotniejszych elementów prawie każdej aplikacji. W tym wpisie pokazałem Ci, w jaki sposób można łatwo wyświetlić w widoku błędy walidacji, które zostały wygenerowane po stronie Web API.
jeszcze ze dwa wpisy o blazorze i się przekonam do tego 😀