Blazor'da Form'lar ve Validation: EditForm, Input Component'leri, DataAnnotations ve .NET 10 Nested Validation
Blazor'da form EditForm ve EditContext ile kurulur, kurallar DataAnnotations attribute'larıyla modelde durur; .NET 10'da AddValidation ve [ValidatableType] ile nested nesneler ve listeler de doğrulanır. Bookstore panelinde kitaplarıyla birlikte yazar ekleyen form'u, ISBN kontrol hanesi için custom attribute'u, API'nin 400 hatalarını ValidationMessageStore ile doğru alana bağlamayı ve Static SSR form'unun ne zaman doğrulandığını gerçek çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404, ASP.NET Core 10.0.15) ile derlenip denenmiştir; tarayıcı davranışı Playwright 1.65'in headless Chromium'uyla gözlenmiş, .NET 11 bölümündeki örnek .NET 11 SDK 11.0.100 ile çalıştırılmıştır.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) yönetim paneline bu yazıda bir form ekliyoruz: yeni bir yazarı kitaplarıyla birlikte kaydetmek. Form'un modeli bir yazar ve onun altında bir kitap listesi; ISBN (International Standard Book Number; kitabın uluslararası standart numarası), fiyat ve yıl kuralları kitapların üzerinde. Form InteractiveServer ve <DataAnnotationsValidator /> kullanıyor. Boş form'u gönderdiğinde kaç hata mesajı görürsün: yalnız yazar adı için bir tane mi, yoksa kitabın alanları için de mi? İkinci soru: Dune'un ISBN'ini, yani veritabanında zaten olan bir numarayı girdiğinde hatayı kim yakalar ve mesaj ekranda nerede görünür? Üçüncüsü: 05'teki Static SSR (Static Server-Side Rendering; sayfanın her istekte sunucuda render edilip düz HTML olarak gönderilmesi) stok sayım formuna [Range(0, 10_000)] eklersen, kullanıcı -5 yazıp alandan çıktığında mesaj hemen görünür mü?
EditForm, EditContext ve Input Component'leri
Blazor'da form'un merkezinde EditForm var. Ona ya bir Model ya da bir EditContext verirsin, ikisini birden değil. EditContext form'un state'ini tutar: hangi alan değişti, hangi alanda hangi mesaj var, form şu an geçerli mi. Model verdiğinde EditForm bunu kendisi oluşturuyor; sunucudan gelen hataları eklemek gibi bir işin varsa kendin oluşturman gerekiyor. Yeni yazar sayfasının markup'ı:
@page "/authors/new"
@rendermode InteractiveServer
@using System.Text.RegularExpressions
@using Bookstore.Admin.Components.Catalog
@using Bookstore.Admin.Validation
@inject BookstoreApiClient Api
@inject NavigationManager Navigation
<PageTitle>New author</PageTitle>
<h1>New author</h1>
<EditForm EditContext="editContext" OnValidSubmit="SaveAsync">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="mb-3">
<label class="form-label">Name <InputText @bind-Value="form.Name" class="form-control" /></label>
<ValidationMessage For="() => form.Name" />
</div>
@for (int i = 0; i < form.Books.Count; i++)
{
NewBookForm book = form.Books[i];
<fieldset class="border rounded p-2 mb-2">
<legend class="fs-6">Book @(i + 1)</legend>
<label>ISBN <InputText @bind-Value="book.Isbn" class="form-control" /></label>
<ValidationMessage For="() => book.Isbn" />
<label>Title <InputText @bind-Value="book.Title" class="form-control" /></label>
<ValidationMessage For="() => book.Title" />
<label>Price (TRY) <InputNumber @bind-Value="book.Price" class="form-control" /></label>
<ValidationMessage For="() => book.Price" />
<label>Year <InputNumber @bind-Value="book.PublishedYear" class="form-control" /></label>
<ValidationMessage For="() => book.PublishedYear" />
<label>Category
<InputSelect @bind-Value="book.CategorySlug" class="form-select">
<option value="">(choose)</option>
@foreach (CategoryView category in categories)
{
<option value="@category.Slug">@category.Name</option>
}
</InputSelect>
</label>
<ValidationMessage For="() => book.CategorySlug" />
<button type="button" class="btn btn-sm btn-outline-danger" disabled="@(form.Books.Count == 1)"
@onclick="() => form.Books.Remove(book)">Remove</button>
</fieldset>
}
<button type="button" class="btn btn-outline-secondary" @onclick="() => form.Books.Add(new())">Add another book</button>
<button type="submit" class="btn btn-primary" disabled="@saving">Save</button>
</EditForm>
Input component'leri HTML input'larını modele bağlıyor ve tip dönüşümünü yapıyor: InputText string, InputNumber sayı, InputSelect seçim listesi; ayrıca InputDate, InputCheckbox, InputRadioGroup, InputTextArea, InputFile ve .NET 10'da gelen, string değerleri gizli alanda taşıyan InputHidden var. Bu component'ler alan değişince EditContext'e haber veriyor ve geçersiz alana invalid CSS class'ını ekliyor. ValidationMessage tek bir alanın mesajını, ValidationSummary bütün mesajları gösteriyor. For="() => book.Isbn" lambda'sı bir expression; Blazor ondan alanın hangi nesnenin hangi property'si olduğunu çıkarıyor. Döngüdeki NewBookForm book = form.Books[i]; satırı bu yüzden önemli: lambda'yı form.Books[i] ile yazsaydın i döngü bittiğinde son değerinde kalır ve her alan yanlış kitaba bakardı.
Form interaktif olduğu için "Add another book" ve "Remove" butonları sıradan @onclick handler'ları: listeye eleman ekleyip çıkarıyorlar, Blazor yeniden render ediyor. Static SSR'da aynı şey her tıklamada bir POST demek olurdu. Submit için üç seçenek var: OnValidSubmit yalnız form geçerliyse, OnInvalidSubmit yalnız geçersizse çalışıyor; OnSubmit ise validation'ı tamamen sana bırakıyor. Burada OnValidSubmit kullandım; kurallar geçmeden API'ye istek gitmiyor.
Form Modeli: DataAnnotations, Custom Attribute ve IValidatableObject
Kurallar modelin üzerinde, attribute olarak:
using System.ComponentModel.DataAnnotations;
using Bookstore.Admin.Validation;
namespace Bookstore.Admin.Components.Catalog;
// Form'un validation graph'ının kökü; NewBookForm'a Books üzerinden ulaşılır.
[ValidatableType]
public sealed class NewAuthorForm : IValidatableObject
{
[Required, StringLength(200)]
public string Name { get; set; } = "";
[MinLength(1, ErrorMessage = "Add at least one book.")]
public List<NewBookForm> Books { get; set; } = [new()];
// Bütün property kuralları geçtikten sonra çalışır.
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
IEnumerable<string> duplicates = Books
.GroupBy(b => Isbn13Attribute.Normalize(b.Isbn))
.Where(g => g.Count() > 1)
.Select(g => g.Key);
foreach (string isbn in duplicates)
{
yield return new ValidationResult($"ISBN {isbn} appears more than once in this form.");
}
}
}
public sealed class NewBookForm
{
[Required, Isbn13, Display(Name = "ISBN")]
public string Isbn { get; set; } = "";
[Required, StringLength(200)]
public string Title { get; set; } = "";
[Range(typeof(decimal), "1", "10000", ErrorMessage = "The price must be between 1 and 10,000 TRY.")]
public decimal Price { get; set; }
[Range(1450, 2100), Display(Name = "Year")]
public int PublishedYear { get; set; }
[Required(ErrorMessage = "Choose a category.")]
public string CategorySlug { get; set; } = "";
}
[Required], [StringLength], [Range], [MinLength] bilinen DataAnnotations attribute'ları; ASP.NET Core MVC (Model-View-Controller; controller ve view'larla sunucuda sayfa üreten model) ve Razor Pages serisinin validation yazısında gördüğümüz altyapının aynısı. decimal için Range(typeof(decimal), "1", "10000") yazmak gerekiyor, çünkü attribute argümanı olarak decimal literal verilemiyor. [Display(Name = "ISBN")] mesajlardaki alan adını değiştiriyor; vermezsen kullanıcı "The field PublishedYear must be..." gibi property adlarını görür.
ISBN'in yalnız 13 haneli olması yetmiyor, son hane bir kontrol hanesi. Bunun için küçük bir custom attribute yazdım:
using System.ComponentModel.DataAnnotations;
namespace Bookstore.Admin.Validation;
// ISBN-13: 13 hane (tire ve boşluk serbest); ağırlıklı toplamı (1, 3, 1, 3, ...) 10'un katı olmalı.
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter)]
public sealed class Isbn13Attribute() : ValidationAttribute("{0} is not a valid ISBN-13.")
{
public override bool IsValid(object? value)
{
if (value is not string text || text.Length == 0)
{
return true; // boş değere izin verilip verilmediğine [Required] karar verir
}
string digits = Normalize(text);
if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
{
return false;
}
int sum = 0;
for (int i = 0; i < 13; i++)
{
sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
}
return sum % 10 == 0;
}
public static string Normalize(string isbn) => isbn.Replace("-", "").Replace(" ", "");
}
Boş değerde true dönmesi bilinçli: zorunluluk [Required]'ın işi, iki attribute aynı alan için iki ayrı mesaj üretmesin. Tek bir alana bağlı olmayan kurallar için IValidatableObject var. Aynı form'da bir ISBN'in iki kez girilmesi bir kitabın değil, form'un hatası; Validate method'u bunu model seviyesinde bir mesaj olarak döndürüyor. Validation sırası şöyle: önce property attribute'ları, sonra type seviyesindeki attribute'lar, en son Validate; bir adımda hata varsa sonrakiler çalışmıyor. Yani kullanıcı çift ISBN mesajını ancak bütün alanlar geçerli olduğunda görüyor.
.NET 10 Nested Validation: AddValidation ve [ValidatableType]
.NET 9'a kadar DataAnnotationsValidator yalnız modelin kendi property'lerini doğruluyordu. Books listesindeki kitapların kuralları çalışmazdı; bunun için deneysel bir paketteki ObjectGraphDataAnnotationsValidator ya da elle yazılmış kod gerekiyordu. .NET 10'da Minimal API'lerin de kullandığı Microsoft.Extensions.Validation altyapısı Blazor'a geldi. Gereken üç şey var: Program.cs'te bir satır,
// Source generator ile üretilen validation metadata'sı: [ValidatableType] form modellerinde nested nesneler ve collection'lar.
builder.Services.AddValidation();
kök modelde [ValidatableType] ve modellerin .razor değil .cs dosyasında olması. AddValidation derleme sırasında çalışan bir source generator'ı devreye sokuyor; generator [ValidatableType] verilen tipten başlayıp ulaşabildiği bütün tipler için validation metadata'sı üretiyor. .razor dosyasının içindeki bir class'ı göremediği için modeller ayrı dosyada. Playwright ile boş form'u gönderdiğimde:
1) empty form: 6 field messages
- The Name field is required.
- The ISBN field is required.
- The Title field is required.
- The price must be between 1 and 10,000 TRY.
- The field Year must be between 1450 and 2100.
- Choose a category.
İlk sorunun cevabı: altı mesaj, biri yazar adı, beşi kitabın alanları. Aynı denemeyi AddValidation() satırını silip tekrarladığımda:
empty form: 1 field message(s)
- The Name field is required.
Derleme hatası yok, uyarı yok; kitap alanları sessizce doğrulanmıyor. Bu satırı unutursan hatayı ancak API reddettiğinde görürsün. Dokümanın bir uyarısı daha var: ValidatableTypeAttribute .NET 10'da deneysel bir API ve doğrudan kullanınca ASP0029 uyarısı veriyor. Razor component'i olan web projelerinde SDK Microsoft.Extensions.Validation.Embedded namespace'inde deneysel olmayan bir kopyasını üretip global using ile ekliyor; [ValidatableType] o yüzden ek bir using olmadan çalışıyor. Ben ilk denemede dosyaya using Microsoft.Extensions.Validation; ekledim ve derleme şu hatayla durdu:
src/Bookstore.Admin/Components/Catalog/NewAuthorForm.cs(8,2): error CS0104: 'ValidatableType' is an ambiguous reference between 'Microsoft.Extensions.Validation.Embedded.ValidatableTypeAttribute' and 'Microsoft.Extensions.Validation.ValidatableTypeAttribute'
Çözüm o using'i silmek. Alan değişince yapılan validation da nested çalışıyor; ikinci kitabın ISBN'ini yanlış kontrol hanesiyle yazıp alandan çıktığımda, form gönderilmeden:
2) second book, wrong check digit (typed, not submitted yet): ["ISBN is not a valid ISBN-13."]
İki kitaba aynı ISBN'i yazıp gönderdiğimde ise mesaj bir alanın altında değil, ValidationSummary'de çıktı; IValidatableObject'in model seviyesindeki sonucu:
3) same ISBN twice: summary=["ISBN 9780156628709 appears more than once in this form."]
4) ISBN of Dune (exists in the API): fields=["A book with ISBN 9780441172719 already exists."]
5) after editing the field: fields=[]
API'nin Hatalarını EditContext'e Eklemek
Form bir ISBN'in geçerli olup olmadığını biliyor ama veritabanında olup olmadığını bilmiyor. Bu kuralı yalnız Bookstore.Api uygulayabilir. API'ye bu yazıda POST /api/authors endpoint'ini ekledim:
public sealed record NewBookRequest(
[property: Required, RegularExpression(@"^\d{13}$", ErrorMessage = "ISBN must be 13 digits.")] string Isbn,
[property: Required, StringLength(200)] string Title,
[property: Range(typeof(decimal), "1", "10000")] decimal Price,
[property: Range(1450, 2100)] int PublishedYear,
[property: Required] string CategorySlug);
public sealed record NewAuthorRequest(
[property: Required, StringLength(200)] string Name,
[property: MinLength(1)] IReadOnlyList<NewBookRequest> Books);
// POST /api/authors { "name": "...", "books": [ { "isbn": "...", ... } ] }: kitaplarıyla birlikte yeni bir yazar.
private static async Task<Results<Created<AuthorView>, ValidationProblem>> CreateAuthor(
NewAuthorRequest request, BookstoreContext db, IInventoryService inventory, CancellationToken ct)
{
string[] isbns = [.. request.Books.Select(b => b.Isbn)];
HashSet<string> taken = [.. await db.Books.Where(b => isbns.Contains(b.Isbn)).Select(b => b.Isbn).ToListAsync(ct)];
Dictionary<string, int> categories = await db.Categories.ToDictionaryAsync(c => c.Slug, c => c.Id, ct);
Dictionary<string, string[]> errors = [];
for (int i = 0; i < request.Books.Count; i++)
{
NewBookRequest book = request.Books[i];
if (taken.Contains(book.Isbn))
{
errors[$"Books[{i}].Isbn"] = [$"A book with ISBN {book.Isbn} already exists."];
}
if (!categories.ContainsKey(book.CategorySlug))
{
errors[$"Books[{i}].CategorySlug"] = [$"Unknown category '{book.CategorySlug}'."];
}
}
if (errors.Count > 0)
{
return TypedResults.ValidationProblem(errors);
}
Request record'ları API'nin kendi kurallarını taşıyor; panelin kurallarına güvenmiyor, çünkü API'yi başka istemciler de çağırabilir. API projesinde AddValidation() zaten açık olduğu için Minimal API, handler'a girmeden önce nested kitapları aynı altyapıyla doğruluyor. Kurallar geçerse handler veritabanına bakıp ISBN çakışmasını ve bilinmeyen kategoriyi kontrol ediyor. İki durumda da yanıt aynı biçimde; hata key'leri Books[1].Isbn gibi yol bilgisi taşıyor:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Books[1].Isbn": [
"A book with ISBN 9780441172719 already exists."
],
"Books[1].CategorySlug": [
"Unknown category 'poetry'."
]
},
"traceId": "00-5ab0012ff0150edaf42216ff63cef52c-6759827229b1f667-00"
}
Panel tarafında typed client bu yanıtı okuyup sözlük olarak döndürüyor:
// Yazar oluşturulduysa null; aksi hâlde API'nin key'e göre validation hataları, örneğin "Books[1].Isbn".
public async Task<IDictionary<string, string[]>?> CreateAuthorAsync(NewAuthorRequest request, CancellationToken ct = default)
{
using HttpResponseMessage response = await http.PostAsJsonAsync("api/authors", request, ct);
if (response.StatusCode == HttpStatusCode.BadRequest)
{
HttpValidationProblemDetails? problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>(ct);
return problem?.Errors ?? new Dictionary<string, string[]>();
}
response.EnsureSuccessStatusCode();
return null;
}
Sayfanın @code bloğu bu sözlüğü form'un alanlarına dağıtıyor:
@code {
private readonly NewAuthorForm form = new();
private EditContext editContext = null!;
private ValidationMessageStore serverErrors = null!;
private IReadOnlyList<CategoryView> categories = [];
private bool saving;
protected override async Task OnInitializedAsync()
{
editContext = new EditContext(form);
serverErrors = new ValidationMessageStore(editContext);
// Kullanıcı alanı değiştirdiği anda o alanın sunucu hatası silinir.
editContext.OnFieldChanged += (_, e) => serverErrors.Clear(e.FieldIdentifier);
editContext.OnValidationRequested += (_, _) => serverErrors.Clear();
categories = await Api.GetCategoriesAsync();
}
private async Task SaveAsync()
{
saving = true;
NewAuthorRequest request = new(form.Name,
[.. form.Books.Select(b => new NewBookRequest(
Isbn13Attribute.Normalize(b.Isbn), b.Title, b.Price, b.PublishedYear, b.CategorySlug))]);
IDictionary<string, string[]>? errors = await Api.CreateAuthorAsync(request);
saving = false;
if (errors is null)
{
// Yeni kitaplar 0 adetle başlar; stok sayfası onları en üstte listeler.
Navigation.NavigateTo("inventory");
return;
}
foreach ((string key, string[] messages) in errors)
{
serverErrors.Add(ToField(key), messages);
}
editContext.NotifyValidationStateChanged();
}
// "Books[1].Isbn" -> ikinci kitabın Isbn alanı; "Name" -> form'un Name'i; geri kalan her şey -> form'un tamamı.
private FieldIdentifier ToField(string key)
{
Match match = Regex.Match(key, @"^Books\[(\d+)\]\.(\w+)$");
if (match.Success && int.Parse(match.Groups[1].Value) is int index && index < form.Books.Count)
{
return new FieldIdentifier(form.Books[index], match.Groups[2].Value);
}
return key == nameof(NewAuthorForm.Name) ? new FieldIdentifier(form, key) : new FieldIdentifier(form, string.Empty);
}
}
Anahtar nesne ValidationMessageStore: EditContext'e bağlı, kendi mesajlarını tutan bir depo. DataAnnotationsValidator da içeride aynı şeyi kullanıyor, bizim store'umuz onun yanına ekleniyor. FieldIdentifier bir alanı nesne ve property adı ikilisiyle tanımlıyor; ToField API'nin Books[1].Isbn key'ini ikinci kitap nesnesinin Isbn alanına çeviriyor, bu sayede mesaj doğru ValidationMessage'ın altına düşüyor. İki event handler mesajların ne zaman silineceğini belirliyor: kullanıcı alanı değiştirince o alanın sunucu mesajı, form tekrar doğrulanınca hepsi. İkinci kitaba Dune'un ISBN'ini yazıp gönderdiğimde, sonra alanı düzeltip tekrar gönderdiğimde:
6) saved, now on /inventory:
Don Quixote Out of stock | 0 | 0 | 0
Mrs Dalloway Out of stock | 0 | 0 | 0
To the Lighthouse Out of stock | 0 | 0 | 0
İkinci sorunun cevabı: hatayı API yakaladı, mesaj ikinci kitabın ISBN alanının altında göründü ve alan düzeltilince kayboldu. Kayıttan sonra sayfa stok ekranına gidiyor; yeni kitaplar stok kaydı 0 olarak açıldığı için listenin en üstünde. API'nin yerel kopyasında çalıştırdığım endpoint testleri:
passed Bookstore.Api.Tests.AuthorTests.An_existing_isbn_is_reported_on_the_book_that_uses_it (182ms)
passed Bookstore.Api.Tests.AuthorTests.Field_rules_of_nested_books_are_validated (2ms)
passed Bookstore.Api.Tests.AuthorTests.Authors_are_listed_by_name_with_their_book_count (19ms)
passed Bookstore.Api.Tests.AuthorTests.A_new_author_is_created_with_books_that_start_with_no_stock (50ms)
Test run summary: Passed!
total: 4
failed: 0
succeeded: 4
Bu testler kitap eklediği için kendi veritabanında çalışıyor (AuthorTestsFactory, BookstoreDb_AuthorTests); aksi hâlde katalogda tam 20 kitap bekleyen eski test, çalışma sırasına göre kırılırdı.
Static SSR Form'da Validation
05'teki stok sayım formuna da kural ekledim:
<EditForm Model="StockCount" FormName="stock-count" OnValidSubmit="SaveStockAsync" Enhance>
<DataAnnotationsValidator />
<label>
Counted quantity on hand
<InputNumber @bind-Value="StockCount!.QuantityOnHand" class="form-control w-auto" />
</label>
<ValidationMessage For="() => StockCount!.QuantityOnHand" />
<button type="submit" class="btn btn-primary">Save</button>
</EditForm>
public sealed class StockCountForm
{
// Bookstore.Api'deki SetStockRequest ile aynı sınırlar; POST'tan sonra sunucuda kontrol edilir.
[Range(0, 10_000), Display(Name = "Quantity on hand")]
public int QuantityOnHand { get; set; }
}
Bu form Static SSR; circuit yok, tarayıcıda .NET yok. Playwright'ta -5 yazıp alandan çıktım, sonra gönderdim:
after typing -5: messages=[], requests=[]
after submit: messages=["The field Quantity on hand must be between 0 and 10000."], requests=["POST /books/9780141439549"]
input value after the round trip: -5
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/books/9780141439549
info: LogicalHandler: Start processing HTTP request GET http://localhost:5080/api/books/9780141439549
Üçüncü sorunun cevabı: hayır. .NET 10'da Static SSR form'u yalnız POST'tan sonra sunucuda doğrulanıyor. Mesaj, sayfa yeniden render edilince geldi; girilen -5 de form'da kaldı. Log'daki iki GET sayfanın kendisinin ve POST'tan sonraki render'ın kitap okuması; PUT /api/inventory yok, çünkü OnValidSubmit çalışmadı. Static SSR'da antiforgery token'ını EditForm 05'te gördüğümüz gibi kendisi ekliyor. Interaktif form'da ise tarayıcıdan bir POST gitmiyor; değerler circuit üzerinden geliyor ve submit sunucudaki bir method çağrısı.
Angular Reactive Forms ile Kıyas
Angular'dan geliyorsan aynı form'u Reactive Forms ile kurmayı düşün: FormGroup içinde bir FormArray, her kitap için bir FormGroup. Fark çoğunlukla kuralın nerede yazıldığında:
| Konu | Angular Reactive Forms | Blazor EditForm |
|---|---|---|
| Form modeli | TypeScript'te FormGroup, FormControl | C# class'ı ve EditContext |
| Kurallar | Validators.required, custom ValidatorFn | DataAnnotations attribute'ları, IValidatableObject |
| Dinamik liste | FormArray.push / removeAt | List<T>'ye ekleyip çıkarmak |
| Sunucu hatası | control.setErrors | ValidationMessageStore.Add |
| Alan durumu | touched, dirty, valid | IsModified, GetValidationMessages |
| API ile aynı kural | C# ve TypeScript'te iki kez yazılır | Aynı attribute iki tarafta da kullanılabilir |
Son satır Blazor'un en somut avantajı ama bedava değil: bizim çözümümüzde Isbn13Attribute panelde, API ise yalnız 13 hane kontrolü yapıyor. Kuralı gerçekten tek yerde tutmak için ikisinin de referans verdiği ortak bir library gerekiyor; panelin veri erişimini konuşacağımız 09. yazıda böyle bir library'ye ihtiyaç doğacak. Kurallar karmaşıklaşırsa (koşullu kurallar, veritabanına bakan kurallar, kural setlerini yeniden kullanmak) FluentValidation sık tercih edilen bir library; Blazor'a EditContext üzerinden bağlanan topluluk paketleri var. Bu seride DataAnnotations ile kalıyoruz.
.NET 11'de Form'lar İçin Gelenler
Bu bölüm .NET 11 içindir; serinin kodu .NET 10 LTS (Long Term Support; üç yıl desteklenen sürüm) üzerinde kalıyor. Üçüncü sorunun .NET 11'deki cevabı farklı: Static SSR form'ları, DataAnnotationsValidator varsa varsayılan olarak tarayıcıda da doğrulanıyor. Aynı stok sayım formunu .NET 11 SDK'sıyla açtığım küçük bir projede denedim:
after typing -5: message=The field Quantity on hand must be between 0 and 10000., requests=[]
after submit: message=The field Quantity on hand must be between 0 and 10000., requests=[]
after 30 and submit: message=(hidden), requests=["POST /stock-count-validated"]
Mesaj alandan çıkar çıkmaz göründü ve geçersiz form'da POST hiç gitmedi. Sunucu kuralları HTML'e metadata olarak yazıyor, Blazor'un JavaScript'i bunları tarayıcıda uyguluyor; okunabilirlik için tırnakları çözülmüş hâli:
<input step="any" type="number" id="Form_QuantityOnHand" name="Form.QuantityOnHand" class="valid" value="0" />
<div class="validation-message" data-valmsg-for="Form.QuantityOnHand" data-valmsg-replace="true" hidden></div>
<button type="submit">Save</button><blazor-client-validation-data data-rules="{"fields":[{"name":"Form.QuantityOnHand","rules":[{"name":"range","message":"The field Quantity on hand must be between 0 and 10000.","params":{"min":"0","max":"10000"}}]}]}"></blazor-client-validation-data></form>
Kurallar yine C# modelinden geliyor ve POST sonrasında sunucuda tekrar doğrulanıyor; yani tarayıcıdaki kontrol yalnız kullanıcıya erken geri bildirim. .NET 11 ayrıca async validation getiriyor: veritabanına ya da bir API'ye bakan kurallar EditForm submit'inde sonuna kadar bekleniyor. Dune örneğindeki "bu ISBN var mı" kontrolü böyle bir kural olabilir. İkisine 13. yazıda daha ayrıntılı bakacağız.
Üretimde Dikkat
Form'daki kurallar kullanıcıya hızlı geri bildirim içindir, güvenlik için değil. InteractiveServer'da kurallar sunucuda çalıştığı için atlanamaz gibi görünse de API'yi başka istemciler de çağırabilir. Kuralın yetkili sahibi API; panel aynısını erken göstermek için tekrar ediyor.
InteractiveServer form'unda her alan değişikliği circuit üzerinden sunucuya bir mesaj, her validation sunucuda bir çalışma. Bu küçük bir form için önemsiz; ama @bind:event="oninput" ile her tuşta doğrulama yaparsan ve kuralların arasında pahalı bir iş varsa, kullanıcı sayısıyla çarpılan bir maliyet olur. Varsayılan onchange çoğu form için yeterli.
Kullanıcı Save'e iki kez basabilir. Sayfadaki saving bayrağı istek sürerken butonu kapatıyor; yine de asıl koruma API tarafında ISBN'in unique index'i. İki istek aynı anda gelirse ikisi de ön kontrolü geçebilir ve ikincisi veritabanının unique index'ine takılır; bunu kullanıcıya düzgün bir validation mesajı olarak döndürmek ayrıca ele alınmalı.
Sık Düşülen Tuzaklar
AddValidation()'ı unutmak. Kitap alanları sessizce doğrulanmıyor; yukarıdaki denemede altı mesaj yerine tek bir mesaj gördük. Dokümana göre modeli .razor dosyasının içine yazmak da aynı sonucu veriyor, çünkü source generator o class'ı göremiyor.
Döngüde For="() => form.Books[i].Isbn" yazmak. Lambda i'yi yakalıyor; ValidationMessage expression'ı değerlendirdiğinde döngü bitmiş ve i liste uzunluğuna eşit oluyor. Denediğimde sayfa açılırken 500 döndü:
System.ArgumentOutOfRangeException: Index was out of range. Must be non-negative and less than the size of the collection. (Parameter 'index')
at lambda_method115(Closure)
at Microsoft.AspNetCore.Components.Forms.FieldIdentifier.ParseAccessor[T](Expression`1 accessor, Object& model, String& fieldName)
Döngü içinde yerel bir değişkene ata, NewBookForm book = form.Books[i]; gibi.
EditForm'a hem Model hem EditContext vermek. Blazor bunu kabul etmiyor; sunucu hatası eklemen gerekiyorsa yalnız EditContext.
Sunucu mesajlarını hiç temizlememek. ValidationMessageStore'a eklenen mesaj kendiliğinden gitmez; kullanıcı alanı düzeltse bile "already exists" ekranda kalır ve OnValidSubmit bir daha çalışmaz. OnFieldChanged ve OnValidationRequested'da temizle.
Static SSR form'unda OnSubmit kullanıp validation beklemek. OnSubmit validation'ı sana bırakıyor; kural attribute'larını eklesen bile editContext.Validate() çağırmadıkça API'ye geçersiz değer gider. 05'teki form bu yüzden OnValidSubmit'e geçti.
Küçük Bir Deneme
★☆☆ NewBookForm.PublishedYear'dan [Display(Name = "Year")]'ı kaldır ve boş form'u tekrar gönder. Mesaj ne oldu? Aynı şeyi Price'ın ErrorMessage'ı için dene.
★★☆ Kitap için bir yayın tarihi alanı ekle: DateOnly? PublishedOn ve InputDate. Gelecekteki bir tarihi reddeden bir kural yaz; bunu attribute olarak mı, IValidatableObject içinde mi yazmak daha doğru?
★★★ Isbn13Attribute'u hem panelin hem Bookstore.Api'nin kullanabileceği bir yere taşı ve API'nin RegularExpression kuralını onunla değiştir. Hangi projeye referans eklemen gerekti? API testlerinde kontrol hanesi yanlış bir ISBN için yeni bir test yaz.
Form'u kurduk ama yeni yazar sayfasında, stok tablosunda ve kitap listesinde benzer markup'ı tekrar tekrar yazıyoruz. Bir sonraki yazıda component tasarımına geçiyoruz: RenderFragment ile templated component, generic bir tablo, CascadingValue, @key, QuickGrid, CSS isolation ve bunların bUnit ile testi.