Mert Özen Her Satırda Daha İleri
Backend .NET Core Web API — #08

ASP.NET Core'da Model Binding: FromRoute, FromQuery, FromBody ve FromHeader

Mert Özen 4 Kas 2026 8 dk 1 görüntülenme
ASP.NET Core'da Model Binding: FromRoute, FromQuery, FromBody ve FromHeader

İstekteki verinin route, query string, body, header, form ve service kaynaklarından action parametrelerine nasıl bağlandığını, [ApiController]'ın tahmin kurallarını, 415 ve 400 hatalarının sebeplerini gerçek yanıtlarla anlatıyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 6 ile yazılmıştır ve .NET 10'da da değişmeden çalışır.

Önce Tahmin Et

Ürünleri filtrelemek için bir query class'ı yazdık ve onu doğrudan action parametresi yaptık. Asıl controller'ı bozmamak için denemeyi ayrı bir BadBindingController'da yapıyoruz:

[HttpGet("filter")]
public ActionResult<string> Filter(ProductQuery query)
{
    return query.Category ?? "none";
}

ProductQuery içinde Category, MinPrice, MaxPrice ve Page property'leri var. curl -i "http://localhost:5080/api/bad-binding/filter?category=books" isteğini atarsan ne döner: books mı, none mı, yoksa bir hata mı? Hata ise hangi status code?

Model Binding Ne Yapar?

Bir HTTP isteği aslında bir yığın metin: URL'in path'i, query string, header'lar ve body. Action'ın ise int id, decimal? minPrice, CreateProductRequest request gibi tipli parametreleri var. Bu ikisinin arasındaki köprüye model binding diyoruz. Framework her parametre için değeri isteğin doğru parçasında arar, string'i parametrenin tipine çevirir ve olanı biteni ModelState'e kaydeder.

Bunu bir kargo ayrıştırma merkezi gibi düşün. Paket geldiğinde görevli üstündeki etikete bakar ve onu doğru banda koyar. [FromRoute], [FromQuery], [FromBody], [FromHeader], [FromForm] ve [FromServices] o etiketler. Etiket yoksa görevli paketin şekline bakıp tahmin eder.

[ApiController]'ın tahmin kuralları şöyle: adı route template'inde geçen parametre route'tan, IFormFile gibi dosya tipleri form'dan, geri kalan complex tipler body'den, basit tipler de query string'den okunur. Baştaki sorunun cevabı da burada. ProductQuery complex bir tip olduğu için body'den bekleniyor. GET isteğinin ne body'si ne de Content-Type'ı var, sonuç:

HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.13","title":"Unsupported Media Type","status":415,"traceId":"00-11780475237c24c0dfae09fd7e7b60a9-283422157a662172-00"}

Etiketi koyduğunda, yani parametreyi [FromQuery] ProductQuery query yaptığında aynı istek çalışır. Bunu birazdan göreceğiz.

Route ve Query String

En sık kullandığın iki kaynakla başlayalım:

[HttpGet]
public ActionResult<IEnumerable<Product>> Search(string? category, decimal? minPrice, int page = 1)
{
    IEnumerable<Product> result = Products;
    if (!string.IsNullOrWhiteSpace(category))
    {
        result = result.Where(p => p.Category == category);
    }

    if (minPrice is not null)
    {
        result = result.Where(p => p.Price >= minPrice);
    }

    return result.Skip((page - 1) * 10).Take(10).ToList();
}

[HttpGet("{id:int}")]
public ActionResult<Product> GetById([FromRoute] int id)
{
    Product? product = Products.FirstOrDefault(p => p.Id == id);
    if (product is null)
    {
        return NotFound();
    }

    return product;
}

Search'te hiçbir attribute yok; üç parametre de basit tip olduğu için query string'den gelir. Opsiyonel bir parametreyi iki şekilde ifade edebilirsin: nullable yaparak (decimal? minPrice, gelmezse null) ya da varsayılan değer vererek (int page = 1). GetById'deki [FromRoute] aslında gereksiz, id route template'inde geçtiği için tahmin zaten doğru olurdu. Ama niyeti açıkça yazmak kodu okuyanın işini kolaylaştırır.

Şimdi iki hatalı isteğe bakalım: ?page=abc ve /api/products/abc.

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-3182cdd7b30c339c9d4ab309603e83b6-cccf6b75b2290928-00","errors":{"page":["The value 'abc' is not valid."]}}

HTTP/1.1 404 Not Found
Content-Length: 0

İlki model binding hatası: abc bir int'e çevrilemedi, hata ModelState'e yazıldı ve [ApiController] action'ı hiç çalıştırmadan 400 döndü. İkincisi ise binding'e hiç ulaşmadı. {id:int} constraint'i abc'yi routing aşamasında eledi, eşleşen endpoint olmadığı için de body'siz bir 404 geldi. Aynı "yanlış tip" hatası, kontrolü nerede yaptığına göre iki farklı status code üretiyor.

Query parametresinin adı C# tarafındaki isimle aynı olmak zorunda değil, dizi de bağlayabilirsin:

[HttpGet("by-ids")]
public ActionResult<IEnumerable<Product>> GetByIds([FromQuery] int[] ids)
{
    return Products.Where(p => ids.Contains(p.Id)).ToList();
}

[HttpGet("sorted")]
public ActionResult<IEnumerable<string>> GetSorted([FromQuery(Name = "sort_by")] string? sortBy)
{
    IEnumerable<Product> result = sortBy == "price"
        ? Products.OrderBy(p => p.Price)
        : Products.OrderBy(p => p.Name);
    return result.Select(p => p.Name).ToList();
}

?ids=1&ids=3 isteği iki ürünü döner; aynı anahtarı tekrarlamak diziyi doldurur. ?sort_by=price fiyata göre sıralar. ?sortBy=price ise hata vermez, sessizce görmezden gelinir ve isme göre sıralı liste döner. Query string'de bilinmeyen anahtarlar kimseyi rahatsız etmez; bu hem esneklik hem tuzak.

Complex Type'ı Query'den Bağlamak

Filtre parametreleri çoğaldıkça action imzası uzar. Onları bir class'ta toplamak daha temiz:

namespace ProductCatalog.Models;

public class ProductQuery
{
    public string? Category { get; set; }
    public decimal? MinPrice { get; set; }
    public decimal? MaxPrice { get; set; }
    public int Page { get; set; } = 1;
}
[HttpGet("filter")]
public ActionResult<IEnumerable<Product>> Filter([FromQuery] ProductQuery query)
{
    IEnumerable<Product> result = Products;
    if (query.Category is not null)
    {
        result = result.Where(p => p.Category == query.Category);
    }

    if (query.MinPrice is not null)
    {
        result = result.Where(p => p.Price >= query.MinPrice);
    }

    if (query.MaxPrice is not null)
    {
        result = result.Where(p => p.Price <= query.MaxPrice);
    }

    return result.Skip((query.Page - 1) * 10).Take(10).ToList();
}

[FromQuery] etiketiyle framework class'ın her property'si için query string'de aynı adlı bir anahtar arar. ?category=peripherals&maxPrice=1000 isteği sadece Wireless Mouse'u döner. Eşleşme büyük-küçük harf duyarlı değil, ?Category=books da çalışır. Pagination ve sıralamayı ayrı bir yazıda bu class'ın üzerine kuracağız.

Body: JSON ve Input Formatter

Yeni ürün oluştururken veriyi body'den alıyoruz. Bunun için ayrı bir request class'ı kullanmak, istemcinin Id gibi alanları gönderememesini sağlar:

namespace ProductCatalog.Models;

public class CreateProductRequest
{
    public string Name { get; set; } = string.Empty;
    public string Category { get; set; } = string.Empty;
    public decimal Price { get; set; }
}
[HttpPost]
public ActionResult<Product> Create(
    [FromBody] CreateProductRequest request,
    [FromHeader(Name = "X-Correlation-Id")] string? correlationId)
{
    Product product = new()
    {
        Id = Products.Max(p => p.Id) + 1,
        Name = request.Name,
        Category = request.Category,
        Price = request.Price
    };
    Products.Add(product);

    if (correlationId is not null)
    {
        Response.Headers["X-Correlation-Id"] = correlationId;
    }

    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}

Body diğer kaynaklardan farklı çalışır. Route ve query değerleri küçük string parçalarıdır ve tek tek çevrilir. Body ise bir bütün olarak bir input formatter'a verilir; varsayılan formatter System.Text.Json ile JSON'u okuyup CreateProductRequest'e çevirir. Bu yüzden bir action'da body'den okunan tek bir parametre olabilir. İkinci bir complex parametre eklersen uygulama açılırken şu exception'la durur:

Unhandled exception. System.InvalidOperationException: Action 'ProductCatalog.Controllers.TwoBodyController.Create (ProductCatalog)' has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use 'FromQueryAttribute' to specify bound from query, 'FromRouteAttribute' to specify bound from route, and 'FromBodyAttribute' for parameters to be bound from body:
CreateProductRequest request
Product product

Formatter'ın seçilmesi Content-Type header'ına bağlı. curl -d ile header'sız bir istek atarsan curl body'yi application/x-www-form-urlencoded olarak işaretler ve JSON formatter devreye girmez: sonuç 415 Unsupported Media Type. Property adlarında ise framework hoşgörülü: {"NAME":"Desk Lamp","Category":"office","PRICE":300} da sorunsuz bağlanır, çünkü ASP.NET Core JSON okurken büyük-küçük harfe bakmaz.

Header ve Form

Yukarıdaki Create'te ikinci parametre header'dan geliyor. HTTP header adları tire içerdiği için C# parametre adıyla eşleşmez; Name ile gerçek adı veriyoruz. -H "X-Correlation-Id: abc-123" ile atılan istek, aynı header'ı yanıtta geri alır:

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5080/api/Products/4
X-Correlation-Id: abc-123

{"id":4,"name":"USB-C Hub","category":"peripherals","price":890}

Form verisi için [FromForm] var:

[HttpPost("form")]
public ActionResult<Product> CreateFromForm([FromForm] CreateProductRequest request)
{
    Product product = new()
    {
        Id = Products.Max(p => p.Id) + 1,
        Name = request.Name,
        Category = request.Category,
        Price = request.Price
    };
    Products.Add(product);
    return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}

curl -F name="Desk Lamp" -F category=office -F price=300 isteği 201 ve doğru ürünü döner. JSON API'lerde form'u en çok dosya yüklerken kullanırsın; o konuyu ayrı bir yazıda işleyeceğiz.

[FromServices]: Parametreden Service Almak

Bazen action'ın ihtiyacı istekte değil, uygulamanın içinde durur. [FromServices], parametreyi dependency injection container'ından ister:

[HttpGet("environment")]
public ActionResult<string> GetEnvironment([FromServices] IWebHostEnvironment environment)
{
    return environment.EnvironmentName;
}

Yanıt Production ya da Development, uygulamayı hangi environment'ta çalıştırdığına göre. Attribute'u silersen .NET 6'da ilginç bir şey olur: IWebHostEnvironment complex bir tip, dolayısıyla body'den bekleniyor ve GET isteği 415 döner. Aynı kod .NET 10'da 200 döner, çünkü .NET 7 ile birlikte container'da kayıtlı tipler otomatik olarak service sayılmaya başladı. Kemik yapıda attribute'u açıkça yazıyoruz; bu davranış değişikliğini ilerleyen sürüm yazılarında göreceğiz. Container'ın nasıl çalıştığını ise dependency injection yazısında ele alacağız.

Binding Hataları ve ModelState

Yarım kalmış bir JSON gönderelim: -d '{"name":"USB-C Hub",'

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8

{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.1","title":"One or more validation errors occurred.","status":400,"traceId":"00-5eef9b1719aa1f3e14859cbd7eb33647-f6ea6efa4a057f62-00","errors":{"$":["Expected start of a property name or value, but instead reached end of data. Path: $ | LineNumber: 0 | BytePositionInLine: 19."],"request":["The request field is required."]}}

errors içinde iki kayıt var ve ikisi de okumaya değer. $ anahtarı JSON path'i gösteriyor; $ belgenin kökü demek. Bir property'de tip hatası olsaydı anahtar $.price gibi olurdu. İkinci kayıt sonucun sonucu: body okunamadığı için request parametresi null kaldı. Projede nullable reference types açık olduğundan nullable olmayan bir parametre zorunlu sayılıyor ve bu da ayrı bir hata üretiyor.

Query tarafında ters bir durum var. int page gibi bir parametre hiç gönderilmezse hata oluşmaz, sadece varsayılan değerini alır. Bir değerin mutlaka gelmesini istiyorsan [BindRequired] kullanırsın; o zaman eksik istek "A value for the 'page' parameter or property was not provided." mesajıyla 400 döner. Değerin içeriğine dair kuralları, yani validation'ı bir sonraki yazıda konuşacağız.

Sık Düşülen Tuzaklar

GET action'ına etiketsiz bir complex parametre koymak ilk sırada. Baştaki örnekteki gibi 415 alırsın ve hata mesajı sana query'den bahsetmez. GET'te complex tip gördüğünde elin kendiliğinden [FromQuery]'ye gitsin.

İkincisi, [FromForm] bekleyen bir action'a JSON göndermek. Burada 415 bile gelmez: CreateFromForm'a Content-Type: application/json ile atılan istek 201 döner ve body'de {"id":7,"name":"","category":"","price":0} durur. Form okuyucusu JSON'u anlamaz, boş bir nesne üretir ve action onu kaydeder.

Üçüncüsü, isim uyuşmazlıkları. sortBy ile sort_by örneğinde gördüğün gibi query string'deki yanlış bir anahtar hata üretmez. Uzun süre fark edilmeyen hatalar genellikle böyle doğar.

Dördüncüsü, testte Content-Type'ı unutmak. 415 gördüğünde ilk bakacağın yer isteğin header'ları olsun.

Küçük Bir Deneme

★☆☆ Search action'ına maxPrice parametresini ekle ve ?minPrice=400&maxPrice=1000 isteğiyle sadece Wireless Mouse'un döndüğünü doğrula.

★★☆ ProductQuery'ye string[] Tags property'si ekle ve ?tags=wireless&tags=sale isteğiyle dizinin dolduğunu action'dan dönen bir değerle göster.

★★★ PUT api/products/{id} action'ı yaz: id route'tan, ürün body'den, If-Match header'ı da [FromHeader] ile gelsin. Header yoksa 428, varsa 204 dön. Sonra [FromBody]'yi silip ne değiştiğine bak.

Bu yazıda verinin action'a nasıl ulaştığını gördük, ama ulaşan verinin doğru olup olmadığına hiç bakmadık. Fiyatı eksi bir ürün, adı boş bir kayıt şu an sorunsuz kaydediliyor. Bir sonraki yazıda Data Annotations, custom attribute'lar, IValidatableObject ve FluentValidation ile model validation'ı ele alacağız.