Validační atributy, umožňující ověřit, že zadávané hodnoty odpovídají požadovaným omezením, jsou velmi užitečná věc používaná napříč .NETem. V tomto článku se podíváme na jejich pokročilejší tvorbu a také na to, jak v nich využívat dependency injection.

Jednoduchý validační atribut

Tvorba jednoduchého validačního atributu je, inu, jednoduchá. Podívejte se třeba na atribut z knihovny Altairis Validation Toolkit který ověřuje platnost IČO. Kompletní zdroják najdete na GitHubu, ale v zásadě stačí pár kroků:

Za prvé, vytvořte novou třídu, jejíž název bude končit slovem Attribute, zde např. IcoAttribute. Při použití se pak přípona Attribute nepoužívá, takže atribut bude použit jako [Ico].

Za druhé, Odekorujte třídu atributem AttributeUsage takto:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]

To není absolutně nezbytné, ale nedává smysl používat náš atribut jinde než na vlastnosti nebo ho používat vícekrát, takže omezením použití zabráníme nesmyslné aplikaci.

Za třetí, poděďte třídu od ValidationAttribute.

Za čtvrté, označte třídu jako sealed, což znemožní z ní dále dědit. Není to nezbytné, ale je to doporučené, z důvodů bezpečnostních a výkonnostních.

Za páté, vytvořte obvyklé konstruktory s výchozí chybovou hláškou a možností její změny.

No a konečně, přepište jednoduchý overload metody IsValid, který bere jako argument validovanou hodnotu a vrací true nebo false podle toho, zda je hodnota validní nebo ne. Zdrojový kód jednoduchého atributu IcoAttribute je následující:

[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class IcoAttribute : ValidationAttribute {

    public IcoAttribute()
        : this("The field {0} must be valid IČO (identification number of person).") { }

    public IcoAttribute(Func<string> errorMessageAccessor) 
        : base(errorMessageAccessor) { }

    public IcoAttribute(string errorMessage) 
        : base(errorMessage) { }

    public override bool IsValid(object value) {
        // Empty values are valid - use RequiredAttribute instead
        var s = value as string;
        if (string.IsNullOrWhiteSpace(s)) return true;

        // IČO is 8 digits, pad with zeroes when not
        if (!Regex.IsMatch(s, "^[0-9]{1,8}$")) return false;
        s = s.PadLeft(8, '0');

        // Calculate sum of digits
        var sum = 0;
        for (var i = 0; i < 7; i++) {
            sum += int.Parse(s[i].ToString()) * (8 - i);
        }

        // Verify checksum number
        var chs = 11 - (sum % 11);
        return chs.ToString().EndsWith(s.Substring(7), StringComparison.Ordinal);
    }
}

Složitější atribut s použitím ValidationContext

Tento jednoduchý přístup ale v řadě případů nestačí. Někdy potřebujeme znát víc, než jednu hodnotu. K tomuto účelu slouží třída ValidationContext a jiný overload metody IsValid. Pokud ho chcete využít, musíte v první řadě zařídit, aby vlastnost RequiresValidationContext vracela true:

public override bool RequiresValidationContext => true;

Poté přepište jiný overload metody IsValid, který kromě validované hodnoty dostane i ValidationContext a vrací nikoliv boolean, ale instanci třídy ValidationResult. Pomocí třídy ValidationContext se můžete dostat i k jiným vlastnostem validované entity a podobně.

Podívejte se na zdroják GreaterThanAttribute na GitHubu. Tento atribut porovnává hodnotu s hodnotou jiné vlastnosti.

Použití DI

Nebývá úplně časté používat ve validačních atributech dependency injection, ale možné to je. Typický příklad najdete v nejnovější verzi atributu pro validaci čísla bankovního účtu, který jsem popisoval nedávno. V jeho první verzi jsem použil řešení, na které jsem nebyl moc hrdý, totiž seznam kódů bank je natvrdo zadaný do zdrojáku. Druhá verze je lepší.

Vytvořil jsem jednoduchý interface IBankCodeValidator, který definuje jedinou metodu Validate, která na vstupu dostane string (kód banky) a vrací true nebo false, podle toho zda jde o validní kód banky nebo ne:

public interface IBankCodeValidator {
    bool Validate(string code);
}

Jednoduchou implementaci pak představuje třída StaticBankCodeValidator, která má hardcoded seznam validních kódů:

public class StaticBankCodeValidator : IBankCodeValidator {
    // Bank codes avaliable from https://www.cnb.cz/cs/platebni-styk/.galleries/ucty_kody_bank/download/kody_bank_CR.csv
    // Valid as of 2020-07-01
    private static readonly string[] BankCodes = {
        "0100","0300","0600","0710","0800","2010","2020","2030","2060","2070",
        "2100","2200","2220","2240","2250","2260","2275","2600","2700","3030",
        "3050","3060","3500","4000","4300","5500","5800","6000","6100","6200",
        "6210","6300","6700","6800","7910","7940","7950","7960","7970","7980",
        "7990","8030","8040","8060","8090","8150","8190","8198","8199","8200",
        "8215","8220","8225","8230","8240","8250","8255","8260","8265","8270",
        "8272","8280","8283","8291","8292","8293","8294","8296" };
    public bool Validate(string code) => BankCodes.Contains(code);
}

Součástí knihovny Altairis Validation Toolkit jsou tři implementace:

Použití je pak snadné, stačí zaregistrovat patřičnou implementaci do IoC/DI kontajneru. To můžete pro ten výchozí v .NET Core udělat jedním řádkem v metodě ConfigureServices třídy Startup:

services.AddSingleton<IBankCodeValidator>(new OnlineBankCodeValidator());

Jenomže jak zařídit, aby validační atribut dokázal patřičnou službu získat? Obvykle se v .NET Core používá constructor dependency, kdy se požadované služby předávají jako argumenty konstruktoru. Jenomže tento postup nelze v případě atributů použít.

Nicméně ValidationContext nabízí též metodu GetService, která umožní získat instanci potřebné služby. Toho využívám ve třídě CzechBankAccountAttribute.

Nejprve si definuji privátní field bankCodeValidator a přidám mu výchozí hodnotu:

private IBankCodeValidator bankCodeValidator = new StaticBankCodeValidator();

Ve složitějším overloadu metody IsValid se pokusím získat službu registrovanou v DI a zavolám jednodušší overload téže metody, který provede vlastní validaci:

protected override ValidationResult IsValid(object value, ValidationContext validationContext) {
    this.bankCodeValidator = (IBankCodeValidator)validationContext.GetService(typeof(IBankCodeValidator)) ?? this.bankCodeValidator;
    return this.IsValid(value)
        ? ValidationResult.Success
        : new ValidationResult(this.FormatErrorMessage(validationContext.MemberName), new string[] { validationContext.MemberName });
}

Používám tam poněkud zvláštní konstrukci proměnná = Metoda() ?? proměnná. Jejím účelem je zajistit, aby se proměnná aktualizovala jenom v případě, že metoda vrátí něco jiného než null, např. pokud v kontajneru není nic zaregistrováno. Je to jednodušší ekvivalent následujícího zápisu:

var valService = (IBankCodeValidator)validationContext.GetService(typeof(IBankCodeValidator));
if(valService != null) this.bankCodeValidator = valService;

Acknowledgements: Inspirací pro tento článek mi byl post Injecting services into ValidationAttributes in ASP.NET Core od Andrewa Locka.