.NET 7 ile ASP.NET Core Yenilikleri: MapGroup, TypedResults, Rate Limiting ve Output Caching
.NET 7 ile gelen MapGroup, TypedResults, endpoint filter'lar, AddProblemDetails, rate limiting ve output caching'i .NET 6 karşılaştırmasıyla anlatıyor; C# 11 required members'a ve geçiş notlarına bakıyoruz.
Sürüm notu: Bu yazı .NET 7 ile gelen yenilikleri anlatır; kodlar .NET 7 içindir.
.NET 7: Minimal API'lerin Olgunlaştığı Sürüm
.NET 6 minimal API'leri getirdi ama ilk sürümleri biraz çıplaktı. Aynı route ön ekini her satırda tekrar yazıyorduk, validation'ı her handler'ın başına elle koyuyorduk, rate limiting için üçüncü parti bir paket arıyorduk. 8 Kasım 2022'de çıkan .NET 7, bu boşlukların çoğunu framework'ün içinden kapattı. C# 11 de aynı paketle geldi.
Bu yazıda route group'ları, tipli sonuçları, endpoint filter'ları, problem details service'ini, yerleşik rate limiting ve output caching middleware'lerini göreceğiz. Her birinde önce .NET 6'da nasıl yazdığımıza, sonra .NET 7'de nasıl yazıldığına bakacağız. En sonda da 6'dan 7'ye geçişin kısa notu var.
Önce Tahmin Et
Bir endpoint'e 10 saniyede en fazla 2 istek izni veren bir rate limiter koyuyorsun ve başka hiçbir ayar yapmıyorsun. Aynı saniye içinde üçüncü isteği attığında hangi status code döner? Çoğu kişinin aklına ilk gelen 429 Too Many Requests. Cevap rate limiting bölümünde.
.NET 6'da Böyle Yazıyorduk
ProductCatalog API'mizin minimal API hâli .NET 6'da şöyleydi:
using ProductCatalog.Api;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IProductService, InMemoryProductService>();
var app = builder.Build();
app.MapGet("/api/products", (IProductService service) => Results.Ok(service.GetAll()));
app.MapGet("/api/products/{id:int}", (int id, IProductService service) =>
{
var product = service.GetById(id);
return product is null ? Results.NotFound() : Results.Ok(product);
});
app.MapPost("/api/products", (CreateProductRequest request, IProductService service) =>
{
// Validation her handler'ın içinde, elle
if (string.IsNullOrWhiteSpace(request.Name) || request.Price <= 0)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["Product"] = new[] { "Name is required and Price must be greater than zero." }
});
}
var product = service.Add(request.Name, request.Price);
return Results.Created($"/api/products/{product.Id}", product);
});
app.Run();
Üç dert var. /api/products ön eki her satırda tekrar ediyor; yarın /api/v2/products'a geçmek istesen hepsini tek tek değiştireceksin. Handler'lar IResult dönüyor, yani derleyici ve Swagger bu endpoint'in 200 mü 404 mü döndüğünü bilmiyor. Validation da handler'ın içine gömülü; ikinci bir POST endpoint'i yazdığında kopyalayacaksın.
MapGroup ile Route Group'lar
.NET 7'de ortak ön eki bir kez tanımlıyorsun:
var products = app.MapGroup("/api/products");
products.MapGet("/", (IProductService service) => TypedResults.Ok(service.GetAll()));
products.MapGet("/{id:int}", ProductEndpoints.GetById);
products.MapPost("/", (CreateProductRequest request, IProductService service) =>
{
var product = service.Add(request.Name, request.Price);
return TypedResults.Created($"/api/products/{product.Id}", product);
})
.AddEndpointFilter<ValidateProductFilter>();
MapGroup bir RouteGroupBuilder döner. Onun üzerinde tanımladığın her endpoint grubun ön ekini alır. Asıl güç ön ekte değil, grubun kendisinin de bir endpoint gibi ayarlanabilmesinde: products.RequireAuthorization() dersen gruptaki tüm endpoint'ler korunur, products.RequireRateLimiting("fixed") dersen hepsine aynı limit uygulanır. Controller'larda class seviyesindeki [Route] ve [Authorize] neyse, minimal API'de grup o. Gruplar iç içe de kurulabiliyor, yani /api grubunun altına /products ve /categories grupları açabilirsin.
TypedResults ve Results<T1, T2>
Yukarıda Results.Ok yerine TypedResults.Ok yazdığımızı fark etmişsindir. İkisi aynı yanıtı üretir ama dönüş tipleri farklı: Results.Ok bir IResult döner, TypedResults.Ok ise Ok<T>. Birden fazla sonuç dönebilen handler'lar için de .NET 7 bir union tipi getirdi:
using Microsoft.AspNetCore.Http.HttpResults;
namespace ProductCatalog.Api;
public static class ProductEndpoints
{
// Dönüş tipi bu endpoint'in hangi yanıtları verebileceğini kendisi söylüyor
public static Results<Ok<Product>, NotFound> GetById(int id, IProductService service)
{
var product = service.GetById(id);
return product is null ? TypedResults.NotFound() : TypedResults.Ok(product);
}
}
Results<Ok<Product>, NotFound> "bu method ya 200 ve bir Product ya da 404 döner" demek. Listede olmayan bir sonuç dönmeye çalışırsan kod derlenmez. Framework bu bilgiyi endpoint metadata'sına da yazıyor, yani OpenAPI belgesi için ayrıca Produces<Product>(200) eklemene gerek kalmıyor. En büyük kazanç ise test tarafında. .NET 6'da bir IResult'ın 404 olduğunu anlamak için iç tipine reflection'la bakmak gerekiyordu, çünkü o tipler internal'dı. .NET 7'de hepsi public:
[Fact]
public void GetById_UnknownId_ReturnsNotFound()
{
var result = ProductEndpoints.GetById(99, new InMemoryProductService());
Assert.IsType<NotFound>(result.Result);
}
[Fact]
public void CreateProductRequest_MissingName_FailsToDeserialize()
{
// C# 11 raw string literal: JSON'u kaçış karakteri olmadan yaz
var json = """
{ "price": 10 }
""";
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<CreateProductRequest>(json, new JsonSerializerOptions(JsonSerializerDefaults.Web)));
}
İkinci test C# 11'in iki yeniliğini birden kullanıyor. Raw string literal, üç tırnak arasına JSON'u olduğu gibi yazmanı sağlıyor. CreateProductRequest ise artık required property'lerle tanımlı:
public class CreateProductRequest
{
public required string Name { get; init; }
public required decimal Price { get; init; }
}
System.Text.Json 7 bu anahtar kelimeye saygı gösteriyor. name alanı olmayan bir body geldiğinde deserialization başarısız oluyor; uygulamayı çalıştırıp böyle bir POST attığımda 400 aldım, log'da da şu satır vardı: JSON deserialization for type 'ProductCatalog.Api.CreateProductRequest' was missing required properties, including the following: name. Üç test de yeşil geçti.
Endpoint Filter'lar ve AddProblemDetails
Validation'ı handler'dan çıkarıp tekrar kullanılabilir bir parçaya taşıyalım. Controller'lardaki action filter'ın minimal API karşılığı endpoint filter:
namespace ProductCatalog.Api;
public class ValidateProductFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
// Handler'ın ilk argümanı CreateProductRequest
var request = context.GetArgument<CreateProductRequest>(0);
if (string.IsNullOrWhiteSpace(request.Name) || request.Price <= 0)
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["Product"] = new[] { "Name is required and Price must be greater than zero." }
});
}
// Her şey yolundaysa sıradaki filter'a ya da handler'a geç
return await next(context);
}
}
Filter, handler'dan önce çalışıyor ve argümanlara erişebiliyor. Sonuç dönerse handler hiç çalışmıyor; next'i çağırırsa zincir devam ediyor. Daha önce gördüğümüz middleware mantığıyla aynı, sadece tek bir endpoint'in etrafına sarılı. {"name":"","price":0} gönderdiğimde handler'a hiç girilmeden 400 döndü.
Hata yanıtlarını tutarlı hâle getirmek için .NET 7'nin bir hamlesi daha var. Program.cs'e şunları ekleyelim:
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
AddProblemDetails(), IProblemDetailsService'i kaydediyor. Exception handler, status code pages ve developer exception page middleware'leri artık yanıt body'sini bu service üzerinden ProblemDetails olarak yazıyor. Farkı görmek için bilinmeyen bir ürünü isteyelim. .NET 6'da Results.NotFound() body'siz bir 404 dönüyordu:
HTTP/1.1 404 Not Found
Content-Length: 0
.NET 7'de bu üç satırla aynı istek şuna dönüşüyor:
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc7231#section-6.5.4","title":"Not Found","status":404}
Fırlatılan bir exception da aynı biçimde 500 olarak geliyor: {"type":"https://tools.ietf.org/html/rfc7231#section-6.6.1","title":"An error occurred while processing your request.","status":500}. Parametresiz UseExceptionHandler()'ın çalışması da AddProblemDetails()'a bağlı. .NET 6'da bu satır uygulama açılırken InvalidOperationException fırlatıyordu, çünkü bir hata sayfası yolu ya da kendi handler'ını vermen gerekiyordu. .NET 7'de AddProblemDetails()'ı silip denedim: aynı exception geliyor, mesajın sonuna da "or configure to generate a 'ProblemDetails' response in 'service.AddProblemDetails()'" eklenmiş.
Rate Limiting Middleware
Daha önce CORS ve güvenlik konusunda rate limiting'in .NET 7 ile geldiğine işaret etmiştik. İşte o middleware:
using Microsoft.AspNetCore.RateLimiting;
builder.Services.AddRateLimiter(options =>
{
options.AddFixedWindowLimiter("fixed", limiter =>
{
limiter.PermitLimit = 2;
limiter.Window = TimeSpan.FromSeconds(10);
// Queue yok: limit dolunca istek hemen reddedilir
limiter.QueueLimit = 0;
});
});
var app = builder.Build();
app.UseRateLimiter();
products.MapGet("/limited", () => TypedResults.Ok("ok"))
.RequireRateLimiting("fixed");
Fixed window, en basit algoritma: 10 saniyelik pencerede 2 izin var, pencere dolunca sayaç sıfırlanıyor. Bunun yanında sliding window, token bucket ve concurrency limiter'ları da var. Şimdi tahminin cevabı. Üç isteği art arda attım:
HTTP/1.1 200 OK
HTTP/1.1 200 OK
HTTP/1.1 503 Service Unavailable
Varsayılan reddetme kodu 429 değil, 503. Bu da istemciye "sen fazla istek attın" yerine "sunucu çöktü" mesajı veriyor; retry mantığı olan bir istemci bu yüzden yanlış davranabilir. Doğrusu bunu açıkça ayarlamak:
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddFixedWindowLimiter("fixed", limiter =>
{
limiter.PermitLimit = 2;
limiter.Window = TimeSpan.FromSeconds(10);
limiter.QueueLimit = 0;
});
});
// Üçüncü istek artık:
// HTTP/1.1 429 Too Many Requests
// Content-Type: application/problem+json
// {"status":429}
Body'nin ProblemDetails olarak gelmesi de az önce eklediğimiz UseStatusCodePages() sayesinde. Daha ayrıntılı bir yanıt ya da Retry-After header'ı istiyorsan OnRejected callback'ini kullanabilirsin. Bir not: bu sayaçlar her uygulama instance'ının belleğinde tutuluyor. Üç instance çalıştırıyorsan gerçek limit üç katı oluyor.
Output Caching
Caching yazısında response caching middleware'ini görmüştük. O middleware HTTP cache header'larına göre çalışıyor ve istemcinin Cache-Control: no-cache göndermesiyle devre dışı kalabiliyor. .NET 7'nin output caching'i ise kararı sunucuya bırakıyor:
builder.Services.AddOutputCache();
var app = builder.Build();
app.UseOutputCache();
products.MapGet("/featured", () => TypedResults.Ok(new { GeneratedAt = DateTime.UtcNow.ToString("HH:mm:ss.fff") }))
.CacheOutput(policy => policy.Expire(TimeSpan.FromSeconds(30)));
Endpoint her çalıştığında o anın saatini dönüyor. Bir saniye arayla iki istek attım:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"generatedAt":"21:20:01.844"}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Age: 1
{"generatedAt":"21:20:01.844"}
İkinci yanıt aynı saati taşıyor ve Age: 1 header'ı, cevabın bir saniyedir cache'te beklediğini söylüyor. Handler ikinci kez çalışmadı. Varsayılan politika bilerek temkinli: yalnızca GET ve HEAD istekleri, yalnızca 200 yanıtları cache'leniyor; cookie set eden yanıtlar ve kimliği doğrulanmış istekler cache'e girmiyor. Tag'lerle bir grup girdiyi tek seferde silmek (EvictByTagAsync) ve SetVaryByQuery ile query parametresine göre ayrı girdiler tutmak da mümkün.
.NET 7'ye Geçiş
Csproj'de TargetFramework değeri net6.0'dan net7.0'a çıkıyor, Microsoft.AspNetCore.*, Microsoft.Extensions.* ve EF Core paketleri de 7.0 serisine. 7.0 serisinin son yaması 7.0.20. Gerekli SDK 7.0.100 ve üstü; global.json kullanıyorsan onu da güncelle. Program.cs iskeleti aynı kaldığı için bu geçiş 3.1'den 6'ya geçişe göre çok daha sakin.
ASP.NET Core 7'nin breaking change listesinde Web API'yi en çok ilgilendiren madde, controller action parametrelerinin artık DI'dan da çıkarılması. Şu action'a bak:
[ApiController]
[Route("api/catalog")]
public class CatalogController : ControllerBase
{
// [FromServices] yok
[HttpGet]
public ActionResult<IEnumerable<Product>> GetAll(IProductService service)
{
return Ok(service.GetAll());
}
}
Aynı kodu iki sürümde de çalıştırdım. .NET 6'da [ApiController] bu complex type parametresini body'den bağlamaya çalışıyor ve body'siz GET isteği 415 Unsupported Media Type ile dönüyor. .NET 7'de framework IProductService'in container'da kayıtlı olduğunu görüyor ve parametreyi oradan çözüyor; yanıt 200 ve ürün listesi. Minimal API'lerde zaten böyleydi, controller'lar ona yetişti. Container'a kayıtlı bir tipi body'den de kabul eden nadir bir action'ın varsa, ApiBehaviorOptions.DisableImplicitFromServicesParameters ile eski davranışa dönebilirsin.
Listede bilmen gereken bir madde daha var: Kestrel artık geliştirme sertifikası olsa bile varsayılan olarak https://localhost:5001'e bağlanmıyor. HTTPS adresini launchSettings.json, ASPNETCORE_URLS ya da --urls ile açıkça vermen gerekiyor.
Destek tarafında ise ilginç bir durum var. .NET 7 bir STS sürümüydü ve o dönemki kurala göre 18 ay destek aldı: 14 Mayıs 2024'te destek dışı kaldı. Yani kendisinden önce çıkan .NET 6 LTS'ten, 12 Kasım 2024'ten, yaklaşık altı ay önce. Bu yüzden birçok ekip production'da 7'yi hiç kullanmadı, 6'dan doğrudan 8'e geçti. Ama .NET 7'nin getirdiklerini bilmek şart, çünkü bu yazıdaki her API .NET 8 ve sonrasında aynen yaşıyor.
Sık Düşülen Tuzaklar
Rate limiter'ın 503 dönmesi en sık görüleni; RejectionStatusCode'u ayarlamayı unutma. İkincisi middleware sırası. Endpoint'e özel RequireRateLimiting ya da [EnableRateLimiting] kullanıyorsan UseRateLimiter, UseRouting'den sonra gelmeli. WebApplication routing'i baştan eklediği için çoğu zaman sorun olmaz, ama kendin UseRouting() çağırıyorsan sıraya dikkat et.
Output cache'te de sıra kritik. Authentication kullanan bir uygulamada UseOutputCache, UseAuthentication ve UseAuthorization'dan sonra gelmeli; aksi hâlde yetkisiz bir kullanıcıya cache'ten içerik dönebilir. CORS kullanıyorsan da UseCors'tan sonra.
Üçüncüsü AddEndpointFilter'ın sırası. Bir endpoint'e birden fazla filter eklediğinde ilk eklenen en dışta çalışır. Validation filter'ını loglama filter'ından önce eklersen geçersiz istekler hiç loglanmaz. Bir de context.GetArgument<T>(0)'daki index handler'ın parametre sırasına bağlı; handler'ın imzasını değiştirdiğinde filter'ı da kontrol et.
Küçük Bir Deneme
★☆☆ .NET 6'daki üç endpoint'i MapGroup("/api/products") altına taşı ve tüm Results.* çağrılarını TypedResults.* ile değiştir. Sonra gruba RequireRateLimiting ekleyip limitin her endpoint'e uygulandığını gör.
★★☆ Rate limiter'ı önce ayarsız çalıştırıp 503'ü, sonra RejectionStatusCode ile 429'u gözünle gör. Ardından OnRejected içinde Retry-After header'ı ekle.
★★★ /featured endpoint'ine Tag("featured") ekle ve IOutputCacheStore.EvictByTagAsync çağıran bir POST /api/cache/featured/purge endpoint'i yaz. Cache'i boşalttıktan sonraki ilk istekte generatedAt değerinin değiştiğini doğrula.
.NET 7 minimal API'leri olgunlaştırdı ama hâlâ bir STS sürümüydü. Bir sonraki yazıda bir LTS olan .NET 8'e geçiyoruz: keyed DI, IExceptionHandler, Identity API endpoint'leri, Native AOT, TimeProvider ve C# 12'nin primary constructor'ları.