Přeskočit na obsah

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.

check #1 · data annotationscheck #2 · manual re-checkcheck #3 · guard clausesRequest DTO[EmailAddress]string CustomerEmail;stringControllerif (!MailAddress.TryCreate(email, …))return ValidationProblem();stringServiceif (!MailAddress.TryCreate(email, …))throw new ArgumentException();Three copies of one rule — the value stays a plain string between layers, so no layer can trust the one before it.

Vezmi si tenhle ukázkový endpoint, který zakládá objednávku. Validace probíhá na třech místech:

  1. Data annotations automaticky validují request DTO a vystaví OpenAPI JSON schéma.
  2. Controller kontroluje pravidla, která se v OpenAPI vyjádřit nedají, abychom selhali rychle, ještě než se spustí jakékoli databázové dotazy.
  3. 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.StrongTypes

A 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 extension

A 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:

Dvoupanelový diagram: bez StrongTypes každá vrstva znovu validuje tentýž obyčejný string; se StrongTypes se hodnota naparsuje jednou na JSON hranici a NonEmptyString nese záruku přes controller, služby i databázi

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.

Byl článek užitečný?

Reagovat a komentovat můžete po přihlášení.

Komentáře

Zapojte se do diskuze

Přihlaste se a přidejte reakci nebo se podělte o svůj názor v komentářích.

Přihlásit se

Nemáte účet?

nebo