Razor Pages

Форми і валідація: повний цикл обробки даних

Повний цикл форм у Razor Pages: HTML form з anti-forgery token, DataAnnotations для визначення правил, client-side validation через jQuery Validate Unobtrusive, server-side ModelState, IFormFile для завантаження файлів, обробка кількох кнопок submit, AJAX-форми через fetch API.

Форми і валідація: повний цикл обробки даних

Форми — це основний механізм взаємодії користувача з сервером у Razor Pages. Натиск кнопки Submit → POST-запит → PageModel → валідація → збереження або відображення помилок. Цей цикл однаковий для будь-якої форми: реєстрація, створення товару, редагування профілю.

У цій статті ми розберемо весь ланцюжок — від HTML до бази даних — і зрозуміємо де і як додавати правила перевірки.


Анатомія форми Razor Pages

Pages/Products/Create.cshtml
@page
@model CreateModel

<form method="post">
    @* ─── 1. Anti-Forgery Token: автоматично! ───────────────────
       При method="post" у Razor Page token додається сам.
       Захист від CSRF-атак без жодного зусилля. *@

    @* ─── 2. Поля форми ────────────────────────────────────────── *@
    <div class="mb-3">
        <label asp-for="Input.Name" class="form-label">Назва</label>
        <input asp-for="Input.Name" class="form-control">
        <span asp-validation-for="Input.Name" class="text-danger small"></span>
    </div>

    @* ─── 3. Кнопка Submit: POST → OnPostAsync() ─────────────────
       Форма без action → POST на ту саму URL *@
    <button type="submit" class="btn btn-primary">Зберегти</button>
    <a asp-page="./Index" class="btn btn-secondary">Скасувати</a>
</form>

@section Scripts {
    @* ─── 4. Client-side validation ──────────────────────────────
       jquery.validate + unobtrusive — читають data-val-* атрибути *@
    <partial name="_ValidationScriptsPartial" />
}

DataAnnotations: правила валідації

DataAnnotations — це атрибути на властивостях DTO що визначають правила. Вони: (a) генерують data-val-* HTML-атрибути для client-side validation, (b) перевіряються на сервері у ModelState.

Models/CreateProductInput.cs
public class CreateProductInput
{
    // ─── Загальні ──────────────────────────────────────────────────
    [Required(ErrorMessage = "Назва обов'язкова")]
    [Display(Name = "Назва товару")]          // Текст label (asp-for читає Display)
    [MaxLength(100, ErrorMessage = "Максимум 100 символів")]
    [MinLength(2, ErrorMessage = "Мінімум 2 символи")]
    public string Name { get; set; } = "";

    [Required]
    [Range(0.01, 999_999.99,
        ErrorMessage = "Ціна має бути від {1} до {2}")]
    [DataType(DataType.Currency)]             // Підказка для UI
    public decimal Price { get; set; }

    [MaxLength(2000)]
    [DataType(DataType.MultilineText)]        // → <textarea>
    public string? Description { get; set; }

    // ─── Текст і рядки ────────────────────────────────────────────
    [Required]
    [EmailAddress(ErrorMessage = "Невірний формат email")]
    public string ContactEmail { get; set; } = "";

    [Phone(ErrorMessage = "Невірний номер телефону")]
    public string? PhoneNumber { get; set; }

    [Url(ErrorMessage = "Невірний URL")]
    public string? WebsiteUrl { get; set; }

    [RegularExpression(@"^[A-Z]{2}\d{6}$",
        ErrorMessage = "Формат: AA000000 (2 літери, 6 цифр)")]
    public string? ArticleCode { get; set; }

    // ─── Числа і діапазони ────────────────────────────────────────
    [Range(0, 100, ErrorMessage = "Знижка від 0 до 100%")]
    public int Discount { get; set; }

    [Range(typeof(DateTime), "2024-01-01", "2030-12-31",
        ErrorMessage = "Дата поза допустимим діапазоном")]
    public DateTime? AvailableFrom { get; set; }

    // ─── Порівняння ───────────────────────────────────────────────
    [Required]
    [DataType(DataType.Password)]
    public string Password { get; set; } = "";

    [Compare(nameof(Password),
        ErrorMessage = "Паролі не співпадають")]
    [DataType(DataType.Password)]
    public string PasswordConfirm { get; set; } = "";

    // ─── Необов'язкові поля ───────────────────────────────────────
    // Без [Required]: null або пусте — OK
    public string? ExternalId { get; set; }

    // nullable int — може бути null (не обов'язкове числове поле)
    public int? WarehouseId { get; set; }
}

Локалізація повідомлень валідації

Program.cs — для локалізованих повідомлень помилок
builder.Services.AddRazorPages()
    .AddDataAnnotationsLocalization(options =>
    {
        // Повідомлення з ресурсів замість жорстко вбудованих рядків
        options.DataAnnotationLocalizerProvider = (type, factory) =>
            factory.Create(typeof(ValidationMessages));
    });
Resources/ValidationMessages.uk-UA.resx
// Required — "%DisplayName% є обов'язковим полем"
// MaxLength — "%DisplayName% не може перевищувати {0} символів"

Client-side validation: без жодного коду

Коли _ValidationScriptsPartial підключений, валідація відбувається у браузері без відправки форми:

Що генерує asp-for + DataAnnotations
@* У PageModel: [Required][MaxLength(100)] public string Name *@
<input asp-for="Input.Name" class="form-control">

@* Razor Pages генерує: *@
<input class="form-control"
       type="text"
       id="Input_Name"
       name="Input.Name"
       value=""
       data-val="true"
       data-val-required="Назва обов'язкова"
       data-val-maxlength="Максимум 100 символів"
       data-val-maxlength-max="100">

jquery.validate.unobtrusive.js читає data-val-* атрибути і автоматично:

  • Блокує submit якщо є помилки
  • Показує повідомлення у <span asp-validation-for="...">
  • Знімає помилки при виправленні поля

Bootstrap integration: додаємо "is-invalid" клас

wwwroot/js/validation-bootstrap.js
// Інтеграція jQuery Validate з Bootstrap форм-стилями
$.validator.setDefaults({
    highlight: function(element) {
        $(element).closest('.form-control, .form-select, .form-check-input')
            .addClass('is-invalid');
    },
    unhighlight: function(element) {
        $(element).closest('.form-control, .form-select, .form-check-input')
            .removeClass('is-invalid')
            .addClass('is-valid');
    },
    errorPlacement: function(error, element) {
        // Помилка вже є у <span asp-validation-for> — не дублюємо
        error.appendTo(element.closest('.mb-3').find('[data-valmsg-for]'));
    }
});
_Layout.cshtml або _ValidationScriptsPartial.cshtml
<script src="~/js/validation-bootstrap.js" asp-append-version="true"></script>

Server-side validation: ModelState

Pages/Products/Create.cshtml.cs
public class CreateModel : PageModel
{
    private readonly ProductService _svc;

    public CreateModel(ProductService svc) => _svc = svc;

    [BindProperty]
    public CreateProductInput Input { get; set; } = new();

    public void OnGet() { /* Порожня форма */ }

    public async Task<IActionResult> OnPostAsync()
    {
        // ─── 1. Автоматична перевірка DataAnnotations ─────────────
        // ModelState.IsValid = false при порушенні будь-якого атрибута
        if (!ModelState.IsValid)
        {
            // Повертаємо ту саму сторінку: форма буде заповнена
            // введеними значеннями + показані помилки
            return Page();
        }

        // ─── 2. Бізнес-валідація (не можна виразити атрибутами) ───
        var existingProduct = await _svc.FindByNameAsync(Input.Name);
        if (existingProduct is not null)
        {
            // Помилка для конкретного поля
            ModelState.AddModelError(
                key: "Input.Name",
                errorMessage: $"Товар з назвою '{Input.Name}' вже існує.");
            return Page();
        }

        // Перевірка бізнес-правила: ціна зі знижкою
        var finalPrice = Input.Price * (1 - Input.Discount / 100m);
        if (finalPrice < 1)
        {
            // Загальна помилка (не прив'язана до поля)
            ModelState.AddModelError(
                key: string.Empty,
                errorMessage: "Ціна після знижки не може бути менше 1 ₴.");
            return Page();
        }

        // ─── 3. Збереження ────────────────────────────────────────
        var product = await _svc.CreateAsync(new Product
        {
            Name = Input.Name,
            Price = Input.Price,
            Discount = Input.Discount,
            Description = Input.Description
        });

        TempData["SuccessMessage"] = $"Товар «{product.Name}» успішно створено!";
        return RedirectToPage("./Index");
    }
}

Очищення ModelState при зміні бізнес-логіки

// Видалити конкретну помилку (якщо потрібно умовно прибрати)
ModelState.Remove("Input.WarehouseId");

// Очистити всі помилки (рідко потрібно)
ModelState.Clear();

// Перевірити конкретне поле
if (ModelState.TryGetValue("Input.Name", out var entry)
    && entry.Errors.Count > 0)
{
    // є помилки для Input.Name
}

// Додати до наявних помилок
ModelState.AddModelError("Input.Name", "Додаткове повідомлення");

Власний ValidationAttribute

Validation/NoHtmlAttribute.cs
// Атрибут що забороняє HTML-теги у рядку
public class NoHtmlAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(
        object? value,
        ValidationContext validationContext)
    {
        if (value is string str && str.Contains('<'))
        {
            return new ValidationResult(
                "Поле не може містити HTML-теги",
                [validationContext.MemberName!]);
        }
        return ValidationResult.Success;
    }
}

// IValidatableObject: валідація між полями
public class CreateProductInput : IValidatableObject
{
    public decimal Price { get; set; }
    public decimal? CompareAtPrice { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (CompareAtPrice.HasValue && CompareAtPrice <= Price)
        {
            yield return new ValidationResult(
                "Ціна 'до знижки' має бути більше поточної ціни",
                [nameof(CompareAtPrice)]);
        }
    }
}

FluentValidation: альтернатива DataAnnotations

dotnet add package FluentValidation.AspNetCore
Validators/CreateProductValidator.cs
using FluentValidation;

public class CreateProductValidator : AbstractValidator<CreateProductInput>
{
    private readonly ProductService _svc;

    public CreateProductValidator(ProductService svc)
    {
        _svc = svc;

        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Назва обов'язкова")
            .MaximumLength(100).WithMessage("Максимум 100 символів")
            .MustAsync(async (name, ct) => !await _svc.ExistsAsync(name))
            .WithMessage(x => $"Товар '{x.Name}' вже існує");

        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Ціна має бути більше 0")
            .LessThanOrEqualTo(999_999);

        RuleFor(x => x.Discount)
            .InclusiveBetween(0, 100);

        When(x => x.CompareAtPrice.HasValue, () =>
        {
            RuleFor(x => x.CompareAtPrice!.Value)
                .GreaterThan(x => x.Price)
                .WithMessage("Ціна 'до знижки' має бути більше поточної");
        });
    }
}
Program.cs
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddScoped<IValidator<CreateProductInput>, CreateProductValidator>();
PageModel
// FluentValidation автоматично інтегрується з ModelState
// — OnPostAsync та ж сама логіка: перевіряємо ModelState.IsValid
public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid) return Page(); // FluentValidation вже додала помилки
    await _svc.CreateAsync(Input);
    return RedirectToPage("./Index");
}

IFormFile: завантаження файлів

Models/CreateProductInput.cs
public class CreateProductInput
{
    [Required]
    public string Name { get; set; } = "";

    // Один файл
    [DataType(DataType.Upload)]
    public IFormFile? Image { get; set; }

    // Кілька файлів
    public List<IFormFile>? Gallery { get; set; }
}
Form з файлами
@* enctype="multipart/form-data" — обов'язково для файлів! *@
<form method="post" enctype="multipart/form-data">
    <div class="mb-3">
        <label asp-for="Input.Image" class="form-label">Фото товару</label>
        <input asp-for="Input.Image" type="file" class="form-control"
               accept="image/*">
        <span asp-validation-for="Input.Image" class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Input.Gallery" class="form-label">Галерея</label>
        @* multiple — дозволяє вибрати кілька файлів *@
        <input asp-for="Input.Gallery" type="file" class="form-control"
               accept="image/*" multiple>
    </div>

    <button type="submit" class="btn btn-primary">Зберегти</button>
</form>
PageModel
public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid) return Page();

    string? imagePath = null;
    if (Input.Image is not null)
    {
        // Валідація файлу
        if (Input.Image.Length > 5 * 1024 * 1024) // 5 MB
        {
            ModelState.AddModelError("Input.Image",
                "Файл не може перевищувати 5 МБ");
            return Page();
        }

        var allowedTypes = new[] { "image/jpeg", "image/png", "image/webp" };
        if (!allowedTypes.Contains(Input.Image.ContentType))
        {
            ModelState.AddModelError("Input.Image",
                "Дозволені формати: JPEG, PNG, WebP");
            return Page();
        }

        // Збереження файлу
        var uploadsDir = Path.Combine("wwwroot", "uploads", "products");
        Directory.CreateDirectory(uploadsDir);

        var ext = Path.GetExtension(Input.Image.FileName);
        var fileName = $"{Guid.NewGuid()}{ext}";
        var filePath = Path.Combine(uploadsDir, fileName);

        await using var stream = System.IO.File.Create(filePath);
        await Input.Image.CopyToAsync(stream);

        imagePath = $"/uploads/products/{fileName}";
    }

    await _svc.CreateAsync(new Product
    {
        Name = Input.Name,
        ImageUrl = imagePath
    });

    return RedirectToPage("./Index");
}

Власний ValidationAttribute для файлів

Validation/ImageFileAttribute.cs
public class ImageFileAttribute : ValidationAttribute
{
    private readonly string[] _allowedExtensions;
    private readonly long _maxSizeBytes;

    public ImageFileAttribute(
        string[] extensions = null!,
        long maxSizeBytes = 5 * 1024 * 1024)
    {
        _allowedExtensions = extensions ?? [".jpg", ".jpeg", ".png", ".webp"];
        _maxSizeBytes = maxSizeBytes;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
    {
        if (value is not IFormFile file)
            return ValidationResult.Success;

        var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
        if (!_allowedExtensions.Contains(ext))
            return new ValidationResult(
                $"Дозволені формати: {string.Join(", ", _allowedExtensions)}");

        if (file.Length > _maxSizeBytes)
            return new ValidationResult(
                $"Файл не може перевищувати {_maxSizeBytes / 1024 / 1024} МБ");

        return ValidationResult.Success;
    }
}

// Використання:
public class CreateProductInput
{
    [ImageFile(maxSizeBytes: 2 * 1024 * 1024)] // 2 MB max
    public IFormFile? Image { get; set; }
}

Anti-Forgery Token: захист від CSRF

CSRF (Cross-Site Request Forgery) — атака де зловмисний сайт надсилає POST від імені аутентифікованого користувача. Anti-forgery token захищає від цього: кожна форма містить унікальний токен, що перевіряється при POST.

У Razor Pages токен додається автоматично при method="post". Але є нюанси:

Program.cs — Глобальна конфігурація
builder.Services.AddRazorPages(options =>
{
    // Вимагати anti-forgery для всіх POST за замовчуванням (вже так)
    options.Conventions.AddPageApplicationModelConvention("/", model =>
    {
        model.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
    });
});
Вимкнути для конкретного endpoint
[IgnoreAntiforgeryToken]
public class WebhookModel : PageModel
{
    // Вебхуки від зовнішніх систем не мають anti-forgery token
    public async Task<IActionResult> OnPostAsync()
    {
        // Обробляємо без перевірки token
    }
}

AJAX + Anti-Forgery

wwwroot/js/ajax-forms.js
// При AJAX POST — передаємо токен у заголовку
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;

fetch('/products/create', {
    method: 'POST',
    headers: {
        'Content-Type': 'application/json',
        'RequestVerificationToken': token  // ← Токен у заголовку
    },
    body: JSON.stringify({ name: 'Coffee', price: 120 })
});
PageModel для AJAX
// Перевірка токена з заголовка замість форми
[ValidateAntiForgeryToken]  // Читає і з форми, і з заголовка
public async Task<IActionResult> OnPostAjaxAsync([FromBody] CreateProductInput input)
{
    if (!ModelState.IsValid)
        return new JsonResult(new { errors = ModelState.ToDictionary() })
            { StatusCode = 400 };

    var product = await _svc.CreateAsync(input);
    return new JsonResult(new { id = product.Id, success = true });
}

Post-Redirect-Get (PRG): стандартний патерн

При успішному POST — завжди робіть RedirectToPage, ніколи return Page(). Якщо після POST рендерити сторінку напряму — F5 повторить POST:

✅ Правильно: PRG патерн
public async Task<IActionResult> OnPostAsync()
{
    if (!ModelState.IsValid) return Page(); // Тільки при помилці — Page()
    await _svc.CreateAsync(Input);
    TempData["Success"] = "Збережено!";
    return RedirectToPage("./Index");  // Успіх → завжди Redirect
}
❌ Неправильно: подвійний POST при F5
public async Task<IActionResult> OnPostAsync()
{
    await _svc.CreateAsync(Input);
    return Page(); // ← Refresh → повторний POST!
}

Повний приклад: форма редагування товару

Pages/Products/Edit.cshtml.cs
namespace MyApp.Pages.Products;

public class EditModel : PageModel
{
    private readonly ProductService _svc;

    public EditModel(ProductService svc) => _svc = svc;

    [BindProperty]
    public EditProductInput Input { get; set; } = new();

    public string ProductName { get; private set; } = "";

    public async Task<IActionResult> OnGetAsync(int id)
    {
        var product = await _svc.GetByIdAsync(id);
        if (product is null) return NotFound();

        ProductName = product.Name;
        Input = new EditProductInput
        {
            Id = product.Id,
            Name = product.Name,
            Price = product.Price,
            Discount = product.Discount,
            Description = product.Description,
            IsActive = product.IsActive
        };

        return Page();
    }

    public async Task<IActionResult> OnPostAsync(int id)
    {
        if (!ModelState.IsValid) return Page();

        // Бізнес валідація
        if (await _svc.ExistsAsync(Input.Name, excludeId: id))
        {
            ModelState.AddModelError("Input.Name",
                $"Назва '{Input.Name}' вже зайнята іншим товаром");
            return Page();
        }

        var success = await _svc.UpdateAsync(id, Input);
        if (!success) return NotFound();

        TempData["SuccessMessage"] = $"Зміни збережено!";
        return RedirectToPage("./Details", new { id });
    }

    public async Task<IActionResult> OnPostDeactivateAsync(int id)
    {
        await _svc.SetActiveAsync(id, false);
        TempData["SuccessMessage"] = "Товар деактивовано";
        return RedirectToPage("./Index");
    }
}
Pages/Products/Edit.cshtml
@page "{id:int}"
@model EditModel
@{
    ViewData["Title"] = $"Редагування: {Model.ProductName}";
}

<h1>Редагування: <small class="text-muted">@Model.ProductName</small></h1>

@if (TempData["SuccessMessage"] is string msg)
{
    <div class="alert alert-success">@msg</div>
}

<form method="post">
    <input type="hidden" asp-for="Input.Id">

    <div class="row g-3">
        <div class="col-md-8">
            <label asp-for="Input.Name" class="form-label"></label>
            <input asp-for="Input.Name" class="form-control">
            <span asp-validation-for="Input.Name" class="text-danger small"></span>
        </div>

        <div class="col-md-2">
            <label asp-for="Input.Price" class="form-label"></label>
            <div class="input-group">
                <input asp-for="Input.Price" class="form-control" step="0.01">
                <span class="input-group-text"></span>
            </div>
            <span asp-validation-for="Input.Price" class="text-danger small"></span>
        </div>

        <div class="col-md-2">
            <label asp-for="Input.Discount" class="form-label"></label>
            <div class="input-group">
                <input asp-for="Input.Discount" class="form-control" type="number" min="0" max="100">
                <span class="input-group-text">%</span>
            </div>
        </div>

        <div class="col-12">
            <label asp-for="Input.Description" class="form-label"></label>
            <textarea asp-for="Input.Description" class="form-control" rows="4"></textarea>
            <span asp-validation-for="Input.Description" class="text-danger small"></span>
        </div>

        <div class="col-12">
            <div class="form-check">
                <input asp-for="Input.IsActive" class="form-check-input" type="checkbox">
                <label asp-for="Input.IsActive" class="form-check-label">Активний</label>
            </div>
        </div>
    </div>

    <div asp-validation-summary="ModelOnly" class="alert alert-danger mt-3"></div>

    <div class="mt-3 d-flex gap-2">
        <button type="submit" class="btn btn-primary">
            <i class="bi bi-save me-1"></i>Зберегти зміни
        </button>
        <a asp-page="./Details" asp-route-id="@Model.Input.Id"
           class="btn btn-secondary">Скасувати</a>
    </div>
</form>

@* Окрема форма для деактивації — page handler *@
@if (Model.Input.IsActive)
{
    <hr>
    <form method="post" asp-page-handler="Deactivate">
        <button type="submit" class="btn btn-outline-warning"
                asp-confirm="Деактивувати товар?">
            <i class="bi bi-pause-circle me-1"></i>Деактивувати
        </button>
    </form>
}

@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

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

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

Завдання 1.1. Реалізуйте форму реєстрації Pages/Auth/Register.cshtml з полями: Email, Password, PasswordConfirm, DisplayName. DataAnnotations: Required, EmailAddress, Compare для паролів, MaxLength 50 для DisplayName. Client-side validation без POST. Server-side: перевіряйте дублікат email через _userService.ExistsAsync(email).

Завдання 1.2. У Pages/Products/Create.cshtml додайте завантаження зображення. Валідація: тільки JPEG/PNG/WebP, максимум 2 МБ. Збережіть файл у wwwroot/uploads/products/{guid}{ext}. Виводьте прев'ю завантаженого зображення на сторінці списку.

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

Завдання 2.1. Реалізуйте масове видалення: на Pages/Products/Index.cshtml додайте checkbox для кожного товару (<input type="checkbox" name="SelectedIds" value="@product.Id">). Кнопка "Видалити обрані" → OnPostDeleteSelectedAsync() де [BindProperty] public int[] SelectedIds.

Завдання 2.2. Реалізуйте форму з IValidatableObject: при створенні товару перевіряйте що CompareAtPrice > Price (якщо вказана), і що AvailableFrom < AvailableTo (якщо вказані обидва). В Validate() — yield return для кожної помилки, ModelState підхопить автоматично.

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

Завдання 3.1. Реалізуйте AJAX валідацію унікальності: endpoint GET /products/check-name?name=Coffee повертає { "exists": true/false }. Додайте custom data-val-remote атрибут через власний ValidationAttribute що генерує data-val-remote-url="/products/check-name". jQuery Validate підхопить автоматично через remote validator.

Copyright © 2026