Minimal API Derinlemesine 1: Parameter Binding Kaynakları, Özel Binding (TryParse/BindAsync), [AsParameters], Form ve IFormFile
Minimal API bir parametrenin kaynağına sırayla karar verir: attribute, özel tipler, BindAsync, TryParse, service ve en son body. ISBN'i TryParse ile bir tipe bağlıyor, BindAsync ve [AsParameters] kullanıyor, form binding'de antiforgery'yi ve enum'ların harf duyarlı binding'ini gerçek çıktılarla görüyoruz.
Sürüm notu: Bu yazıdaki kodlar .NET 10 (SDK 10.0.404) ile derlenip denenmiştir.
Önce Tahmin Et
Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kitap listesi sıralanabiliyor. Sıralama bir enum, query parametreleri de tek bir record'da toplanmış:
public enum BookSort
{
Title,
Price,
PriceDescending,
}
app.MapGet("/api/books", ([AsParameters] BookQuery query) => query.Apply());
Angular istemcisi GET /api/books?sort=price gönderiyor; dikkat et, küçük harfle. Aynı parametreyi bir [ApiController] controller'ında [FromQuery] BookSort sort olarak alınca Price geliyor. Minimal API ne yapar? Fiyata göre mi sıralar, varsayılan Title'a mı düşer, yoksa 400 mü döner? Bonus: ?sort=42 gönderirsen?
Bir önceki yazıda HttpContext'i elle okuduk. Minimal API'de bunu nadiren yaparsın, çünkü handler'ın parametre listesi aslında bir binding tarifidir: framework uygulama ayağa kalkarken her parametreye bakar, nereden geleceğine karar verir ve bunu bir kez derler. .NET 6 dönemindeki minimal API yazısında temel kuralları görmüştük; burada .NET 10'daki tam resme, kendi tiplerini bağlamaya ve form binding'e bakıyoruz.
Parametrenin Kaynağına Nasıl Karar Veriliyor
Learn'deki parameter binding sayfası sırayı şöyle anlatıyor. İlk önce açık attribute'lar: [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromForm], [FromServices] ve [AsParameters]. Attribute yoksa özel tiplere bakılır: HttpContext, HttpRequest, HttpResponse, ClaimsPrincipal, CancellationToken, IFormCollection, IFormFileCollection, IFormFile, Stream ve PipeReader. Sonra tipin static bir BindAsync method'u var mı diye bakılır. Yoksa tip string mi ya da static bir TryParse'ı var mı: varsa adı route template'inde geçiyorsa route'tan, geçmiyorsa query string'den gelir. O da değilse tip DI container'ında kayıtlı bir service mi diye sorulur. Hiçbiri tutmazsa parametre body'den, JSON olarak okunur.
Bu sıranın pratik sonucu şu: int, Guid, DateOnly gibi TryParse'ı olan her tip ve string route ya da query'den gelir; service'ler attribute'suz gelir; geri kalan her şey body'dir. Aynı key'i birden fazla taşıyabilen query ve header'lar için array'ler de destekleniyor: int[] ids parametresi ?ids=1&ids=2'yi okur. Keyed service'ler .NET 8 yazısındaki [FromKeyedServices] ile gelir. Kurumsal sipariş için iki kargo hesaplayıcısını aynı endpoint'te kullanalım:
builder.Services.AddKeyedSingleton<IShippingCalculator, StandardShipping>("standard");
builder.Services.AddKeyedSingleton<IShippingCalculator, ExpressShipping>("express");
// ...
app.MapGet("/api/shipping/quote", (
InstitutionContext institution,
int quantity,
[FromKeyedServices("express")] IShippingCalculator express,
[FromKeyedServices("standard")] IShippingCalculator standard) =>
new
{
Unit = institution.Unit.Name,
Standard = standard.Calculate(quantity),
Express = express.Calculate(quantity),
});
$ curl "localhost:5238/api/shipping/quote?quantity=25" -H "X-Tenant-Id: 101"
{"unit":"Main Campus","standard":0,"express":149.90}
quantity bir int ve route'ta yok, o yüzden query'den geldi. 20 adet ve üstündeki kurumsal siparişte standart kargo ücretsiz. InstitutionContext ise ne service ne body: onun nereden geldiğini birazdan göreceğiz.
Kendi Tipin: TryParse ile Isbn
Kitap detayını ISBN'le (kitabın uluslararası standart numarası) getiren endpoint'te parametreyi string almak kolay ama ISBN'in doğruluğunu her handler'da yeniden kontrol etmek gerekir. Bunun yerine ISBN'i bir tipe çevirip doğrulamayı tipin içine koyuyoruz:
public readonly record struct Isbn(string Value) : IParsable<Isbn>
{
// "978-0441013593" ve "9780441013593" aynı ISBN; tireleri atıp checksum'a bakıyoruz
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out Isbn result)
{
result = default;
string digits = (s ?? "").Replace("-", "");
if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
{
return false;
}
int sum = 0;
for (int i = 0; i < 12; i++)
{
sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
}
if ((10 - sum % 10) % 10 != digits[12] - '0')
{
return false;
}
result = new Isbn(digits);
return true;
}
public static Isbn Parse(string s, IFormatProvider? provider) =>
TryParse(s, provider, out Isbn result) ? result : throw new FormatException($"'{s}' is not a valid ISBN-13.");
public override string ToString() => Value;
}
app.MapGet("/api/books/{isbn}", Results<Ok<Book>, NotFound> (Isbn isbn, BookCatalog catalog) =>
catalog.Find(isbn) is { } book ? TypedResults.Ok(book) : TypedResults.NotFound());
app.MapGet("/api/books/lookup", (Isbn[] isbn, BookCatalog catalog) =>
isbn.Select(i => catalog.Find(i)?.Title ?? $"{i}: not found"));
$ curl localhost:5238/api/books/9780441013593
{"id":1,"isbn":"978-0441013593","title":"Dune","price":450,"category":"science-fiction"}
$ curl -i localhost:5238/api/books/978-0441013590
HTTP/1.1 400 Bad Request
Content-Length: 0
$ curl "localhost:5238/api/books/lookup?isbn=978-0547928227&isbn=9780141439549&isbn=9780000000002"
["The Hobbit","Middlemarch","9780000000002: not found"]
Tireli ve tiresiz yazım aynı kitabı buldu; son hanesi yanlış olan ISBN handler'a hiç ulaşmadan 400 aldı (son çıktı Production ortamından). Isbn[] de tek tek TryParse'tan geçti. Framework yalnızca method'un imzasına bakıyor: TryParse(string, out T) ya da TryParse(string, IFormatProvider, out T). IParsable<T>'yi implement etmek zorunlu değil ama imzaları derleyiciye kontrol ettirmek için iyi bir alışkanlık. /api/books/lookup ile /api/books/{isbn} çakışmadı, çünkü routing yazısında gördüğümüz gibi sabit segment parametreli segmentten önce gelir. Bir detay: minimal API TryParse'a IFormatProvider olarak invariant culture veriyor; kültürün binding'e etkisini localization yazısında konuşacağız.
BindAsync: Tipin Binding'i Kendisi Yapsın
TryParse tek bir string alır. Bir değeri header, cookie ve service'lerin birleşiminden üretmen gerekiyorsa static BindAsync daha uygun. Önceki yazıda X-Tenant-Id'yi bir middleware'de çözüp Items'a koymuştuk. Aynı işi bir parametre tipi de yapabilir:
public record InstitutionContext(InstitutionUnit Unit)
{
// Parametre tipinin kendisi binding'i üstleniyor
public static ValueTask<InstitutionContext?> BindAsync(HttpContext context)
{
InstitutionDirectory directory = context.RequestServices.GetRequiredService<InstitutionDirectory>();
string? raw = context.Request.Headers[TenantMiddleware.HeaderName];
InstitutionContext? result = int.TryParse(raw, out int unitId) && directory.Find(unitId) is { } unit
? new InstitutionContext(unit)
: null;
return ValueTask.FromResult(result);
}
}
BindAsync null dönerse ve parametre nullable değilse framework 400 döner; exception fırlatırsa 500. Header'sız istekte Production'da body'siz bir 400 geldi. Log seviyesini Debug'a çektiğimde sebep de göründü:
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[4]
Required parameter "InstitutionContext institution" was not provided from InstitutionContext.BindAsync(HttpContext).
Middleware mi, BindAsync mi? Middleware path'e göre çalışır ve endpoint'e hiç ulaşmadan cevap verebilir; bütün bir URL alanını korumak için uygundur. BindAsync ise yalnız o parametreyi isteyen endpoint'lerde çalışır, handler'ın imzasında görünür ve test etmesi kolaydır. İkisini aynı anda kullanmak gereksiz; Bookstore'da kurumsal alan için middleware'i tutuyoruz, kurumsal path dışındaki tek tük endpoint'lerde BindAsync'i. BindAsync(HttpContext context, ParameterInfo parameter) overload'ı parametrenin adına ve attribute'larına da erişmeni sağlıyor. C# 11'in static abstract üyeleriyle gelen IBindableFromHttpContext<TSelf> interface'i de aynı imzayı derleyiciye kontrol ettiriyor.
[AsParameters] ile Parametre Nesnesi
Listeleme endpoint'i büyüdükçe handler'ın imzası uzar: kategori, arama metni, sayfa, sayfa boyutu, sıralama, bir de service. .NET 7 ile gelen [AsParameters] bunları bir tipte toplamanı sağlıyor:
public record BookQuery(
BookCatalog Catalog,
string? Category,
[FromQuery(Name = "q")] string? Search,
[Range(1, 50)] int PageSize = 10,
int Page = 1,
BookSort Sort = BookSort.Title)
{
public IEnumerable<Book> Apply()
{
IEnumerable<Book> books = Catalog.All;
if (Category is not null)
{
books = books.Where(b => b.Category == Category);
}
if (Search is not null)
{
books = books.Where(b => b.Title.Contains(Search, StringComparison.OrdinalIgnoreCase));
}
books = Sort switch
{
BookSort.Price => books.OrderBy(b => b.Price),
BookSort.PriceDescending => books.OrderByDescending(b => b.Price),
_ => books.OrderBy(b => b.Title),
};
return books.Skip((Page - 1) * PageSize).Take(PageSize);
}
}
[AsParameters] tipin her constructor parametresini (ya da property'sini) ayrı bir handler parametresiymiş gibi bağlar ve aynı kurallar geçerlidir: BookCatalog service olarak, string? ve int'ler query'den, Search attribute'taki adla q'dan. Varsayılan değeri olan parametreler opsiyonel; Page gelmezse 1. Nullable olmayan ve varsayılanı olmayan bir parametre gelmezse 400 alırsın: quantity'siz quote isteği Development'ta Required parameter "int quantity" was not provided from query string. mesajıyla döndü. Learn, [AsParameters]'ın recursive bir model binding olmadığını vurguluyor; iç içe complex tipleri query'den doldurmaz. Performans için record yerine struct önerdiğini de not et. .NET 10 yazısında gördüğümüz AddValidation() da bu tipin içindeki attribute'ları tanıyor:
$ curl "localhost:5238/api/books?category=classics&sort=Price"
[{"id":2,"isbn":"978-0141439587","title":"Emma","price":280,"category":"classics"},{"id":4,"isbn":"978-0141439549","title":"Middlemarch","price":390,"category":"classics"}]
$ curl "localhost:5238/api/books?pageSize=100"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"PageSize":["The field PageSize must be between 1 and 50."]},"traceId":"00-1ffb852c2e8cbe6ca750b2bc7b0a0167-606e571dfebcebf4-00"}
Şimdi tahminin cevabı. ?sort=price 400 döndü. Development'ta body şunu söylüyordu:
$ curl "localhost:5238/api/books?sort=price"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Microsoft.AspNetCore.Http.BadHttpRequestException","status":400,"detail":"Failed to bind parameter \"BookSort Sort\" from \"price\".", ...}
Minimal API enum'ları büyük-küçük harfe duyarlı parse ediyor; Price çalışıyor, price çalışmıyor. Controller tarafında aynı değer Price'a bağlandı, çünkü MVC'nin enum binding'i harf duyarsız. İşin ilginç yanı sayılar: ?sort=1 her iki tarafta da Price oldu, ?sort=42 ise minimal API'de 200 döndü ve enum'da olmayan 42 değeri switch'in varsayılan koluna, yani başlığa göre sıralamaya düştü. Yani enum'u query'den alıyorsan hem harf duyarlılığını hem de tanımsız sayıları kendin ele almalısın.
Form, IFormFile ve Antiforgery
Kitap kapağı yüklemek için form binding'i kullanalım. IFormFile özel bir tip olduğu için attribute istemiyor; formdaki düz alanlar için [FromForm] yazıyoruz:
builder.Services.AddAntiforgery();
// ...
app.UseAntiforgery();
app.MapGet("/api/antiforgery/token", (IAntiforgery antiforgery, HttpContext context) =>
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(context);
return new { tokens.FormFieldName, tokens.RequestToken };
});
app.MapPost("/api/books/{isbn}/cover", (Isbn isbn, IFormFile file, [FromForm] string? altText) =>
new { Isbn = isbn.Value, file.FileName, file.ContentType, file.Length, AltText = altText });
.NET 8'den beri form'dan okuyan her minimal API endpoint'i otomatik olarak antiforgery metadata'sı taşıyor ve UseAntiforgery() middleware'i token'ı doğruluyor. Token'sız istek reddedildi:
$ curl -i -X POST localhost:5238/api/books/978-0441013593/cover -F "file=@cover.png;type=image/png" -F "altText=Dune cover"
HTTP/1.1 400 Bad Request
Content-Length: 0
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[9]
Invalid anti-forgery token found when reading parameter "IFormFile file" from the request body as form.
Önce token endpoint'inden token'ı ve onunla eşleşen cookie'yi alıp (curl -c), sonra cookie'yi (-b) ve token'ı __RequestVerificationToken form alanında gönderdiğimde istek geçti:
{"isbn":"9780441013593","fileName":"cover.png","contentType":"image/png","length":7,"altText":"Dune cover"}
UseAntiforgery() satırını silip denedim; AddAntiforgery() yerinde dursa da istek 500 döndü ve log şunu söyledi: Endpoint HTTP: POST /api/books/{isbn}/cover contains anti-forgery metadata, but a middleware was not found that supports anti-forgery. Mesaj, middleware'in UseAuthentication() ve UseAuthorization()'dan sonra gelmesi gerektiğini de hatırlatıyor.
Antiforgery'nin korumaya çalıştığı saldırı CSRF (başka bir sitenin kullanıcının tarayıcısına onun adına istek attırması). Bu saldırı tarayıcının cookie'yi otomatik göndermesine dayanır. API'n kimliği yalnızca Authorization: Bearer header'ıyla doğruluyorsa, yani cookie authentication kullanmıyorsa, tarayıcı o header'ı kendiliğinden eklemez; bu durumda endpoint'te .DisableAntiforgery() çağırmak makul bir karar. Önemli olan bunu bilerek yapmak. Kapak dosyasının boyut ve tip kontrolü, güvenli dosya adı ve streaming dosya yükleme yazısındaki kurallarla aynı; minimal API orada değişen bir şey getirmiyor. Birden fazla dosya için IFormFileCollection, form alanlarından bir DTO doldurmak için de [FromForm] CoverUpload upload gibi complex bir tip kullanabilirsin.
Binding Hataları: 400'ün Arkasında Ne Var
Bu yazıdaki her 400'ün iki yüzü var. Development'ta RouteHandlerOptions.ThrowOnBadRequest true; binding hatası BadHttpRequestException olarak fırlıyor ve developer exception page onu, AddProblemDetails() açık olduğu için ProblemDetails biçiminde, exception ayrıntılarıyla yazıyor. Production'da ayar false; framework sessizce body'siz bir 400 dönüyor ve sebebi yalnızca Debug seviyesindeki log'a yazıyor:
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[3]
Failed to bind parameter "BookSort Sort" from "price".
İstemci açısından body'siz bir 400 pek yardımcı değil. Bu boşluğu iki yerden doldurabilirsin. Status code pages middleware'i body'siz hata yanıtlarına ProblemDetails gövdesi ekliyor; onu built-in middleware yazısında kuracağız. .NET 11 yazısında gördüğümüz değişiklikle de endpoint filter'lar binding hatasından sonra çalışıp kendi mesajını yazabiliyor. Learn'deki tablo hata türüne göre status code'u da özetliyor: TryParse'ın false dönmesi ve BindAsync'in null dönmesi 400, BindAsync'in exception fırlatması 500, bozuk JSON 400, application/json olmayan bir content type ile gelen body 415.
Controller Karşılığı
Controller'larda aynı işler model binding yazısındaki araçlarla yapılıyor ama birkaç fark var. TryParse yerine MVC TypeConverter'lara ve kendi IModelBinder'larına bakar; Isbn gibi bir tipi controller'da bağlamak için ya bir TypeConverter ya da bir model binder yazarsın. BindAsync'in karşılığı yine custom model binder. [AsParameters]'ın karşılığı ise [FromQuery] BookQuery query; controller bunu .NET 6'dan beri destekliyor, üstelik iç içe complex tipleri de doldurabiliyor. Binding hataları controller'da exception'a değil ModelState'e gider ve [ApiController] otomatik olarak bir ValidationProblemDetails döner; yani controller hata mesajını varsayılan olarak istemciye gösterir, minimal API Production'da göstermez. Antiforgery de controller'larda otomatik değil, [ValidateAntiForgeryToken] gibi attribute'larla açılıyor.
Sık Düşülen Tuzaklar
Birincisi, body'den iki parametre okumaya çalışmak. (CartItem item, InstitutionOrderRequest order) imzasıyla bir endpoint eklediğimde uygulama derlendi ve açıldı, ama ilk istekte endpoint'ler oluşturulurken şu exception geldi ve o andan itibaren uygulamadaki bütün istekler 500 döndü:
System.InvalidOperationException: Failure to infer one or more parameters.
Below is the list of parameters that we found:
Parameter | Source
---------------------------------------------------------------------------------
item | Body (Inferred)
order | UNKNOWN
Body bir kez okunabilen tek bir stream; iki DTO gerekiyorsa onları tek bir request tipinde birleştir.
İkincisi, string query'yi enum'a bağlarken büyük-küçük harf. Tahminde gördük: price 400, 42 ise geçerli. Angular tarafı enum'ları küçük harfle gönderiyorsa ya istemciyle sözleşmeyi netleştir ya da enum'u kendi TryParse'ı olan bir tiple sar.
Üçüncüsü, form endpoint'inde antiforgery'yi bilmeden kapatmak. 400'ü görüp .DisableAntiforgery()'yi yapıştırmak, cookie authentication kullanan bir uygulamada CSRF kapısını açar. Önce kimlik doğrulamanın nasıl yapıldığına bak.
Dördüncüsü, ThrowOnBadRequest'e güvenip Production davranışını test etmemek. Development'ta ayrıntılı mesajlar gören istemci ekibi, Production'da body'siz 400'lerle karşılaşınca şaşırır; integration test'lerini Production ayarlarıyla da çalıştır.
Küçük Bir Deneme
★☆☆ /api/books/{isbn} route'una {isbn:minlength(13)} constraint'i ekle ve tiresiz, 12 haneli bir ISBN gönder. Status code değişti mi? Constraint'in route eşleşmesini, TryParse'ın binding'i etkilediğini gözlemle.
★★☆ BookSort'u saran bir BookSortParameter record struct'ı yaz: TryParse içinde Enum.TryParse<BookSort>(s, ignoreCase: true, out ...) ve Enum.IsDefined kullan. BookQuery'de onu kullanıp ?sort=price'ın 200, ?sort=42'nin 400 döndüğünü gör.
★★★ Kapak yükleme endpoint'ini [FromForm] CoverUpload upload alacak şekilde değiştir (IFormFile File, string? AltText, [Range(1, 5)] int Position). AddValidation()'ın form'dan gelen bu tipi doğrulayıp doğrulamadığını ve antiforgery'nin hâlâ devrede olduğunu curl ile kontrol et.
Binding, handler'a giden yolun ilk yarısı. Bir sonraki yazıda ikinci yarıya geçiyoruz: endpoint'leri route group'larla düzenlemek, araya endpoint filter'lar koymak, TypedResults kataloğu ve tüm bunların OpenAPI belgesine nasıl yansıdığı.