ASP.NET Core'da Model Validation: Data Annotations ve FluentValidation
Data Annotations, ModelState ve otomatik 400, ValidationProblemDetails'i özelleştirmek, custom attribute, IValidatableObject ve FluentValidation 11.x ile model validation; Türkçe sunucularda Range ve mesaj dili tuzakları dahil.
Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.
Önce Tahmin Et
Üzerinde hiçbir attribute olmayan bir request class'ı düşün. Projede nullable reference types açık:
namespace ProductCatalog.Models;
public class ImplicitRequest
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
Bu class'ı body'den alan bir action'a iki istek atıyoruz: biri {"price":100}, diğeri {"name":null,"price":100}. Hiç [Required] yazmadık. Sence ikisi de 200 mü döner, ikisi de 400 mü, yoksa biri 200 biri 400 mü?
Data Annotations ile Kurallar
Bir önceki yazıda verinin action'a nasıl ulaştığını gördük. Model validation ise ulaşan verinin kurallara uyup uymadığına bakar. .NET'teki en tanıdık yöntem Data Annotations: kuralları property'lerin üstüne attribute olarak yazarsın.
using System.ComponentModel.DataAnnotations;
using ProductCatalog.Validation;
namespace ProductCatalog.Models;
public class CreateProductRequest
{
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Required]
[RegularExpression(@"^[A-Z]{3}-\d{4}$", ErrorMessage = "Sku must look like ABC-1234.")]
public string Sku { get; set; } = string.Empty;
[AllowedCategory("peripherals", "books", "office")]
public string Category { get; set; } = string.Empty;
[Range(typeof(decimal), "0.01", "100000", ParseLimitsInInvariantCulture = true)]
public decimal Price { get; set; }
public decimal? DiscountPrice { get; set; }
}
[Required] değerin gelmesini, [StringLength] uzunluk aralığını, [RegularExpression] bir kalıba uymasını, [Range] de sayının sınırlar içinde kalmasını ister. ErrorMessage ile varsayılan mesajı değiştirebilirsin. [AllowedCategory] bizim yazdığımız bir attribute, birazdan ona geleceğiz. ParseLimitsInInvariantCulture'ın neden orada durduğunu ise tuzaklar bölümünde göreceksin; Türkiye'de çalışan biri için önemli bir detay.
Hatalı bir istek atalım: {"name":"K","sku":"kb-1","category":"toys","price":0}
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-6c530b4549e2e0aaaff5a62810db3544-7c2e534ad5c03d30-00","errors":{"Sku":["Sku must look like ABC-1234."],"Name":["The field Name must be a string with a minimum length of 2 and a maximum length of 100."],"Price":["The field Price must be between 0.01 and 100000."],"Category":["Category must be one of: peripherals, books, office."]}}
Dört alan, dört hata, tek yanıt. İstemci formdaki her alanın altına kendi hatasını yazabilir; ilk hatada durup kullanıcıyı dört kez geri göndermek zorunda kalmaz. {} gönderdiğinde ise Name için iki mesaj birden gelir: hem Required hem StringLength boş string'i reddeder.
Şimdi baştaki sorunun cevabı. {"price":100} isteği 200 OK ve {"name":"","price":100} döndü. {"name":null,"price":100} ise "The Name field is required." hatasıyla 400 aldı. Nullable reference types açıkken nullable olmayan bir property'ye framework gizli bir [Required] ekler, ama bu kural sadece null'a bakar. Property hiç gönderilmediğinde initializer'daki string.Empty yerinde kalır ve kural geçer. Açıkça yazdığın [Required] ise boş string'i de reddeder. Zorunlu alanı her zaman açıkça işaretle; derleyici uyarısını susturmak için yazılmış bir = string.Empty'ye güvenme.
ModelState ve Otomatik 400
Model binding ve validation çalıştığında bulunan her hata ModelState'e yazılır. [ApiController], action çalışmadan hemen önce ModelState.IsValid'e bakan bir filter ekler; geçersizse action'ı hiç çağırmadan yukarıdaki ValidationProblemDetails'i döner. [ApiController] olmayan bir controller'da bu kontrolü elle yaparsın:
[Route("api/manual-products")]
public class ManualProductsController : ControllerBase
{
[HttpPost]
public IActionResult Create([FromBody] CreateProductRequest request)
{
// Otomatik 400 yok, kontrol bizde
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
return Ok(request);
}
}
ValidationProblem(ModelState) aynı formatta 400 üretir. Validation her durumda çalışıyor; [ApiController]'ın tek yaptığı, sonucuna bakıp senin yerine karar vermek. Bu kontrolü unuttuğunda geçersiz veri sessizce içeri girer.
ValidationProblemDetails'i Özelleştirmek
Bazı ekipler validation hataları için 400 yerine 422 Unprocessable Entity kullanır ya da başlığı kendi diline çevirmek ister. Otomatik yanıtı üreten fabrikayı değiştirebilirsin:
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
ProblemDetailsFactory factory = context.HttpContext.RequestServices
.GetRequiredService<ProblemDetailsFactory>();
ValidationProblemDetails problem = factory.CreateValidationProblemDetails(
context.HttpContext,
context.ModelState,
statusCode: StatusCodes.Status422UnprocessableEntity,
title: "Request validation failed");
return new UnprocessableEntityObjectResult(problem);
};
});
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Request validation failed","status":422,"traceId":"00-348060f76cd69ba66261f55cbd52ee63-e152ed1a983528e2-00","errors":{"Name":["The field Name must be a string with a minimum length of 2 and a maximum length of 100."]}}
ValidationProblemDetails'i new ile kendin de oluşturabilirsin, ama o zaman type ve traceId alanları kaybolur. ProblemDetailsFactory üzerinden gitmek, bir önceki yazıda konuştuğumuz ClientErrorMapping'i ve trace bilgisini korur. Factory'yi RequestServices'ten istememiz bir service locator kullanımı; burada framework'ün verdiği tek kapı bu olduğu için kabul edilebilir, ama kendi kodunda bu kalıbı yaymamak gerekir. Neden öyle olduğunu dependency injection yazısında konuşacağız.
Kendi Attribute'unu Yazmak
Hazır attribute'lar yetmediğinde ValidationAttribute'tan türetip kendininkini yazarsın:
using System.ComponentModel.DataAnnotations;
namespace ProductCatalog.Validation;
public class AllowedCategoryAttribute : ValidationAttribute
{
private readonly string[] _categories;
public AllowedCategoryAttribute(params string[] categories)
{
_categories = categories;
}
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
if (value is string category && _categories.Contains(category))
{
return ValidationResult.Success;
}
return new ValidationResult(
$"{validationContext.DisplayName} must be one of: {string.Join(", ", _categories)}.",
new[] { validationContext.MemberName! });
}
}
IsValid değeri alır, geçerliyse ValidationResult.Success, değilse mesajlı bir ValidationResult döner. İkinci parametredeki üye adı, hatanın errors sözlüğünde hangi anahtarın altına yazılacağını belirler. Yukarıdaki çıktıdaki "Category must be one of: peripherals, books, office." mesajı buradan geliyor. Bu attribute'un benzeri .NET 8'de framework'e [AllowedValues] olarak eklendi; ilerleyen sürüm yazılarında göreceğiz.
IValidatableObject: Alanlar Arası Kurallar
Attribute'lar tek bir property'ye bakar. "İndirimli fiyat, normal fiyattan düşük olmalı" gibi iki alanı karşılaştıran bir kural için class'ın kendisi devreye girer:
public class CreateProductRequest : IValidatableObject
{
// Property'ler yukarıdakiyle aynı
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (DiscountPrice is not null && DiscountPrice >= Price)
{
yield return new ValidationResult(
"Discount price must be lower than price.",
new[] { nameof(DiscountPrice) });
}
}
}
Fiyatı 1450, indirimli fiyatı 1500 olan geçerli bir ürün gönderdiğinde yanıt "DiscountPrice":["Discount price must be lower than price."] hatasını taşır. İlginç olan, aynı isteğe bir de tek harflik bir isim koyduğunda olan: yanıtta sadece Name hatası görünür, indirim hatası kaybolur. Validate, property seviyesindeki kurallar geçmeden çalışmaz. Mantıklı bir sıra bu: alanların kendisi bozukken onları karşılaştırmanın anlamı yok. Ama istemci hataları iki tur hâlinde öğrenir, bunu bilerek tasarla.
FluentValidation ile Kuralları Ayırmak
Kurallar büyüdükçe attribute'lar class'ı boğmaya başlar. Liste elemanlarını tek tek denetlemek, kurala göre koşul koymak, async kontrol yapmak attribute'larla zahmetli. FluentValidation kuralları ayrı bir class'a taşır. .NET 6 hedefleyen bir projede kullanacağın sürüm 11.x serisi; şu an en güncel olanı 11.12.0. 12.x serisi sadece .NET 8 ve üstünü hedefliyor.
dotnet add package FluentValidation --version 11.12.0
dotnet add package FluentValidation.DependencyInjectionExtensions --version 11.12.0
Sipariş oluşturma isteği için bir validator yazalım:
using FluentValidation;
using ProductCatalog.Models;
namespace ProductCatalog.Validation;
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.CustomerEmail).NotEmpty().EmailAddress();
RuleFor(x => x.Items).NotEmpty().WithMessage("An order must contain at least one item.");
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
}
}
CreateOrderRequest, CustomerEmail ve List<OrderItemRequest> Items taşıyan düz bir class; üzerinde tek bir attribute yok. Validator'ları Program.cs'te tek satırla kaydediyoruz: builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();. Action tarafı şöyle:
[HttpPost]
public async Task<IActionResult> Create(
CreateOrderRequest request,
[FromServices] IValidator<CreateOrderRequest> validator)
{
ValidationResult result = await validator.ValidateAsync(request);
if (!result.IsValid)
{
foreach (ValidationFailure error in result.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
return ValidationProblem(ModelState);
}
return Ok(new { message = "Order accepted", itemCount = request.Items.Count });
}
Hataları ModelState'e aktarıp ValidationProblem döndüğümüz için yanıt, Data Annotations'ın ürettiğiyle aynı şemada:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-f8ef95fbe5944a22c9beb009156d21cb-a5d31b7c385d3994-00","errors":{"CustomerEmail":["'Customer Email' is not a valid email address."],"Items[0].Quantity":["'Quantity' must be between 1 and 100. You entered 0."],"Items[1].ProductId":["'Product Id' must be greater than '0'."]}}
Items[0].Quantity anahtarına dikkat et; hatanın listenin hangi elemanında olduğunu istemciye kendisi söylüyor. Validator'ı neden elle çağırdığımızı da açıklayayım. Eskiden FluentValidation.AspNetCore paketi validator'ları MVC pipeline'ına bağlayıp otomatik çalıştırıyordu. Bu paketin artık bakımı yapılmıyor, NuGet'te "legacy" olarak işaretli ve yazarı elle çağırmayı öneriyor. Elle çağırmanın iyi bir yan etkisi de var: validation'ın ne zaman çalıştığını kodu okuyarak görürsün. Async action'ları ayrı bir yazıda detaylandıracağız.
Sık Düşülen Tuzaklar
İlk tuzak Türkçe bir makinede patlar. ParseLimitsInInvariantCulture olmadan yazılmış bir [Range(typeof(decimal), "0.01", "100000")], işletim sisteminin dili Türkçe olan bir sunucuda bu property'yi taşıyan her istekte 500 döner. Log'daki satır şu:
System.ArgumentException: 0.01 is not a valid value for Decimal. (Parameter 'value')
Sınır değerleri string olarak verdiğinde attribute onları varsayılan olarak o anki culture ile parse ediyor ve Türkçe'de ondalık ayırıcı virgül. Geliştirici makinen İngilizce, sunucu Türkçe ise hatayı ancak production'da görürsün. ParseLimitsInInvariantCulture = true sınırları her makinede aynı okur. Mesajın kendisi yine culture'a göre biçimlenir; Türkçe sunucuda between 0,01 and 100000 yazar.
İkincisi de culture ile ilgili. FluentValidation hazır mesajlarını işletim sisteminin diline göre seçer. Aynı istek Türkçe bir makinede "'Customer Email' geçerli bir e-posta adresi değil." döner, İngilizce makinede yukarıdaki mesajı. API'nin dili sunucunun ayarına bağlı olmamalı; Program.cs'te ValidatorOptions.Global.LanguageManager.Culture = new CultureInfo("en"); satırıyla dili sabitle.
Üçüncüsü, = string.Empty ile gizlenen zorunluluk. Tahmin bölümünde gördün; açık [Required] olmadan boş isimli kayıtlar içeri girer.
Dördüncüsü, aynı request'te iki sistemi karıştırmak. Bir class'ta hem Data Annotations hem FluentValidation kuralı varsa, attribute kurallarından biri bozulduğunda otomatik 400 tetiklenir ve action, dolayısıyla FluentValidation hiç çalışmaz. İstemci kuralların yarısını görür. Bir request için tek bir yöntem seç.
Küçük Bir Deneme
★☆☆ CreateProductRequest'e [Range(0, 1000)] ile sınırlı bir Stock property'si ekle ve -5 gönderip mesajı oku.
★★☆ [NotBlank] adında bir attribute yaz: null, boş string ve sadece boşluktan oluşan string'i reddetsin. Name'e uygula ve " " göndererek dene.
★★★ CreateOrderRequestValidator'a aynı ProductId'nin listede iki kez geçmesini engelleyen bir kural ekle. Sonra uygulamayı LC_ALL=tr_TR.UTF-8 dotnet run ile çalıştırıp mesajların dilini gözle, ardından LanguageManager ayarıyla İngilizce'ye sabitle.
Bu yazıda validation'ın controller tarafını gördük ve hepsinde [ApiController] bizim için çalıştı. .NET 6 ile gelen minimal API'lerde ise ne [ApiController] var ne de otomatik validation. Bir sonraki yazıda MapGet ve MapPost ile minimal API'leri yazacak, aynı ProductCatalog endpoint'lerini controller'sız kuracak ve hangisini ne zaman seçeceğini konuşacağız.