Форми і валідація: повний цикл обробки даних
Форми і валідація: повний цикл обробки даних
Форми — це основний механізм взаємодії користувача з сервером у Razor Pages. Натиск кнопки Submit → POST-запит → PageModel → валідація → збереження або відображення помилок. Цей цикл однаковий для будь-якої форми: реєстрація, створення товару, редагування профілю.
У цій статті ми розберемо весь ланцюжок — від HTML до бази даних — і зрозуміємо де і як додавати правила перевірки.
Анатомія форми Razor Pages
@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.
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; }
}
Локалізація повідомлень валідації
builder.Services.AddRazorPages()
.AddDataAnnotationsLocalization(options =>
{
// Повідомлення з ресурсів замість жорстко вбудованих рядків
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(ValidationMessages));
});
// Required — "%DisplayName% є обов'язковим полем"
// MaxLength — "%DisplayName% не може перевищувати {0} символів"
Client-side validation: без жодного коду
Коли _ValidationScriptsPartial підключений, валідація відбувається у браузері без відправки форми:
@* У 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" клас
// Інтеграція 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]'));
}
});
<script src="~/js/validation-bootstrap.js" asp-append-version="true"></script>
Server-side validation: ModelState
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
// Атрибут що забороняє 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
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("Ціна 'до знижки' має бути більше поточної");
});
}
}
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddScoped<IValidator<CreateProductInput>, CreateProductValidator>();
// FluentValidation автоматично інтегрується з ModelState
// — OnPostAsync та ж сама логіка: перевіряємо ModelState.IsValid
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid) return Page(); // FluentValidation вже додала помилки
await _svc.CreateAsync(Input);
return RedirectToPage("./Index");
}
IFormFile: завантаження файлів
public class CreateProductInput
{
[Required]
public string Name { get; set; } = "";
// Один файл
[DataType(DataType.Upload)]
public IFormFile? Image { get; set; }
// Кілька файлів
public List<IFormFile>? Gallery { get; set; }
}
@* 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>
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 для файлів
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". Але є нюанси:
builder.Services.AddRazorPages(options =>
{
// Вимагати anti-forgery для всіх POST за замовчуванням (вже так)
options.Conventions.AddPageApplicationModelConvention("/", model =>
{
model.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
});
});
[IgnoreAntiforgeryToken]
public class WebhookModel : PageModel
{
// Вебхуки від зовнішніх систем не мають anti-forgery token
public async Task<IActionResult> OnPostAsync()
{
// Обробляємо без перевірки token
}
}
AJAX + Anti-Forgery
// При 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 })
});
// Перевірка токена з заголовка замість форми
[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:
public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid) return Page(); // Тільки при помилці — Page()
await _svc.CreateAsync(Input);
TempData["Success"] = "Збережено!";
return RedirectToPage("./Index"); // Успіх → завжди Redirect
}
public async Task<IActionResult> OnPostAsync()
{
await _svc.CreateAsync(Input);
return Page(); // ← Refresh → повторний POST!
}
Повний приклад: форма редагування товару
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");
}
}
@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.
Tag Helpers: типізований HTML
Детальний розгляд Tag Helpers у Razor Pages: asp-for, asp-page, asp-route-*, asp-validation-for, asp-validation-summary, asp-items, asp-append-version, environment, cache, link та script Tag Helpers, написання власного Tag Helper з TagHelper базового класу.
Практичний проєкт: TaskManager на Razor Pages
Повний практичний CRUD-проєкт TaskManager на Razor Pages: список задач з пошуком і пагінацією, форми створення та редагування з валідацією, підтвердження видалення, категорії, пріоритети, статуси, спільний Layout з навігацією, Partial Views, IMemoryCache, EF Core з PostgreSQL.