ASP.NET Core MVC

Model Binding: від HTTP до C#

Model Binding у ASP.NET Core MVC: прив'язка параметрів через методи (не [BindProperty]!), порядок пошуку Route→Query→Form→Body, атрибути [FromRoute]/[FromQuery]/[FromForm]/[FromBody]/[FromHeader]/[FromServices], Custom Model Binders через IModelBinder, TryUpdateModelAsync. Демо: ProductSearchController з Money custom binder.

Model Binding: від HTTP до C#

Коли HTTP-запит надходить до вашого Action-методу, він містить безліч даних: сегменти URL, query string параметри, тіло форми, JSON, заголовки. Model Binding — це механізм ASP.NET Core що автоматично перетворює всі ці HTTP-дані на C# об'єкти, які ви отримуєте як параметри методу.

Ключова різниця від Razor Pages: тут немає [BindProperty]. Всі вхідні дані — це параметри Action-методу.


Проблема без Model Binding

Уявіть, якби Model Binding не існував:

// ❌ Без Model Binding — ручне парсення HTTP:
public IActionResult Search()
{
    // Витягуємо все вручну з HttpContext
    var query = Request.Query["q"].ToString();
    var pageStr = Request.Query["page"].ToString();
    var page = int.TryParse(pageStr, out var p) ? p : 1;
    var minPriceStr = Request.Query["minPrice"].ToString();
    decimal? minPrice = decimal.TryParse(minPriceStr, out var mp) ? mp : null;

    // І це лише для query string. А якщо ще й body...
    // ...
}

// ✅ З Model Binding — параметри просто з'являються:
public IActionResult Search(string? q, int page = 1, decimal? minPrice = null)
{
    // page вже int, minPrice вже decimal?, q вже string?
    // Model Binding зробив все за вас
}

Model Binding — це автоматичний маппер HTTP→C#.


Порядок пошуку: звідки беруться дані

Коли ASP.NET Core прив'язує параметр id в Details(int id), він шукає значення у такому порядку:

1. Form values    (POST body: application/x-www-form-urlencoded або multipart)
2. Route values   (сегменти URL: /product/details/42 → id=42)
3. Query string   (URL параметри: /product/details?id=42)
На відміну від Minimal API де [FromRoute]/[FromQuery] часто обов'язкові, у MVC Controller Model Binder автоматично шукає значення у всіх джерелах. Явні атрибути потрібні лише для уточнення або особливих випадків.
public class ProductController : Controller
{
    // id буде знайдено автоматично:
    // - з маршруту: GET /product/details/42
    // - або з query: GET /product/details?id=42
    public IActionResult Details(int id) => View();

    // Якщо і маршрут, і query мають "page" — форма перемагає,
    // потім маршрут, потім query (Form > Route > Query)
    public IActionResult List(int page = 1) => View();
}

Explicit Binding Source Attributes

Якщо вам потрібно точно вказати звідки брати дані:

FromRoute: тільки з маршруту

[HttpGet("products/{category}/{id:int}")]
public IActionResult Details(
    [FromRoute] string category,    // лише з /products/{category}/
    [FromRoute] int id)             // лише з /{id}
{
    return View();
}
// GET /products/electronics/42  → category="electronics", id=42
// GET /products/electronics/42?id=999  → id=42 (query ігнорується!)

FromQuery: тільки з query string

public IActionResult Search(
    [FromQuery] string? q,             // ?q=laptop
    [FromQuery(Name = "p")] int page,  // ?p=2 (параметр має ім'я "p")
    [FromQuery] decimal? maxPrice)     // ?maxPrice=5000
{
    return View();
}
// GET /product/search?q=laptop&p=2&maxPrice=5000

FromForm: тільки з тіла форми

[HttpPost]
public IActionResult Create(
    [FromForm] string title,    // з POST body: title=...
    [FromForm] string author)   // з POST body: author=...
{
    // ...
}

FromBody: JSON або інший content type

[HttpPost]
public IActionResult CreateFromApi([FromBody] CreateProductDto dto)
{
    // dto десеріалізується з JSON тіла запиту
    // Content-Type: application/json
    // { "title": "...", "price": 1000 }
}
[FromBody] можна використати лише один раз на метод. Якщо потрібно кілька параметрів з JSON — зберіть їх в один DTO-клас.

FromHeader: із заголовків HTTP

public IActionResult GetWithHeader(
    [FromHeader(Name = "X-Api-Version")] string apiVersion,
    [FromHeader(Name = "Accept-Language")] string language)
{
    // Читаємо власні заголовки запиту
}

FromServices: із DI контейнера

// Не потрібно ін'єктувати через конструктор якщо сервіс потрібен лише в одному Action
public IActionResult Report([FromServices] IReportService reportSvc)
{
    var report = reportSvc.Generate();
    return View(report);
}

Складні об'єкти як параметри

Model Binding автоматично прив'язує складні об'єкти з тіла форми:

public record CreateProductDto(
    string Title,
    string Description,
    decimal Price,
    int StockCount,
    string Category
);

public class ProductController : Controller
{
    // POST /product/create з body:
    // Title=Laptop&Description=...&Price=45000&StockCount=10&Category=Electronics
    [HttpPost]
    public async Task<IActionResult> Create(CreateProductDto dto)
    {
        // dto.Title, dto.Price тощо вже заповнені!
        if (!ModelState.IsValid) return View(dto);
        // ...
    }
}

Вкладені об'єкти

public record AddressDto(string Street, string City, string ZipCode);
public record OrderDto(
    string CustomerName,
    string Email,
    AddressDto ShippingAddress  // ← вкладений об'єкт
);
<!-- HTML-форма з вкладеним об'єктом -->
<input name="CustomerName" value="...">
<input name="Email" value="...">
<input name="ShippingAddress.Street" value="...">   <!-- ← крапкова нотація -->
<input name="ShippingAddress.City" value="...">
<input name="ShippingAddress.ZipCode" value="...">
[HttpPost]
public IActionResult PlaceOrder(OrderDto order)
{
    // order.ShippingAddress.Street — вже заповнено
    // Крапкова нотація у HTML автоматично прив'язується до вкладених властивостей
}

Колекції

<!-- Масив рядків: items[0], items[1], ... -->
<input name="items[0]" value="Book">
<input name="items[1]" value="Pen">

<!-- Масив об'єктів -->
<input name="orderItems[0].ProductId" value="1">
<input name="orderItems[0].Quantity" value="2">
<input name="orderItems[1].ProductId" value="5">
<input name="orderItems[1].Quantity" value="1">
[HttpPost]
public IActionResult SaveOrder(
    List<string> items,              // ["Book", "Pen"]
    List<OrderItemDto> orderItems)   // [{ProductId=1, Qty=2}, {ProductId=5, Qty=1}]
{ ... }

TryUpdateModelAsync: ручне оновлення моделі

Корисно коли модель вже є (наприклад, завантажена з БД) і потрібно оновити лише деякі поля:

[HttpPost]
public async Task<IActionResult> Edit(int id)
{
    // 1. Завантажуємо існуючу сутність
    var product = await _service.GetByIdAsync(id);
    if (product is null) return NotFound();

    // 2. Оновлюємо тільки дозволені поля з форми
    // Захист від over-posting: вказуємо whitelist полів
    var updateSucceeded = await TryUpdateModelAsync(
        product,
        prefix: "",                          // префікс форми
        p => p.Title,                        // дозволені поля
        p => p.Description,
        p => p.Price
        // p.CreatedAt — НЕ тут, не дозволяємо оновлювати
    );

    if (!updateSucceeded || !ModelState.IsValid)
        return View(product);

    await _service.SaveAsync(product);
    return RedirectToAction(nameof(Index));
}
TryUpdateModelAsync з whitelist — це захист від over-posting атаки: коли зловмисник надсилає додаткові поля (наприклад, IsAdmin=true) у POST-запиті, сподіваючись що вони прив'яжуться до моделі.

Custom Model Binder: IModelBinder

Іноді стандартний Model Binder не вміє конвертувати дані у ваш тип. Наприклад, Money — складний тип що представляє суму і валюту.

Крок 1: Тип що потребує custom binding

Models/Money.cs
namespace ProductApp.Models;

// Гроші: "USD 1,500.00" або "UAH 45000"
public record Money(decimal Amount, string Currency)
{
    public override string ToString() => $"{Currency} {Amount:N2}";

    // Парсинг рядка "USD 1500" або "1500 UAH"
    public static bool TryParse(string? input, out Money? money)
    {
        money = null;
        if (string.IsNullOrWhiteSpace(input)) return false;

        var parts = input.Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
        if (parts.Length != 2) return false;

        // Формат: "USD 1500" або "1500 USD"
        if (decimal.TryParse(parts[1], out var amount1))
        {
            money = new Money(amount1, parts[0].ToUpper());
            return true;
        }
        if (decimal.TryParse(parts[0], out var amount2))
        {
            money = new Money(amount2, parts[1].ToUpper());
            return true;
        }

        return false;
    }
}

Крок 2: Custom Model Binder

ModelBinders/MoneyModelBinder.cs
using Microsoft.AspNetCore.Mvc.ModelBinding;
using ProductApp.Models;

namespace ProductApp.ModelBinders;

public class MoneyModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // Отримуємо значення з форми/query за ім'ям параметра
        var value = bindingContext.ValueProvider
            .GetValue(bindingContext.ModelName);

        if (value == ValueProviderResult.None)
        {
            // Значення не знайдено — Model Binding пропускає
            return Task.CompletedTask;
        }

        var stringValue = value.FirstValue;

        if (Money.TryParse(stringValue, out var money))
        {
            // Успіх: встановлюємо результат
            bindingContext.Result = ModelBindingResult.Success(money);
        }
        else
        {
            // Помилка: додаємо до ModelState
            bindingContext.ModelState.TryAddModelError(
                bindingContext.ModelName,
                $"Неправильний формат суми. Очікується: 'USD 1500' або '1500 UAH'. Отримано: '{stringValue}'");
        }

        return Task.CompletedTask;
    }
}

Крок 3: Реєстрація через атрибут

ModelBinders/MoneyModelBinderProvider.cs
using Microsoft.AspNetCore.Mvc.ModelBinding;
using ProductApp.Models;

namespace ProductApp.ModelBinders;

// Provider — "фабрика" для конкретного типу
public class MoneyModelBinderProvider : IModelBinderProvider
{
    public IModelBinder? GetBinder(ModelBinderProviderContext context)
    {
        // Повертаємо наш binder тільки для типу Money
        if (context.Metadata.ModelType == typeof(Money))
            return new MoneyModelBinder();

        return null; // null = використовуй інший provider
    }
}
Program.cs
builder.Services.AddControllersWithViews(options =>
{
    // Вставляємо на початок — перевіряється першим
    options.ModelBinderProviders.Insert(0, new MoneyModelBinderProvider());
});

Або простіший варіант — через [ModelBinder] атрибут:

// Позначаємо тип напряму (якщо не хочемо реєструвати Provider)
[ModelBinder(typeof(MoneyModelBinder))]
public record Money(decimal Amount, string Currency) { ... }

Демо-проєкт: ProductSearchController

Будуємо покроково Controller що демонструє всі інструменти Model Binding.

Крок 1: Моделі

Models/Product.cs
namespace ProductApp.Models;

public record Product(
    int Id,
    string Title,
    string Category,
    Money Price,
    int StockCount,
    bool IsAvailable
);
Models/SearchFilters.cs
namespace ProductApp.Models;

public record SearchFilters(
    string? Query,
    string? Category,
    decimal? MinPrice,
    decimal? MaxPrice,
    bool? InStockOnly,
    string SortBy = "title",
    int Page = 1,
    int PageSize = 10
);

public record CreateProductDto(
    string Title,
    string Category,
    Money Price,           // ← наш custom type!
    int StockCount
);

Крок 2: Controller

У цьому контролері ми зберігаємо стан фільтрів у ViewBag, а сповіщення про успіх — у TempData. Якщо вам цікаво, як саме вони працюють під капотом — ці механізми (разом з ViewData та ViewModel) будуть об'єктом детального розбору у наступній статті 06.
Controllers/ProductSearchController.cs
using Microsoft.AspNetCore.Mvc;
using ProductApp.Models;
using ProductApp.Services;

namespace ProductApp.Controllers;

public class ProductSearchController : Controller
{
    private readonly IProductService _service;

    public ProductSearchController(IProductService service)
    {
        _service = service;
    }

    // ── SEARCH: фільтрація через [FromQuery] ───────────────────────
    // GET /productsearch?query=laptop&category=electronics&minPrice=1000
    public async Task<IActionResult> Index(
        [FromQuery] string? query,
        [FromQuery] string? category,
        [FromQuery] decimal? minPrice,
        [FromQuery] decimal? maxPrice,
        [FromQuery] bool? inStockOnly,
        [FromQuery] string sortBy = "title",
        [FromQuery] int page = 1)
    {
        var filters = new SearchFilters(
            query, category, minPrice, maxPrice, inStockOnly, sortBy, page);

        var products = await _service.SearchAsync(filters);

        // Передаємо фільтри назад у View для збереження стану форми
        ViewBag.Filters = filters;

        return View(products);
    }

    // ── DETAILS: параметр з маршруту ──────────────────────────────
    // GET /productsearch/details/42
    // GET /productsearch/details?id=42
    public async Task<IActionResult> Details(
        [FromRoute] int id)  // лише з маршруту, не з query
    {
        var product = await _service.GetByIdAsync(id);
        if (product is null) return NotFound();
        return View(product);
    }

    // ── SORT: демонстрація змішаних джерел ───────────────────────
    // GET /productsearch/sort/{sortField}?page=2&category=electronics
    [HttpGet("productsearch/sort/{sortField}")]
    public async Task<IActionResult> SortedList(
        [FromRoute] string sortField,      // з маршруту: "title", "price", "date"
        [FromQuery] int page = 1,          // з query string
        [FromQuery] string? category = null) // з query string
    {
        var filters = new SearchFilters(
            null, category, null, null, null, sortField, page);
        var products = await _service.SearchAsync(filters);

        ViewBag.SortField = sortField;
        return View("Index", products);
    }

    // ── CREATE: форма і збереження з [FromForm] ───────────────────
    // GET /productsearch/create
    [HttpGet]
    public IActionResult Create() =>
        View(new CreateProductDto("", "", new Money(0, "UAH"), 0));

    // POST /productsearch/create
    // Price приймається як "UAH 45000" і конвертується через MoneyModelBinder
    [HttpPost]
    public async Task<IActionResult> Create([FromForm] CreateProductDto dto)
    {
        if (!ModelState.IsValid)
            return View(dto);

        var product = await _service.CreateAsync(dto);
        TempData["Success"] = $"Товар «{product.Title}» (ціна: {product.Price}) додано!";
        return RedirectToAction(nameof(Index));
    }

    // ── API-LIKE: прийом JSON через [FromBody] ────────────────────
    // POST /productsearch/batch-create
    // Content-Type: application/json
    // Body: [{ "title": "...", "category": "..." }, ...]
    [HttpPost("productsearch/batch-create")]
    public async Task<IActionResult> BatchCreate([FromBody] List<CreateProductDto> dtos)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);

        var created = new List<Product>();
        foreach (var dto in dtos)
            created.Add(await _service.CreateAsync(dto));

        return Json(new { count = created.Count, products = created });
    }

    // ── API KEY CHECK: [FromHeader] ───────────────────────────────
    // GET /productsearch/premium-data
    // Header: X-Access-Level: premium
    [HttpGet("productsearch/premium-data")]
    public IActionResult PremiumData(
        [FromHeader(Name = "X-Access-Level")] string? accessLevel)
    {
        if (accessLevel != "premium")
            return Unauthorized();

        return Json(new { data = "Секретні преміум дані" });
    }
}

Крок 3: View для пошуку

Views/ProductSearch/Index.cshtml
@model List<ProductApp.Models.Product>
@{
    var filters = ViewBag.Filters as ProductApp.Models.SearchFilters;
    ViewData["Title"] = "Пошук товарів";
}

<h1>Пошук товарів</h1>

@* Форма фільтрів — GET запит зберігає параметри у URL *@
<form method="get" class="mb-4">
    <div class="row g-2">
        <div class="col-md-4">
            <input type="text" name="query" class="form-control"
                   placeholder="Назва товару..."
                   value="@filters?.Query">
        </div>
        <div class="col-md-2">
            <input type="text" name="category" class="form-control"
                   placeholder="Категорія"
                   value="@filters?.Category">
        </div>
        <div class="col-md-2">
            <input type="number" name="minPrice" class="form-control"
                   placeholder="Ціна від"
                   value="@filters?.MinPrice">
        </div>
        <div class="col-md-2">
            <input type="number" name="maxPrice" class="form-control"
                   placeholder="Ціна до"
                   value="@filters?.MaxPrice">
        </div>
        <div class="col-md-2">
            <button type="submit" class="btn btn-primary w-100">Шукати</button>
        </div>
    </div>
    <div class="mt-2">
        <div class="form-check form-check-inline">
            <input type="checkbox" name="inStockOnly" value="true"
                   class="form-check-input"
                   @(filters?.InStockOnly == true ? "checked" : "")>
            <label class="form-check-label">Тільки в наявності</label>
        </div>
    </div>
</form>

@if (!Model.Any())
{
    <p class="text-muted">Товарів за вашим запитом не знайдено.</p>
}
else
{
    <p>Знайдено: <strong>@Model.Count</strong> товар(ів)</p>
    <div class="row row-cols-1 row-cols-md-3 g-3">
        @foreach (var product in Model)
        {
            <div class="col">
                <div class="card h-100">
                    <div class="card-body">
                        <h5 class="card-title">
                            <a asp-action="Details" asp-route-id="@product.Id">
                                @product.Title
                            </a>
                        </h5>
                        <p class="card-text text-muted">@product.Category</p>
                        <p class="card-text">
                            <strong>@product.Price</strong>
                        </p>
                        @if (!product.IsAvailable)
                        {
                            <span class="badge bg-secondary">Немає в наявності</span>
                        }
                    </div>
                </div>
            </div>
        }
    </div>
}
Views/ProductSearch/Create.cshtml
@model ProductApp.Models.CreateProductDto
@{ ViewData["Title"] = "Новий товар"; }

<h1>Додати товар</h1>

<div class="row">
    <div class="col-md-6">
        <form asp-action="Create" method="post">
            <div class="mb-3">
                <label asp-for="Title" class="form-label">Назва</label>
                <input asp-for="Title" class="form-control">
                <span asp-validation-for="Title" class="text-danger"></span>
            </div>
            <div class="mb-3">
                <label asp-for="Category" class="form-label">Категорія</label>
                <input asp-for="Category" class="form-control">
            </div>
            <div class="mb-3">
                <label asp-for="Price" class="form-label">
                    Ціна (формат: "UAH 45000" або "USD 1500")
                </label>
                @* asp-for="Price" виведе Money.ToString() як value *@
                <input name="Price" class="form-control"
                       placeholder="UAH 1000" value="@Model.Price">
                <span asp-validation-for="Price" class="text-danger"></span>
                <div class="form-text">
                    Вказуйте валюту і суму через пробіл: UAH 45000
                </div>
            </div>
            <div class="mb-3">
                <label asp-for="StockCount" class="form-label">Кількість</label>
                <input asp-for="StockCount" class="form-control" type="number" min="0">
            </div>

            <button type="submit" class="btn btn-primary">Додати товар</button>
            <a asp-action="Index" class="btn btn-outline-secondary">Скасувати</a>
        </form>
    </div>
</div>

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

Крок 4: Запускаємо і тестуємо Model Binding

dotnet run

Тестуємо різні URL:

GET /productsearch                         → всі товари  
GET /productsearch?query=laptop            → пошук по назві
GET /productsearch?category=electronics&minPrice=5000  → фільтр
GET /productsearch/details/1               → [FromRoute] id=1
GET /productsearch/sort/price?page=2       → [FromRoute] + [FromQuery]
POST /productsearch/create з form body     → [FromForm] + MoneyModelBinder

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

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

Завдання 1.1. Додайте до ProductSearchController Action Compare що приймає список id через query string: /productsearch/compare?ids=1&ids=3&ids=5. Параметр — List<int> ids. View показує порівняльну таблицю знайдених товарів.

Завдання 1.2. Реалізуйте [FromHeader] — Action що читає Accept-Language заголовок і повертає повідомлення відповідною мовою (uk → "Привіт!", en → "Hello!", інше → "Hi!").

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

Завдання 2.1. Розширте MoneyModelBinder: він має також підтримувати формати "$1500" (USD), "€500" (EUR), "₴45000" (UAH). Напишіть юніт-тест для Money.TryParse з різними входами.

Завдання 2.2. Реалізуйте DateRangeModelBinder для типу DateRange(DateOnly From, DateOnly To). Прийом з форми: два поля {paramName}.from і {paramName}.to. Якщо from > to — помилка у ModelState. Використайте у ProductSearchController для фільтрації за датою додавання.

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

Завдання 3.1. Реалізуйте захист від over-posting для форми редагування продукту через TryUpdateModelAsync:

  1. Продукт має поля: Title, Category, Price, StockCount, CreatedAt, CreatedBy
  2. У формі редагування дозволено змінювати лише Title, Category, Price
  3. CreatedAt і CreatedBy — незмінні навіть якщо зловмисник додасть їх у POST-запит
  4. Напишіть тест: надішліть POST з CreatedAt=2000-01-01 і переконайтесь що значення не змінилося

Резюме

  • Model Binding у MVC: параметри методу, не [BindProperty] — головна відмінність від Razor Pages
  • Порядок пошуку: Form → Route → Query (без атрибутів)
  • [FromRoute], [FromQuery], [FromForm], [FromBody] — явні атрибути для контролю джерела
  • Складні об'єкти: автоматично прив'язуються з крапковою нотацією (Order.Address.Street)
  • Колекції: items[0], items[1] у формі → List<T> у параметрі
  • Custom Model Binder: IModelBinder + IModelBinderProvider для власних типів
  • TryUpdateModelAsync: whitelist полів для захисту від over-posting

У наступній статті — Views, ViewData, ViewBag, TempData і ViewModel: як передавати дані з Controller у View і яким чином зберігати стан між запитами.