Validace v .NET API bez jediného řádku kódu
· 5 min čtení · Aktualizováno
- dotnet
- csharp
- strong-types
- type-safety
- api-design
- validations
Na jednom z minulých projektů jsem si všiml jednoho problému. Byly to validace. Konkrétně to, kolikrát jsem musel napsat to samé pravidlo znovu.
Vezmi si tenhle ukázkový endpoint, který zakládá objednávku. Validace probíhá na třech místech:
- Data annotations automaticky validují request DTO a vystaví OpenAPI JSON schéma.
- Controller kontroluje pravidla, která se v OpenAPI vyjádřit nedají, abychom selhali rychle, ještě než se spustí jakékoli databázové dotazy.
- Služba s business logikou musí kontrolovat znovu, protože ji můžete zavolat i odjinud. Například z background jobu poté, co si z databáze načtete nějakou entitu.
Tři různá místa a snadno zaneseš chybu jenom tím, že jedno z nich zapomeneš upravit.
public record CreateOrderRequest(
[EmailAddress] string CustomerEmail,
string ProductCode,
[Range(1, int.MaxValue)] int Quantity);
[HttpPost]
[ProducesResponseType<OrderResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request)
{
// We need to parse, because data annotations don't change the type.
if (!MailAddress.TryCreate(request.CustomerEmail, out _))
return ValidationProblem("Customer email is not valid.");
try
{
var order = await orders.PlaceAsync(request.CustomerEmail, request.ProductCode, request.Quantity);
return Ok(OrderResponse.From(order));
}
catch (UnknownProductException) // Could be checked explicitly via a separate method
{
return ValidationProblem("No product matches that code.");
}
catch (OutOfStockException)
{
return ValidationProblem("Not enough stock to fill the order.");
}
}Data annotations ověří e-mailovou adresu, ale pořád mám jen string. Podobně je celé číslo ověřené jako kladné, jenže tu informaci nikde nedržím. Pak se dostaneme do služby:
public async Task<Order> PlaceAsync(string customerEmail, string productCode, int quantity)
{
// Must be checked. Even though the controller already checked all this.
if (string.IsNullOrWhiteSpace(productCode))
throw new ArgumentException("Product code is required.", nameof(productCode));
if (quantity <= 0)
throw new ArgumentOutOfRangeException(nameof(quantity), "Quantity must be positive.");
if (!await catalog.ContainsAsync(productCode))
throw new UnknownProductException(productCode);
// The real work here.
}PlaceAsync nakonec ručí za to, že všechno funguje. Může ho zavolat controller, ale klidně i background worker nebo jiný endpoint. Takže musí validovat znovu.
Parsuj, nevaliduj
Řešení je stará myšlenka z funkcionálního programování: místo kontrolování hodnoty a posílání téhož volného typu dál ji jednou převeď do typu, který neplatný stav vůbec neumí reprezentovat. int, který právě prošel kontrolou > 0, je pořád jen int. Důkaz se zahodí ve chvíli, kdy kontrola projde, takže další metoda níž musí kontrolovat znovu. Positive<int> si ten důkaz nese přímo v typu a kompilátor ho vymáhá všude níž.
Přesně na to jsem postavil balíček Kalicz.StrongTypes. Je to malá kolekce C# typů. Například NonEmptyString, Email, NonEmptyEnumerable<T>, pár numerických typů jako Positive<T>, NonNegative<T> a další užitečné typy a pomocníky. Result<T, TError> je další mimořádně užitečný typ, který umí reprezentovat úspěšný nebo chybový stav vaší operace. A pak je tu Maybe<T>, který pomůže reprezentovat tři stavy v API. Hodí se třeba, když chceš rozlišit mezi změnou hodnoty na něco, změnou hodnoty na nic a neměněním hodnoty.
Nezmínil jsem tady zdaleka všechno, co knihovna nabízí, pokud tě to zajímá, mrkni na GitHub. Knihovnu hodlám dál rozšiřovat. Například chystám Interval<T>, který usnadní reprezentovat rozsah začátek + konec s validací, že konec je po začátku. O něm napíšu samostatný článek.
„Bez jediného řádku“
Stačí jeden řádek na instalaci:
dotnet add package Kalicz.StrongTypesA můžeš rovnou začít typy používat. Třeba takhle:
public record CreateOrderRequest(
Email CustomerEmail,
NonEmptyString ProductCode,
Positive<int> Quantity);
[HttpPost]
[ProducesResponseType<OrderResponse>(StatusCodes.Status200OK)]
[ProducesResponseType<ValidationProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OrderResponse>> Create(CreateOrderRequest request)
{
// No validations needed here.
var result = await orders.PlaceAsync(request.CustomerEmail, request.ProductCode, request.Quantity);
if (result.Error is { } error)
return error switch
{
PlaceOrderError.UnknownProduct => FieldProblem(nameof(request.ProductCode), "No product matches that code."),
PlaceOrderError.OutOfStock => FieldProblem(nameof(request.Quantity), "Not enough stock to fill the order."),
};
return Ok(OrderResponse.From(result.Success!));
}
private ActionResult FieldProblem(string field, string message)
{
ModelState.AddModelError(field, message);
return ValidationProblem(ModelState);
}Data annotations jsou pryč. Mapování na MailAddress je pryč. Všechny informace o hodnotách teď žijí přímo v typech. A protože metoda místo házení výjimek vrací Result, kompilátor přesně ví, jaké chyby můžou nastat.
Všechny typy se serializují do JSONu a zpátky bez jakékoli konfigurace. A stejně tak se správně aktualizuje OpenAPI schéma. Stačí přidat balíček Kalicz.StrongTypes.OpenApi.Swashbuckle (nebo variantu pro Microsoft.AspNetCore.OpenApi) a zaregistrovat ho:
// Program.cs - install Kalicz.StrongTypes.OpenApi.Swashbuckle, then one call
builder.Services.AddSwaggerGen(options => options.AddStrongTypes());Request výše pak vygeneruje přesně ta omezení, která klient potřebuje:
{
"customerEmail": { "type": "string", "format": "email", "minLength": 1, "maxLength": 254 }, // Email
"productCode": { "type": "string", "minLength": 1 }, // NonEmptyString
"quantity": { "type": "integer", "format": "int32", "minimum": 0, "exclusiveMinimum": true } // Positive<int>
}Signatury jako dokumentace
Nejhezčí na tom je, o kolik je díky tomu kód srozumitelnější. Každá metoda může vyslovit své skutečné požadavky:
// Before: every caller re-checks, every reader wonders.
Task<Order> PlaceAsync(string email, string code, int quantity);
// After: the signature is the contract.
Task<Result<Order, PlaceOrderError>> PlaceAsync(Email email, NonEmptyString code, Positive<int> quantity);Druhá signatura odpovídá na otázky, které tě bys s tou první musel dohledávat: kód nemůže být prázdný, množství nemuže být nula. Kolegové na review se přestanou ptát „a co když je množství -3?“, protože to nejde zkompilovat.
Business pravidla, která selhat legitimně můžou, dostanou totéž přes Result<T, TError>. Chyba se stane hodnotou v signatuře, ne překvapením v podobě výjimky:
Ale hlavně, když si tenhle kód přečteš za pět měsíců, nemusíš si pamatovat kontext. Nemusíš zjišťovat, odkud hodnoty přicházejí a co všechno můžou obsahovat. A noví lidé v týmu se tě nemusí vyptávat. Kód prostě velmi přesně popisuje, co se děje. Přidává jasnost a kontext.
public async Task<Result<Order, PlaceOrderError>> PlaceAsync(Email email, NonEmptyString code, Positive<int> quantity)
{
if (!await catalog.ContainsAsync(code))
return PlaceOrderError.UnknownProduct;
if (!await inventory.HasStockAsync(code, quantity))
return PlaceOrderError.OutOfStock;
return new Order(email, code, quantity);
}Typy uložíš i databáze
Když entitu načteš do kontextu přes EF Core, musel bys validovat znovu. Ale s balíčkem Kalicz.StrongTypes.EfCore se silné typy stanou přímo vlastnostmi entity. Jediné volání na options builderu navěsí value converter na každý z nich:
public sealed class Order
{
public Guid Id { get; init; }
public Email CustomerEmail { get; init; }
public NonEmptyString ProductCode { get; init; }
public Positive<int> Quantity { get; init; }
}
// Program.cs - StrongTypes registration for db context.
services.AddDbContext<ShopDbContext>(options => options
.UseNpgsql(connectionString)
.UseStrongTypes());Sloupec v databázi je pořád obyčejný varchar nebo int, na úložišti se nemění nic. Ale EF Core teď nedovolí uložit neplatné hodnoty. A když se pokusíš neplatná data z databáze načíst, dostaneš hlasitou chybu místo tiché null hodnoty. Naparsovaná informace tak zůstane zachovaná napořád. Entitu si můžeš načíst a bezpečně zavolat PlaceAsync.
Méně edge-casů, méně testů
Počet potřebných unit testů rapidně klesl. Tyhle hraniční případy prostě nejsou možné. Jako NonEmptyString nepředáš null, "" ani " ". Do množství nepředáš -1. Zbydou testy o chování. Ty, které maji smysl. A API pro tvorbu těchto typů je velmi explicitní v tom, co chceš dělat:
NonEmptyString name = NonEmptyString.Create(input); // throws on invalid
NonEmptyString? safe = NonEmptyString.TryCreate(input); // null on invalid
NonEmptyString? fluent = input.AsNonEmpty(); // same, as an extensionA pro vlastnosti, které si ověřit chceš, ti property-based testing dovolí napsat jednu testovací metodu a prohnat skrz ni stovky různých hodnot. Balíček Kalicz.StrongTypes.FsCheck generuje validní silně typované hodnoty pro property-based testy, takže tvoje generátory respektují stejné invarianty jako váš kód. Napiš mi do komentářů, jestli by o property-based testingu chtěl samostatný článek.
Vyzkoušej to
Celý ten posun v jednom obrázku. Validace opakovaná v každé vrstvě versus jedno naparsování na hranici:
I backend za tímhle webem běží na StrongTypes. Request DTO, doménové eventy, stav entit. Pokud tě ten přístup zaujal, na stránce StrongTypes najdeš diagramy a další ukázky, zdrojáky žijí na GitHubu a balíček je na NuGetu. Začni jedním request recordem. Smaž guard clauses, které tím ztratí smysl. Do odpoledne budeš vědět, že to chceš všude.