ASP.NET Core Web API'de Routing: Attribute Routing, Route Template ve Constraint'ler
Endpoint routing'in karar ve yürütme adımları, attribute routing ve [controller]/[action] token'ları, route template parçaları, constraint'ler ve öncelik sırası, çakışan route'lar ve AmbiguousMatchException.
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
ProductCatalog API'nin en sade hâli aşağıda. İstemci yanlışlıkla id yerine bir metin gönderiyor: GET /api/products/abc. Hangi status code döner? 400 Bad Request mı, 404 Not Found mı, yoksa abc sayıya çevrilemediği için bir 500 mü?
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Models;
namespace ProductCatalog.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private static readonly List<Product> Products = new()
{
new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m, CategoryId = 1 },
new Product { Id = 2, Name = "Wireless Mouse", Price = 520m, CategoryId = 1 },
new Product { Id = 3, Name = "USB-C Hub", Price = 890m, CategoryId = 2 }
};
[HttpGet("{id:int}")]
public ActionResult<Product> GetById(int id)
{
var product = Products.FirstOrDefault(p => p.Id == id);
return product is null ? NotFound() : product;
}
}
Endpoint Routing: Karar ve Yürütme
Cevap 404, ama ilginç olan nasıl bir 404 olduğu:
$ curl -i http://localhost:5214/api/products/abc
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Tue, 03 Nov 2026 21:34:52 GMT
Server: Kestrel
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:5214/api/products/abc
404
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:5214/api/products/99
404 application/problem+json; charset=utf-8
İkisi de 404, ama biri boş, öteki ProblemDetails body'li. 99 için action çalıştı, ürünü bulamadı ve NotFound() döndü. abc için ise action hiç çalışmadı. {id:int} constraint'i abc'yi reddetti, routing başka uyan bir endpoint bulamadı ve istek hiçbir controller'a ulaşmadan 404 ile döndü. Routing açısından /api/products/abc diye bir adres yok.
ASP.NET Core'da bu iş iki adımda yürür. Önceki yazıda gördüğümüz UseRouting middleware'i isteğin path'ine ve method'una bakar, tanımlı bütün endpoint'ler arasından birini seçer ve onu HttpContext'e iliştirir. Pipeline'ın sonundaki endpoint middleware'i de seçilen endpoint'i çalıştırır. İki adımın arasında kalan her middleware hangi endpoint'in seçildiğini görebilir; authorization'ın bir endpoint'in [Authorize] olup olmadığını bilmesi bu sayede mümkün. Bunu kendi gözümüzle görelim:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.Use(async (context, next) =>
{
var endpoint = context.GetEndpoint();
app.Logger.LogInformation("{Path} matched {Endpoint}", context.Request.Path, endpoint?.DisplayName ?? "(none)");
await next();
});
app.MapControllers();
app.MapGet("/api/orders/{orderId:guid}", (Guid orderId) => new { orderId, Status = "Pending" });
app.MapGet("/debug/routes", (IEnumerable<EndpointDataSource> sources) =>
sources.SelectMany(s => s.Endpoints)
.OfType<RouteEndpoint>()
.Select(e => e.RoutePattern.RawText));
app.Run();
Burada UseRouting çağırmadık; .NET 6'daki WebApplication onu pipeline'ın başına kendisi eklediği için bizim middleware'imiz çalışırken karar çoktan verilmiş oluyor. /debug/routes endpoint'i ise uygulamadaki bütün route'ları listeliyor. Bu yardımcıyı yalnızca geliştirme ortamında tut.
Attribute Routing ile Controller
Controller'ı biraz genişletelim:
using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Models;
namespace ProductCatalog.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private static readonly List<Product> Products = new()
{
new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m, CategoryId = 1 },
new Product { Id = 2, Name = "Wireless Mouse", Price = 520m, CategoryId = 1 },
new Product { Id = 3, Name = "USB-C Hub", Price = 890m, CategoryId = 2 }
};
[HttpGet]
public IEnumerable<Product> GetAll() => Products;
[HttpGet("{id:int:min(1)}")]
public ActionResult<Product> GetById(int id)
{
var product = Products.FirstOrDefault(p => p.Id == id);
return product is null ? NotFound() : product;
}
[HttpGet("{name}")]
public ActionResult<Product> GetByName(string name)
{
var product = Products.FirstOrDefault(p => p.Name.Replace(" ", "-").Equals(name, StringComparison.OrdinalIgnoreCase));
return product is null ? NotFound() : product;
}
[HttpGet("/api/categories/{categoryId:int}/products")]
public IEnumerable<Product> GetByCategory(int categoryId) =>
Products.Where(p => p.CategoryId == categoryId);
[HttpGet("[action]")]
public int Count() => Products.Count;
}
Attribute routing'de route'u action'ın hemen üstüne yazarsın. Controller'daki [Route("api/[controller]")] bir ön ek tanımlar, action'daki [HttpGet("...")] ise o ön ekin devamını ve kabul edilen method'u. İkisi birleşir: GetById için sonuç api/Products/{id:int:min(1)}. [HttpGet], [HttpPost], [HttpPut], [HttpPatch] ve [HttpDelete] aynı şekilde çalışır.
Köşeli parantezler token'dır. [controller], class adından Controller son ekini atarak Products olur; [action] da method adına dönüşür, bu yüzden Count action'ı api/Products/Count adresinde. Token'lar sayesinde controller'ın adını değiştirdiğinde route'lar da kendiliğinden güncellenir. Bunun bir yan etkisi var. İlk yazıda gördüğün Location: http://localhost:5214/api/Products/4 header'ındaki büyük P, buradaki [controller] token'ından geliyor. Eşleşme büyük-küçük harfe duyarlı değil, ama ürettiğin link'ler class adının harflerini taşır.
GetByCategory'deki route / ile başlıyor. Başında / (ya da ~/) olan bir template controller'ın ön ekini yok sayar. Kategoriye ait ürünler gibi başka bir kaynağın altında duran adresleri bu sayede ilgili controller'da tutabilirsin.
Route Template'in Parçaları
Bir template, / ile ayrılmış segment'lerden oluşur. api gibi düz metinler literal segment'tir ve birebir eşleşmek zorundadır. Süslü parantezler parametredir: {id} o segment'teki değeri yakalar ve aynı adlı action parametresine bağlanır. Parametrenin sonuna ? koyarsan opsiyonel olur ({slug?}), = ile varsayılan değer verirsin ({page=1}). {**path} gibi bir catch-all parametresi ise geri kalan bütün path'i tek bir string olarak alır; dosya yolu gibi değerler için kullanılır.
Yazdığımız uygulamanın gördüğü bütün route'lar /debug/routes çıktısında:
$ curl http://localhost:5214/debug/routes
["api/Products","api/Products/{id:int:min(1)}","api/Products/{name}","api/categories/{categoryId:int}/products","api/Products/Count","/api/orders/{orderId:guid}","/debug/routes"]
Token'ların yerine gerçek adların geçtiğini, GetByCategory'nin de ön eksiz kaldığını görüyorsun.
Constraint'ler ve Öncelik
Constraint, parametrenin hangi değerleri kabul edeceğini söyler: int, long, bool, guid, datetime tip kontrolü yapar; min(1), max(100), range(1,100) sayı aralığı, minlength(3), length(3,20) uzunluk, alpha ve regex(...) biçim kontrolü. Birden fazlasını iki noktayla zincirlersin: {id:int:min(1)}. Şimdi aynı controller'a birkaç istek atalım:
$ curl http://localhost:5214/api/products/2
{"id":2,"name":"Wireless Mouse","price":520,"categoryId":1}
$ curl http://localhost:5214/api/products/usb-c-hub
{"id":3,"name":"USB-C Hub","price":890,"categoryId":2}
$ curl http://localhost:5214/api/products/count
3
$ curl http://localhost:5214/api/categories/2/products
[{"id":3,"name":"USB-C Hub","price":890,"categoryId":2}]
Aynı oturumda abc ve 0 için de birer istek attım. Middleware'imizin log'larındaki mesaj satırları, her isteğin hangi action'a gittiğini gösteriyor:
/api/products/2 matched ProductCatalog.Api.Controllers.ProductsController.GetById (ProductCatalog.Api)
/api/products/abc matched ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)
/api/products/0 matched ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)
/api/products/usb-c-hub matched ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)
/api/products/count matched ProductCatalog.Api.Controllers.ProductsController.Count (ProductCatalog.Api)
/api/categories/2/products matched ProductCatalog.Api.Controllers.ProductsController.GetByCategory (ProductCatalog.Api)
Dikkatli bak. 2 değeri hem {id:int:min(1)} hem de {name} ile eşleşebilirdi, ama GetById seçildi. count hem Count action'ının literal'i hem de {name} ile eşleşebilirdi, Count kazandı. Routing'in bir öncelik sırası var: literal segment, constraint'li parametreden; constraint'li parametre, düz parametreden önce gelir. Daha belirgin olan kazanır.
0 ise min(1)'e takıldı ve GetByName'e düştü, abc de öyle. Artık ikisi de action'dan dönen ProblemDetails'li bir 404 alıyor. Yani bir constraint eklediğinde ya da kaldırdığında, sadece o route'un değil komşu route'ların davranışı da değişebilir.
Çakışan Route'lar
Öncelik sırası iki route'u ayıramazsa ne olur? İki action'ın da constraint'siz birer parametresi olsun:
using Microsoft.AspNetCore.Mvc;
namespace ProductCatalog.Api.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetById(int id) => Ok(id);
[HttpGet("{name}")]
public IActionResult GetByName(string name) => Ok(name);
}
Bu kod net6.0 hedefinde sessizce derlenir. Hata çalışma anında, ilk istekte gelir ve status code 500 olur:
Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException: The request matched multiple endpoints. Matches:
ProductCatalog.Api.Controllers.ProductsController.GetById (ProductCatalog.Api)
ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)
Aynı kodu net10.0 hedefinde derlediğimde ise derleyici bunu önceden yakaladı: warning ASP0023: Route '{name}' conflicts with another action route. Bu analyzer .NET 6 hedefinde yok, bu yüzden .NET 6 projelerinde çakışmaları testlerle ya da /debug/routes gibi bir listeyle yakalaman gerekiyor. Çözüm, route'ları gerçekten ayırmak: bir constraint eklemek ({id:int}), literal bir segment koymak (by-name/{name}) ya da biri farklı bir method kullanıyorsa onu doğru attribute ile işaretlemek. Order property'si ile öncelik vermek de mümkün, ama çoğu zaman route'u netleştirmek daha okunur bir çözüm.
Minimal API'lerde Aynı Dil
Template ve constraint dili controller'lara özel değil. Örnekteki sipariş endpoint'i bir minimal API ve aynı {orderId:guid} syntax'ını kullanıyor:
$ curl http://localhost:5214/api/orders/3f2504e0-4f89-11d3-9a0c-0305e82c3301
{"orderId":"3f2504e0-4f89-11d3-9a0c-0305e82c3301","status":"Pending"}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5214/api/orders/42
404
42 bir Guid olmadığı için hiçbir endpoint eşleşmedi; middleware'imizin log'unda bu istek için /api/orders/42 matched (none) yazıyordu. Minimal API'leri kendi yazısında ayrıntılı göreceğiz.
Sık Düşülen Tuzaklar
Constraint'i validation sanmak. {id:int} bir doğrulama kuralı değil, bir eşleşme kuralıdır. Geçersiz değerde istemciye "id sayı olmalı" diyen bir 400 dönmez, sessiz bir 404 döner ya da istek beklemediğin bir komşu action'a gider. Girdiyi doğrulamak istiyorsan bunu model validation ile yap.
[ApiController] ile conventional routing kullanmaya çalışmak. app.MapControllerRoute("default", "{controller}/{action=Index}/{id?}") gibi bir tanımla attribute route'u olmayan bir [ApiController] action'ı bıraktığımda uygulama hiç açılmadı: Action methods on controllers annotated with ApiControllerAttribute must be attribute routed. Web API'de attribute routing kullan.
Literal ile parametreyi yarıştırmak. [HttpGet("{name}")] varken sonradan [HttpGet("count")] eklemek, count adlı bir ürünü artık ulaşılamaz yapar. Route'larını kaynak odaklı ve birbirinden ayrışık tasarla.
Başına / koymayı unutmak ya da yanlışlıkla koymak. "/api/categories/..." controller ön ekini atlar, "api/categories/..." ise ön ekin arkasına eklenir ve api/Products/api/categories/... gibi bir adres ortaya çıkar.
Küçük Bir Deneme
★☆☆ GetById'deki min(1) constraint'ini kaldır ve /api/products/0 isteğinin artık hangi action'a gittiğini log'dan oku.
★★☆ Ürünleri SKU koduyla getiren bir action ekle. SKU'lar KB-1001 biçiminde olsun; regex constraint'i ile yalnızca bu biçimi kabul et ve {name} route'u ile çakışmadığını /debug/routes ve birkaç istekle doğrula.
★★★ Çakışan route örneğini bir projede çalıştır, 500 cevabını gör. Sonra projeyi net10.0 hedefiyle derleyip ASP0023 uyarısını kendi gözünle gör ve çakışmayı üç farklı yöntemle gider.
Routing isteği doğru action'a ulaştırdı. Peki o action'ın içinde neler oluyor? Bir sonraki yazıda controller'ların ve action'ların kendisine bakıyoruz: ControllerBase, [ApiController]'ın getirdikleri, IActionResult ile ActionResult<T> arasındaki fark ve bellek içi bir listeyle tam bir CRUD iskeleti.