ASP.NET Core MVC

Валідація: IValidatableObject та FluentValidation

Просунута валідація в ASP.NET Core MVC: IValidatableObject для cross-field правил, Remote validation через [Remote], власні ValidationAttribute, інтеграція FluentValidation з MVC. Демо: форма реєстрації з [Remote] та FluentValidation для складних бізнес-правил замовлення.

Валідація: IValidatableObject та FluentValidation

У курсі Razor Pages ви ознайомилися з базовою валідацією через DataAnnotations: [Required], [StringLength], [Range], [EmailAddress]. Це чудово для простих форм — але що з правилами, які стосуються кількох полів одночасно? Або правилами що потребують звернення до бази даних (чи зайнятий цей email)? Або правилами що занадто складні для атрибутів (якщо метод доставки "кур'єр", тоді адреса обов'язкова)?

ASP.NET Core MVC пропонує три рівні відповіді на ці питання. Від простого до потужного:

  1. IValidatableObject — крос-field правила безпосередньо в моделі
  2. Custom ValidationAttribute — повторювані перевірки у вигляді атрибутів
  3. [Remote] validation — перевірки на сервері без перезавантаження сторінки
  4. FluentValidation — повноцінний framework для складних бізнес-правил з тестованим кодом

IValidatableObject: крос-field правила в моделі

Найпростіший спосіб валідації кількох полів одночасно — реалізація інтерфейсу IValidatableObject. Метод Validate викликається після успішної валідації всіх DataAnnotations, тому можна бути впевненим що базові обмеження вже перевірено.

Models/CheckoutDto.cs
using System.ComponentModel.DataAnnotations;

namespace ShopApp.Models;

public class CheckoutDto : IValidatableObject
{
    [Required(ErrorMessage = "Вкажіть ім'я")]
    [StringLength(100, MinimumLength = 2)]
    public string FullName { get; set; } = "";

    [Required, EmailAddress]
    public string Email { get; set; } = "";

    [Required(ErrorMessage = "Оберіть метод доставки")]
    public string DeliveryMethod { get; set; } = ""; // "courier" або "pickup"

    // Обов'язкове ЛИШЕ для кур'єрської доставки
    public string? DeliveryAddress { get; set; }

    // Payment method
    [Required]
    public string PaymentMethod { get; set; } = ""; // "card" або "cash"

    // Card fields — обов'язкові ЛИШЕ якщо PaymentMethod == "card"
    public string? CardNumber { get; set; }
    public string? CardExpiry { get; set; }

    [Range(1, int.MaxValue, ErrorMessage = "Сума замовлення має бути більшою за нуль")]
    public decimal OrderTotal { get; set; }

    // IValidatableObject: крос-field валідація
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // Правило 1: кур'єрська доставка потребує адреси
        if (DeliveryMethod == "courier" && string.IsNullOrWhiteSpace(DeliveryAddress))
        {
            yield return new ValidationResult(
                "Для кур'єрської доставки вкажіть адресу",
                [nameof(DeliveryAddress)]  // ← до якого поля прив'язати помилку
            );
        }

        // Правило 2: оплата карткою потребує номера та терміну дії
        if (PaymentMethod == "card")
        {
            if (string.IsNullOrWhiteSpace(CardNumber))
            {
                yield return new ValidationResult(
                    "Введіть номер картки",
                    [nameof(CardNumber)]
                );
            }

            if (string.IsNullOrWhiteSpace(CardExpiry))
            {
                yield return new ValidationResult(
                    "Введіть термін дії картки",
                    [nameof(CardExpiry)]
                );
            }
        }

        // Правило 3: самовивіз не підтримується для замовлень більше 5000 грн
        if (DeliveryMethod == "pickup" && OrderTotal > 5000)
        {
            yield return new ValidationResult(
                "Самовивіз доступний лише для замовлень до 5000 ₴. " +
                "Для великих замовлень оберіть кур'єрську доставку.",
                [nameof(DeliveryMethod), nameof(OrderTotal)]  // ← прив'язка до двох полів
            );
        }
    }
}

Розберемо ключові моменти реалізації:

yield return new ValidationResult(...) — метод Validate є генератором. Кожен yield return — це окрема помилка валідації. Якщо всі правила виконані — жодного yield return, метод повертає порожню колекцію.

Другий аргумент ValidationResult — масив імен властивостей, до яких прив'язана помилка. Це важливо: MVC відображає помилку у <span asp-validation-for="DeliveryAddress"> саме завдяки цьому. Якщо передати null — помилка стає «загальною» (ModelState[""]) і відображається в asp-validation-summary.

Порядок виконання: спочатку перевіряються усі [Required], [StringLength] тощо. Лише якщо всі вони пройшли — Validate(). Тобто в Validate() гарантовано ненульові required-поля.

IValidatableObject.Validate не потрібно викликати вручну. ASP.NET Core MVC робить це автоматично як частину ModelState.IsValid. Аналогічно — на клієнті jQuery Validation виклик Validate()не підтримується: цей метод виконується лише на сервері.

Власний ValidationAttribute

Коли одне й те саме правило потрібно в кількох моделях — IValidatableObject починає дублюватися. Тоді правило виносять у власний атрибут.

Власний атрибут — це клас, що успадковує ValidationAttribute та перевизначає метод IsValid:

Attributes/NoHtmlAttribute.cs
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;

namespace ShopApp.Attributes;

// Атрибут: забороняє HTML-теги у рядках (захист від XSS)
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class NoHtmlAttribute : ValidationAttribute
{
    private static readonly Regex HtmlRegex =
        new("<[^>]+>", RegexOptions.Compiled | RegexOptions.IgnoreCase);

    public NoHtmlAttribute()
    {
        // Повідомлення за замовчуванням (якщо не передано через ErrorMessage)
        ErrorMessage = "Поле '{0}' не може містити HTML-теги";
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is null) return ValidationResult.Success; // необов'язкове поле — ОК

        var str = value.ToString()!;

        if (HtmlRegex.IsMatch(str))
        {
            // context.DisplayName — локалізована назва поля (з [Display(Name="...")])
            var errorMessage = FormatErrorMessage(context.DisplayName);
            return new ValidationResult(errorMessage, [context.MemberName!]);
        }

        return ValidationResult.Success;
    }
}
Attributes/FutureDateAttribute.cs
using System.ComponentModel.DataAnnotations;

namespace ShopApp.Attributes;

// Атрибут: дата має бути в майбутньому
public class FutureDateAttribute : ValidationAttribute
{
    public int MinDaysAhead { get; set; } = 0; // мінімум X днів у майбутньому

    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is not DateTime date) return ValidationResult.Success;

        var minDate = DateTime.Today.AddDays(MinDaysAhead);

        if (date < minDate)
        {
            var msg = MinDaysAhead > 0
                ? $"Дата має бути щонайменше через {MinDaysAhead} дн. від сьогодні"
                : "Дата має бути в майбутньому";
            return new ValidationResult(msg, [context.MemberName!]);
        }

        return ValidationResult.Success;
    }
}

Використання — як звичайні DataAnnotations:

Models/CreateEventDto.cs
using ShopApp.Attributes;

public class CreateEventDto
{
    [Required]
    [NoHtml] // ← власний атрибут
    public string Title { get; set; } = "";

    [NoHtml]
    public string? Description { get; set; }

    [Required]
    [FutureDate(MinDaysAhead = 3)] // ← з параметром
    public DateTime EventDate { get; set; }
}

IClientValidatable: клієнтська валідація для власних атрибутів

Власні атрибути за замовчуванням валідуються лише на сервері. Щоб додати клієнтську валідацію через jQuery Validation, реалізуйте IClientModelValidator:

Attributes/FutureDateAttribute.cs (клієнтська частина)
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;

// Додати до FutureDateAttribute:
public class FutureDateAttribute : ValidationAttribute, IClientModelValidator
{
    // ...

    public void AddValidation(ClientModelValidationContext context)
    {
        // Додаємо data-атрибути для jQuery Validation
        context.Attributes["data-val"] = "true";
        context.Attributes["data-val-futuredate"] =
            $"Дата має бути в майбутньому";
        context.Attributes["data-val-futuredate-mindays"] =
            MinDaysAhead.ToString();
    }
}
// Потрібно зареєструвати адаптер jQuery Validation у Scripts
$.validator.addMethod("futuredate", function(value, element, params) {
    const minDays = parseInt(params.mindays);
    const minDate = new Date();
    minDate.setDate(minDate.getDate() + minDays);
    const inputDate = new Date(value);
    return inputDate >= minDate;
});

$.validator.unobtrusive.adapters.add("futuredate", ["mindays"], function(options) {
    options.rules["futuredate"] = { mindays: options.params.mindays };
    options.messages["futuredate"] = options.message;
});

Remote Validation: Remote — перевірка на сервері в реальному часі

[Remote] — атрибут що дозволяє перевіряти значення поля ajax-запитом до сервера без перезавантаження сторінки. Ідеальний сценарій: перевірка унікальності email під час реєстрації.

Models/RegisterDto.cs
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel.DataAnnotations;

namespace BlogApp.Models;

public class RegisterDto
{
    [Required, StringLength(50, MinimumLength = 3)]
    [Display(Name = "Нікнейм")]
    // Remote: при blur або change поля — надсилається GET на /account/checkusername?Username=...
    [Remote(
        action: "CheckUsername",    // ← назва Action-методу
        controller: "Account",      // ← назва Controller
        ErrorMessage = "Цей нікнейм вже зайнятий"
    )]
    public string Username { get; set; } = "";

    [Required, EmailAddress]
    [Display(Name = "Email")]
    [Remote("CheckEmail", "Account",
        ErrorMessage = "Цей email вже зареєстрований",
        AdditionalFields = nameof(Id)  // ← передати додаткове поле разом з запитом
    )]
    public string Email { get; set; } = "";

    // Потрібно для AdditionalFields при редагуванні (щоб не блокувати власний email)
    public int Id { get; set; }

    [Required, DataType(DataType.Password)]
    [StringLength(100, MinimumLength = 8)]
    public string Password { get; set; } = "";

    [Required, DataType(DataType.Password)]
    [Display(Name = "Підтвердження паролю")]
    [Compare(nameof(Password), ErrorMessage = "Паролі не збігаються")]
    public string ConfirmPassword { get; set; } = "";
}

Action-методи для Remote validation повертають JsonResult з true (valid) або рядком помилки:

Controllers/AccountController.cs
using Microsoft.AspNetCore.Mvc;
using BlogApp.Services;

namespace BlogApp.Controllers;

public class AccountController : Controller
{
    private readonly IUserService _users;

    public AccountController(IUserService users)
    {
        _users = users;
    }

    // Remote validation: GET /account/checkusername?Username=mylogin
    [AcceptVerbs("GET", "POST")] // jQuery Validation надсилає GET; POST — для форм з antiforgery
    public async Task<IActionResult> CheckUsername(string username)
    {
        var isTaken = await _users.IsUsernameTakenAsync(username);

        // true → валідне; рядок → повідомлення про помилку
        return isTaken
            ? Json($"Нікнейм «{username}» вже зайнятий")
            : Json(true);
    }

    // Remote з AdditionalFields: GET /account/checkemail?Email=x@x.com&Id=5
    [AcceptVerbs("GET", "POST")]
    public async Task<IActionResult> CheckEmail(string email, int id)
    {
        // Якщо id > 0 — редагування: не блокуємо власний email
        var existingUser = await _users.FindByEmailAsync(email);
        var isTaken = existingUser is not null && existingUser.Id != id;

        return isTaken
            ? Json($"Email «{email}» вже використовується")
            : Json(true);
    }

    [HttpGet]
    public IActionResult Register() => View(new RegisterDto());

    [HttpPost]
    public async Task<IActionResult> Register(RegisterDto dto)
    {
        if (!ModelState.IsValid) return View(dto);

        await _users.CreateAsync(dto.Username, dto.Email, dto.Password);
        TempData["Success"] = $"Акаунт «{dto.Username}» успішно створено!";
        return RedirectToAction("Login");
    }
}
[Remote] спрацьовує лише при наявності клієнтських скриптів валідації: jQuery Validation + Unobtrusive Validation (_ValidationScriptsPartial). На сервері при ModelState.IsValid Remote перевірка також виконується автоматично як звичайний атрибут.
Remote validation надсилає GET-запит (або POST з HttpMethod = "POST"). Ніколи не виконуйте в ньому операцій що змінюють стан (запис, оновлення даних). Remote — лише для читання з метою перевірки.

FluentValidation: validation як код, а не атрибути

DataAnnotations та IValidatableObject добре для простих сценаріїв, але мають суттєві обмеження:

  • Важко тестувати (логіка в атрибутах або самій моделі)
  • Не підтримують складні умовні правила
  • Правила прив'язані до моделі (не можна окремо замінити для різних контекстів)
  • Обмежена підтримка валідації колекцій

FluentValidation — бібліотека що дозволяє описувати правила валідації у вигляді окремих класів-валідаторів з fluent API:

// DataAnnotations підхід (в моделі)
[Required]
[StringLength(100, MinimumLength = 2)]
[RegularExpression(@"^[\w\s]+$")]
public string Title { get; set; } = "";

// FluentValidation підхід (в окремому класі)
RuleFor(x => x.Title)
    .NotEmpty().WithMessage("Заголовок обов'язковий")
    .Length(2, 100).WithMessage("Від 2 до 100 символів")
    .Matches(@"^[\w\s]+$").WithMessage("Лише букви та пробіли");

Встановлення та підключення

dotnet add package FluentValidation.AspNetCore
Program.cs
using FluentValidation;
using FluentValidation.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllersWithViews();

// Реєструємо FluentValidation — він автоматично знаходить валідатори у збірці
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
// або явно: builder.Services.AddScoped<IValidator<OrderDto>, OrderDtoValidator>();

AddFluentValidationAutoValidation() інтегрує FluentValidation у ModelState: якщо є зареєстрований валідатор для типу, він виконується автоматично при ModelState.IsValid у Controller. Жодних змін у Controller-коді.

Базовий валідатор: реєстраційна форма

Validators/RegisterDtoValidator.cs
using FluentValidation;
using BlogApp.Models;
using BlogApp.Services;

namespace BlogApp.Validators;

public class RegisterDtoValidator : AbstractValidator<RegisterDto>
{
    private readonly IUserService _users;

    // DI — валідатор може отримувати сервіси!
    public RegisterDtoValidator(IUserService users)
    {
        _users = users;

        // Правила для Username
        RuleFor(x => x.Username)
            .NotEmpty().WithMessage("Нікнейм обов'язковий")
            .Length(3, 50).WithMessage("Від 3 до 50 символів")
            .Matches(@"^[a-zA-Z0-9_]+$")
                .WithMessage("Лише латинські букви, цифри та підкреслення")
            .MustAsync(BeUniqueUsername)
                .WithMessage(x => $"Нікнейм «{x.Username}» вже зайнятий");

        // Правила для Email
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("Email обов'язковий")
            .EmailAddress().WithMessage("Некоректний формат email")
            .MustAsync(BeUniqueEmail)
                .WithMessage(x => $"Email «{x.Email}» вже зареєстрований");

        // Правила для Password
        RuleFor(x => x.Password)
            .NotEmpty().WithMessage("Пароль обов'язковий")
            .MinimumLength(8).WithMessage("Мінімум 8 символів")
            .Matches("[A-Z]").WithMessage("Хоча б одна велика буква")
            .Matches("[0-9]").WithMessage("Хоча б одна цифра")
            .Matches("[^a-zA-Z0-9]").WithMessage("Хоча б один спеціальний символ");

        // Крос-field правило: ConfirmPassword
        RuleFor(x => x.ConfirmPassword)
            .Equal(x => x.Password)
            .WithMessage("Паролі не збігаються");
    }

    // Async правила через MustAsync — можна звертатися до БД
    private async Task<bool> BeUniqueUsername(string username, CancellationToken ct)
        => !await _users.IsUsernameTakenAsync(username);

    private async Task<bool> BeUniqueEmail(RegisterDto dto, string email, CancellationToken ct)
    {
        var existing = await _users.FindByEmailAsync(email);
        return existing is null || existing.Id == dto.Id;
    }
}

Просунутий валідатор: складне замовлення

Ось де FluentValidation дійсно сяє — складні умовні правила, вкладені об'єкти, валідація колекцій:

Models/OrderDto.cs
namespace ShopApp.Models;

public class OrderDto
{
    public string DeliveryMethod { get; set; } = ""; // "courier" | "pickup" | "nova-poshta"
    public string? DeliveryAddress { get; set; }
    public string? NovaPoshtaBranch { get; set; } // відділення Нової Пошти

    public string PaymentMethod { get; set; } = ""; // "card" | "cash" | "invoice"
    public string? CardNumber { get; set; }

    public List<OrderItemDto> Items { get; set; } = [];

    public string? PromoCode { get; set; }
    public string CustomerEmail { get; set; } = "";
}

public class OrderItemDto
{
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}
Validators/OrderDtoValidator.cs
using FluentValidation;
using ShopApp.Models;
using ShopApp.Services;

namespace ShopApp.Validators;

public class OrderDtoValidator : AbstractValidator<OrderDto>
{
    public OrderDtoValidator(IPromoCodeService promoCodes)
    {
        // ── Базові правила ─────────────────────────────────────────────
        RuleFor(x => x.CustomerEmail)
            .NotEmpty().EmailAddress()
            .WithMessage("Введіть коректний email");

        // ── Умовна валідація доставки ───────────────────────────────────
        // When/Unless — умовне застосування правил
        RuleFor(x => x.DeliveryAddress)
            .NotEmpty().WithMessage("Для кур'єрської доставки вкажіть адресу")
            .When(x => x.DeliveryMethod == "courier");

        RuleFor(x => x.NovaPoshtaBranch)
            .NotEmpty().WithMessage("Вкажіть відділення Нової Пошти")
            .Matches(@"^\d+$").WithMessage("Номер відділення — лише цифри")
            .When(x => x.DeliveryMethod == "nova-poshta");

        // ── Умовна валідація оплати ─────────────────────────────────────
        RuleFor(x => x.CardNumber)
            .NotEmpty().WithMessage("Введіть номер картки")
            .CreditCard().WithMessage("Некоректний номер картки")
            .When(x => x.PaymentMethod == "card");

        // Unless — протилежне: виконувати ЯКЩО NOT умова
        RuleFor(x => x.DeliveryMethod)
            .Must(m => m != "pickup")
            .WithMessage("Накладений платіж доступний лише при самовивозі")
            .Unless(x => x.PaymentMethod == "cash"); // якщо не cash — обмеження знімається

        // ── Валідація колекцій через RuleForEach ───────────────────────
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Замовлення має містити хоча б один товар")
            .Must(items => items.Count <= 50)
                .WithMessage("Максимум 50 позицій в одному замовленні");

        // RuleForEach — правило для кожного елемента List<>
        RuleForEach(x => x.Items).SetValidator(new OrderItemValidator());

        // ── Async правило з сервісом ────────────────────────────────────
        When(x => !string.IsNullOrEmpty(x.PromoCode), () =>
        {
            RuleFor(x => x.PromoCode!)
                .MustAsync(async (code, ct) =>
                    await promoCodes.IsValidAsync(code))
                .WithMessage(x => $"Промо-код «{x.PromoCode}» недійсний або використаний");
        });

        // ── Transform: конвертація значення перед валідацією ────────────
        // Нормалізуємо email (trim + lowercase) перед перевіркою
        Transform(x => x.CustomerEmail, v => v?.Trim().ToLowerInvariant() ?? "");
    }
}

// Окремий валідатор для вкладеного типу
public class OrderItemValidator : AbstractValidator<OrderItemDto>
{
    public OrderItemValidator()
    {
        RuleFor(x => x.ProductId)
            .GreaterThan(0).WithMessage("Некоректний ідентифікатор товару");

        RuleFor(x => x.Quantity)
            .InclusiveBetween(1, 100)
            .WithMessage("Кількість від 1 до 100");

        RuleFor(x => x.UnitPrice)
            .GreaterThan(0).WithMessage("Ціна має бути більшою за нуль");
    }
}

Декілька ключових конструкцій FluentValidation що потрібно запам'ятати:

When(condition, rules) — застосовує правила лише якщо умова виконана. Ідеально для залежних полів.

Unless(condition, rules) — протилежне: застосовує правила лише якщо умова не виконана.

RuleForEach(x => x.Collection) — правила для кожного елемента колекції. Помилки прив'язуються до Items[0].Quantity, Items[1].ProductId тощо.

MustAsync(async (value, ct) => ...) — async правило із зверненням до сервісів.

Transform(x => x.Field, v => transform(v)) — перетворити значення перед валідацією (нормалізація).

FluentValidation у Controller — нічого не змінюється

Controllers/OrderController.cs
using Microsoft.AspNetCore.Mvc;
using ShopApp.Models;

namespace ShopApp.Controllers;

public class OrderController : Controller
{
    private readonly IOrderService _orders;

    public OrderController(IOrderService orders)
    {
        _orders = orders;
    }

    [HttpGet]
    public IActionResult Checkout() => View(new OrderDto());

    [HttpPost]
    public async Task<IActionResult> Checkout(OrderDto dto)
    {
        // ModelState.IsValid — FluentValidation виконується автоматично
        // Жодних змін у Controller коді порівняно з DataAnnotations
        if (!ModelState.IsValid) return View(dto);

        var order = await _orders.CreateAsync(dto);
        TempData["Success"] = $"Замовлення #{order.Id} оформлено!";
        return RedirectToAction("Confirmation", new { id = order.Id });
    }
}

Тестування FluentValidation-валідаторів

Ключова перевага FluentValidation — валідатор є звичайним C# класом, що легко тестувати:

Tests/OrderDtoValidatorTests.cs
using FluentValidation.TestHelper;
using ShopApp.Models;
using ShopApp.Validators;

namespace ShopApp.Tests;

public class OrderDtoValidatorTests
{
    private readonly OrderDtoValidator _validator;

    public OrderDtoValidatorTests()
    {
        // Mock сервіс промо-кодів
        var promoServiceMock = new Mock<IPromoCodeService>();
        promoServiceMock.Setup(x => x.IsValidAsync("VALID10")).ReturnsAsync(true);
        promoServiceMock.Setup(x => x.IsValidAsync("EXPIRED")).ReturnsAsync(false);

        _validator = new OrderDtoValidator(promoServiceMock.Object);
    }

    [Fact]
    public async Task Should_Require_Address_For_Courier_Delivery()
    {
        var dto = new OrderDto
        {
            DeliveryMethod = "courier",
            DeliveryAddress = null // ← помилка
        };

        var result = await _validator.TestValidateAsync(dto);

        // FluentValidation TestHelper — чіткий синтаксис перевірки
        result.ShouldHaveValidationErrorFor(x => x.DeliveryAddress)
              .WithErrorMessage("Для кур'єрської доставки вкажіть адресу");
    }

    [Fact]
    public async Task Should_Not_Require_Address_For_Pickup()
    {
        var dto = new OrderDto
        {
            DeliveryMethod = "pickup",
            DeliveryAddress = null // ← ОК, самовивіз — адреса не потрібна
        };

        var result = await _validator.TestValidateAsync(dto);

        result.ShouldNotHaveValidationErrorFor(x => x.DeliveryAddress);
    }
}

TestValidateAsync та ShouldHaveValidationErrorFor — методи з пакета FluentValidation.TestHelper що роблять тестування валідаторів надзвичайно читабельним.


Порівняння підходів

DataAnnotationsIValidatableObjectCustom AttributeFluentValidation
Просте поле✅ ІдеальноОverkillOverkillОк
Крос-field правила❌ Не підтримує✅ ІдеальноСкладноWhen()
Правило з DI/БДСкладноMustAsync
Remote validation[Remote]Окремо
Тестованість❌ Погано❌ СередньоСередньо✅ Відмінно
Різні правила для різних контекстів✅ Окремі валідатори
Клієнтська валідація✅ АвтоматичнаПотрібен адаптер❌ Лише серверна

Відображення помилок FluentValidation у View

Помилки FluentValidation автоматично потрапляють у ModelState — тому стандартні Tag Helpers працюють без змін:

Views/Order/Checkout.cshtml
@model ShopApp.Models.OrderDto

@* Summary — загальні помилки (без прив'язки до поля) *@
<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>

<form asp-action="Checkout" method="post">
    <div class="mb-3">
        <label asp-for="CustomerEmail" class="form-label">Email</label>
        <input asp-for="CustomerEmail" class="form-control" type="email">
        @* Помилки FluentValidation з'являються тут *@
        <span asp-validation-for="CustomerEmail" class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="DeliveryMethod" class="form-label">Метод доставки</label>
        <select asp-for="DeliveryMethod" class="form-select"
                onchange="updateDeliveryFields(this.value)">
            <option value="">— Оберіть —</option>
            <option value="pickup">Самовивіз</option>
            <option value="courier">Кур'єр</option>
            <option value="nova-poshta">Нова Пошта</option>
        </select>
        <span asp-validation-for="DeliveryMethod" class="text-danger"></span>
    </div>

    @* Умовні поля — показуємо/приховуємо через JS *@
    <div id="address-field" class="mb-3" style="display:none;">
        <label asp-for="DeliveryAddress" class="form-label">Адреса доставки</label>
        <input asp-for="DeliveryAddress" class="form-control">
        <span asp-validation-for="DeliveryAddress" class="text-danger"></span>
    </div>

    <button type="submit" class="btn btn-primary">Оформити замовлення</button>
</form>

@section Scripts {
    <partial name="_ValidationScriptsPartial"/>
    <script>
        function updateDeliveryFields(method) {
            document.getElementById("address-field").style.display =
                method === "courier" ? "block" : "none";
        }
    </script>
}

Практичні завдання

Рівень 1 — Базовий

Завдання 1.1. Для моделі UserProfileDto (Username, Bio, WebsiteUrl, BirthDate) реалізуйте IValidatableObject.Validate з такими крос-field правилами:

  • Якщо Bio не порожнє і містить WebsiteUrl — повернути помилку «Вкажіть сайт у відповідному полі, а не в біографії»
  • Якщо BirthDate < DateTime(1900, 1, 1) — повернути помилку «Некоректна дата народження»
  • Якщо BirthDate > DateTime.Today.AddYears(-13) — «Вік має бути більше 13 років»

Завдання 1.2. Створіть власний атрибут [NotContains(string forbidden)] що перевіряє відсутність певного підрядка у значенні. Застосуйте: [NotContains("admin")] до поля Username. Повідомлення: «Поле не може містити "{forbidden}"».

Рівень 2 — Логіка

Завдання 2.1. Реалізуйте Remote validation для поля Username:

  • Controller AccountController.CheckUsernameAvailability(string username)
  • Перевірка: username не є зарезервованим словом (admin, root, support, api) та не зайнятий у БД
  • Якщо зарезервований — повернути "Цей нікнейм зарезервований системою", якщо зайнятий — "Вже використовується", інакше — true
  • Додайте AdditionalFields = nameof(Id) для підтримки редагування профілю

Завдання 2.2. Напишіть AbstractValidator<CheckoutDto> (FluentValidation) що замінює IValidatableObject з початку статті. Забезпечте всі три крос-field правила через When() та Unless(). Напишіть 3 юніт-тести Should_Require_Address_When_Courier, Should_Not_Require_Card_When_Cash, Should_Block_Pickup_For_Large_Order_With_Cash.

Рівень 3 — Архітектура

Завдання 3.1. Реалізуйте повноцінну форму реєстрації з кількома кроками (wizard):

  • Крок 1: AccountInfoDto (Username, Email, Password, ConfirmPassword) — FluentValidation з MustAsync для перевірки унікальності
  • Крок 2: PersonalInfoDto (FirstName, LastName, BirthDate, PhoneNumber) — власний PhoneUaAttribute що валідує формат +380XXXXXXXXX
  • Крок 3: PreferencesDto (NotificationsEnabled, PreferredLanguage, AgreeToTerms) — [Remote] для перевірки чи вибрана PreferredLanguage підтримується системою
  • Зберігайте проміжні кроки у TempData (серіалізація в JSON). Завершення — лише якщо ВСІ три кроки пройшли валідацію

Резюме

  • IValidatableObject — крос-field правила в тілі моделі через Validate(). Простий але не тестовий, не масштабується до складних правил
  • Custom ValidationAttribute — повторювані правила у вигляді атрибутів. Клієнтська підтримка через IClientModelValidator
  • [Remote] — ajax-перевірка поля на сервері в реальному часі. Action повертає Json(true) або Json("повідомлення"). Лише GET-запит, лише читання
  • FluentValidation — окремий клас-валідатор з fluent API. Підтримує: When/Unless (умовні правила), RuleForEach (колекції), MustAsync (async з DI-сервісами), Transform (нормалізація)
  • FluentValidation автоматично інтегрується в ModelState через AddFluentValidationAutoValidation()Controller-код не змінюється
  • Головна перевага FluentValidation — тестованість: TestValidateAsync + ShouldHaveValidationErrorFor

У наступній статті — HTMX: філософія «HTML over the wire», core атрибути (hx-get/post, hx-target, hx-swap, hx-trigger) та практичне застосування для живих інтерфейсів без SPA.