HttpContext, HttpRequest ve HttpResponse ile Çalışmak: Header, Cookie, Query, Body, Items ve Feature'lar
ASP.NET Core'da HttpContext bir isteğin bütün hâlidir: request'in header, cookie, query ve body'si, response'un status code, header ve cookie'leri, Items, Features ve RequestAborted. Body'yi iki kez okumayı, response başladıktan sonra header eklemenin hatasını, proxy arkasında doğru IP'yi ve IHttpContextAccessor'ın risklerini 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çı) API'sine her response'a işlemin kaç milisaniye sürdüğünü yazan küçük bir middleware ekliyoruz. Mantık basit: endpoint'i çalıştır, süreyi ölç, header'ı ekle:
app.Use(async (context, next) =>
{
long start = Stopwatch.GetTimestamp();
await next(context);
// Endpoint işini bitirdi, şimdi süreyi yazalım
context.Response.Headers["X-Response-Time-Ms"] = Stopwatch.GetElapsedTime(start).TotalMilliseconds.ToString("F1");
});
GET /api/debug/request endpoint'i JSON dönen sıradan bir minimal API. curl'e bu isteği atarsan ne görürsün? 500 mü, header'sız bir 200 mü, yoksa header'lı bir 200 mü?
37 numaralı .NET 11 yazısıyla sürüm yazıları bitti. Bu yazıyla başlayan altı yazıda her gün dokunduğun ama serinin kemik yazılarında yalnızca kenarından geçtiğimiz API yüzeyine bakacağız: HttpContext, minimal API binding'i, route group ve endpoint filter'lar, JSON ve formatter'lar, built-in middleware'ler ve localization. Kodlar .NET 10 ve minimal API üzerinden; .NET 8'den beri şablonun varsayılanı bu. Controller'daki karşılığını her yazıda kısaca veriyorum. Bu altı yazının kodu repoda samples/daily-api/Bookstore.Api klasöründe tek bir proje olarak büyüyor; dotnet run ile http://localhost:5238 adresinde açılıyor.
HttpContext: Bir İsteğin Bütün Hâli
Kestrel bir HTTP isteğini okuduğunda onu bir HttpContext object'ine çevirir ve pipeline'daki her middleware'e, en sonda da endpoint'e bu object'i verir. Request'in kendisi Request'te, yazacağın yanıt Response'ta durur. Kimliği doğrulanmış kullanıcı User'da, istemci bağlantıyı kopardığında iptal olan token RequestAborted'da, log'larla eşleştirebileceğin istek kimliği TraceIdentifier'da, request boyunca taşınacak küçük veriler Items'ta, server'ın sunduğu alt seviye yetenekler de Features'ta. Scoped service'leri çözen RequestServices de buradadır.
Minimal API'de bu object'leri istemek için parametre yazman yeterli; HttpContext, HttpRequest, HttpResponse, CancellationToken ve ClaimsPrincipal attribute gerektirmeyen özel tiplerdir. Controller'da aynı object'ler ControllerBase'in HttpContext, Request, Response ve User property'lerinden gelir. Yani bu yazıda gördüğün her şey controller'da da aynen geçerli; yalnızca nereden eriştiğin değişiyor.
HttpRequest'i Okumak
İsteğin parçalarını görmek için bir debug endpoint'i yazalım:
app.MapGet("/api/debug/request", (HttpRequest request) =>
{
RequestHeaders headers = request.GetTypedHeaders();
return new
{
request.Method,
request.Scheme,
Host = request.Host.Value,
PathBase = request.PathBase.Value,
Path = request.Path.Value,
QueryString = request.QueryString.Value,
Search = request.Query["q"].ToString(),
UserAgent = request.Headers.UserAgent.ToString(),
AcceptLanguage = headers.AcceptLanguage.Select(l => $"{l.Value}:{l.Quality ?? 1}"),
RemoteIp = request.HttpContext.Connection.RemoteIpAddress?.ToString(),
TraceId = request.HttpContext.TraceIdentifier,
};
});
$ curl -i "localhost:5238/api/debug/request?q=dune&page=2" -H "Accept-Language: tr-TR,tr;q=0.9,en;q=0.8" -H "User-Agent: bookstore-web/1.0"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
X-Response-Time-Ms: 2.7
{"method":"GET","scheme":"http","host":"localhost:5238","pathBase":"","path":"/api/debug/request","queryString":"?q=dune&page=2","search":"dune","userAgent":"bookstore-web/1.0","acceptLanguage":["tr-TR:1","tr:0.9","en:0.8"],"remoteIp":"127.0.0.1","traceId":"0HNQ2IPUMTQPI:00000001"}
Path ile PathBase ayrı tutulur: uygulama bir reverse proxy'nin arkasında /bookstore altında yayınlanıyorsa ve sen UsePathBase("/bookstore") dersen PathBase o ön eki, Path geri kalanı taşır. Link üretirken ikisini birleştirmeyi unutursan proxy arkasında kırık URL'ler üretirsin. Query["q"] bir StringValues döner, çünkü aynı key query string'de birden fazla kez geçebilir; değer yoksa boş StringValues gelir, exception değil.
Header'ları iki türlü okuyabilirsin. request.Headers["User-Agent"] string key'le çalışır; request.Headers.UserAgent gibi property'ler aynı işi yazım hatası riski olmadan yapar. Değerin içini ayrıştırman gerekiyorsa GetTypedHeaders() işe yarar: yukarıda Accept-Language'ı elle split etmeden quality değerleriyle birlikte okuduk. Cookie'ler request.Cookies'te, form alanları request.Form'da. Form'a erişmeden önce HasFormContentType'a bak; JSON gelen bir istekte Form'u okumaya kalkarsan exception alırsın. Async kodda ReadFormAsync()'i tercih et.
Body'yi İki Kez Okumak
Kurumsal siparişlerde (okul ve kütüphanelerin toplu siparişi) denetim için gelen body'yi loglamak istedik. İlk deneme şöyleydi:
app.Use(async (context, next) =>
{
if (HttpMethods.IsPost(context.Request.Method) && context.Request.Path.StartsWithSegments("/api/institution/orders"))
{
using StreamReader reader = new(context.Request.Body, leaveOpen: true);
string body = await reader.ReadToEndAsync(context.RequestAborted);
app.Logger.LogInformation("Institutional order body ({Length} chars): {Body}", body.Length, body);
}
await next(context);
});
Log satırı doğru geldi ama endpoint 400 döndü (Development'ta, çıktıyı kısalttım):
$ curl -i -X POST localhost:5238/api/institution/orders -H "X-Tenant-Id: 101" -H "Content-Type: application/json" -d '{"items":[{"isbn":"978-0547928227","quantity":25}]}'
HTTP/1.1 400 Bad Request
Content-Type: text/plain; charset=utf-8
Microsoft.AspNetCore.Http.BadHttpRequestException: Failed to read parameter "InstitutionOrderRequest order" from the request body as JSON.
---> System.Text.Json.JsonException: The input does not contain any JSON tokens. ...
Request body bir stream ve varsayılan olarak yalnız ileri doğru, bir kez okunur. Middleware sonuna kadar okuduğu için endpoint'in JSON binding'i boş bir stream buldu. Çözüm, okumadan önce EnableBuffering() çağırmak ve okuduktan sonra başa sarmak:
public class RequestBodyLoggingMiddleware(RequestDelegate next, ILogger<RequestBodyLoggingMiddleware> logger)
{
public async Task InvokeAsync(HttpContext context)
{
if (HttpMethods.IsPost(context.Request.Method) && context.Request.Path.StartsWithSegments("/api/institution/orders"))
{
// Body'yi belleğe/diske alıp tekrar okunabilir yapar
context.Request.EnableBuffering();
using StreamReader reader = new(context.Request.Body, leaveOpen: true);
string body = await reader.ReadToEndAsync(context.RequestAborted);
logger.LogInformation("Institutional order body ({Length} chars): {Body}", body.Length, body);
// Sonraki okuyucu baştan başlasın
context.Request.Body.Position = 0;
}
await next(context);
}
}
EnableBuffering body'yi ilk 30 KB'a kadar bellekte, sonrasını geçici bir dosyada tutar. Bu yüzden onu yalnız ihtiyacın olan path'lerde aç; her isteğin body'sini buffer'lamak bellek ve disk maliyetidir. leaveOpen: true da önemli: StreamReader dispose olurken request stream'ini kapatmasın. Body'den yalnız bir DTO istiyorsan stream'le uğraşmana gerek yok; await request.ReadFromJsonAsync<InstitutionOrderRequest>() aynı JSON ayarlarıyla deserialize eder. Request ve response'u loglamak için hazır bir yol da var: Logging serisindeki HTTP logging yazısında anlatılan AddHttpLogging; body'nin maliyeti orada ayrıntılı.
İstemcinin IP Adresi ve Proxy
Connection.RemoteIpAddress TCP bağlantısının karşı ucudur. Uygulaman Nginx, YARP ya da bir load balancer'ın arkasındaysa bu adres istemcinin değil proxy'nin adresi olur; gerçek adres X-Forwarded-For header'ında gelir. ForwardedHeaders middleware'i bu header'ı okuyup RemoteIpAddress ve Scheme'i düzeltir, ama yalnız güvendiği proxy'lerden gelen header'ları kabul eder:
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
// Loopback varsayılan olarak güvenilir; gerçek proxy'nin adresini ekle
options.KnownProxies.Add(IPAddress.Parse("10.0.0.5"));
});
var app = builder.Build();
app.UseForwardedHeaders();
$ curl localhost:5238/api/debug/request -H "X-Forwarded-For: 203.0.113.7" -H "X-Forwarded-Proto: https"
{"method":"GET","scheme":"https",...,"remoteIp":"203.0.113.7",...}
İstek localhost'tan geldiği ve loopback adresleri varsayılan olarak güvenilir listede olduğu için middleware header'a inandı. Bu, kendi makinende test ederken işine yarar ama şunu da gösterir: proxy'siz çalışan bir uygulamada güven listesini gevşetirsen herkes istediği IP'yi yazabilir. Rate limiting'i ya da audit log'u IP'ye bağladıysan bu ciddi bir açık. UseForwardedHeaders pipeline'ın en başında durmalı ki sonraki her middleware düzeltilmiş adresi görsün. .NET 10'daki KnownIPNetworks değişikliği ve container ortamları Web Server'lar ve Hosting serisinin reverse proxy yazısında anlatılıyor.
HttpResponse'a Yazmak
Response tarafında üç şeyi ayırt etmek gerekiyor: status code ve header'lar, cookie'ler, body. Sepet id'sini taşıyan bir cookie ile başlayalım:
const string CartCookie = "bookstore_cart";
app.MapPost("/api/cart/items", (CartItem item, HttpContext context, CartStore carts) =>
{
if (!Guid.TryParse(context.Request.Cookies[CartCookie], out Guid cartId))
{
cartId = Guid.NewGuid();
context.Response.Cookies.Append(CartCookie, cartId.ToString(), new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromDays(7),
IsEssential = true,
});
}
return carts.Add(cartId, item);
});
$ curl -i -X POST localhost:5238/api/cart/items -H "Content-Type: application/json" -d '{"isbn":"978-0441013593","quantity":1}'
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Set-Cookie: bookstore_cart=072ee971-bf4b-4617-8645-d6a0b3f8cd71; max-age=604800; path=/; secure; samesite=lax; httponly
X-Response-Time-Ms: 5.2
[{"isbn":"978-0441013593","quantity":1}]
HttpOnly cookie'yi JavaScript'ten gizler; XSS (sayfaya enjekte edilen script) cookie'yi çalamaz. Secure yalnız HTTPS'te gönderilmesini sağlar; tarayıcılar ve curl localhost'u güvenli kabul ettiği için yerelde HTTP ile de çalıştı. SameSite=Lax başka sitelerden gelen POST'larda cookie'nin gitmesini engeller. Cookie'yi -c ile kaydedip ikinci isteği -b ile gönderdiğimde ikinci kitap aynı sepete eklendi ve GET /api/cart iki satırı döndü. Silmek için Response.Cookies.Delete(CartCookie); bu, süresi geçmiş bir Set-Cookie yazar.
Body'yi elle yazman gerekirse WriteAsJsonAsync var; tenant middleware'inde birazdan kullanacağız. Ama minimal API'de çoğu zaman TypedResults döndürmek daha doğru, çünkü sonuç OpenAPI belgesine de yansır; bunu 40. yazıda göreceğiz.
Tahminin Cevabı: Response Başladıktan Sonra
Tahmin ettiğin middleware'i çalıştırdığımda curl şunu gördü:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
{"method":"GET","scheme":"http",...}
Status 200, body tam, ama X-Response-Time-Ms yok. Server log'unda ise şunlar vardı:
fail: Microsoft.AspNetCore.Server.Kestrel[13]
Connection id "0HNQ2IQ4BPVBC", Request id "0HNQ2IQ4BPVBC:00000001": An unhandled exception was thrown by the application.
System.InvalidOperationException: Headers are read-only, response has already started.
Endpoint JSON'u yazmaya başladığı anda Kestrel status satırını ve header'ları ağa gönderdi; header'lar body'den önce gider ve geri alınamaz. await next(context) döndüğünde response çoktan başlamıştı, header'ı değiştirmeye çalışınca exception geldi. Developer exception page de devreye giremedi ("The response has already started, the error page middleware will not be executed."). Kontrol etmek için context.Response.HasStarted'a bakabilirsin, ama asıl çözüm header'ı doğru anda yazmak:
public class ResponseTimeMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
long start = Stopwatch.GetTimestamp();
// Header'lar gönderilmeden hemen önce çalışır
context.Response.OnStarting(() =>
{
TimeSpan elapsed = Stopwatch.GetElapsedTime(start);
context.Response.Headers["X-Response-Time-Ms"] = elapsed.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture);
return Task.CompletedTask;
});
await next(context);
}
}
OnStarting callback'i header'lar gönderilmeden hemen önce çalışır; yukarıdaki curl çıktılarında gördüğün X-Response-Time-Ms buradan geliyor. CultureInfo.InvariantCulture'ı da bilerek yazdım: bu Mac'in kültürü Türkçe ve onsuz header 2,7 olarak gidiyor. Kardeşi OnCompleted ise response istemciye tamamen gittikten sonra çalışır; artık yanıtı değiştiremezsin ama loglama ve temizlik için uygun:
context.Response.OnCompleted(() =>
{
app.Logger.LogInformation("Response for {OrderNumber} finished with {StatusCode}", orderNumber, context.Response.StatusCode);
return Task.CompletedTask;
});
Items, Features ve Request'e Ait Veriler
Kurumsal müşterinin birimlerinden (kampüs, kütüphane, departman) gelen her istek X-Tenant-Id header'ında birim numarasını taşıyor. Bunu her endpoint'te ayrı ayrı okumak yerine bir middleware'de bir kez çözüp Items'a koyuyoruz:
public class TenantMiddleware(RequestDelegate next, InstitutionDirectory directory)
{
public const string HeaderName = "X-Tenant-Id";
private static readonly object UnitKey = new();
public async Task InvokeAsync(HttpContext context)
{
// Yalnız kurumsal endpoint'ler tenant ister
if (!context.Request.Path.StartsWithSegments("/api/institution"))
{
await next(context);
return;
}
string? raw = context.Request.Headers[HeaderName];
if (!int.TryParse(raw, out int unitId) || directory.Find(unitId) is not { } unit)
{
context.Response.StatusCode = StatusCodes.Status400BadRequest;
await context.Response.WriteAsJsonAsync(new { error = $"Missing or unknown {HeaderName} header." });
return;
}
context.Items[UnitKey] = unit;
await next(context);
}
public static InstitutionUnit GetUnit(HttpContext context) =>
context.Items[UnitKey] as InstitutionUnit
?? throw new InvalidOperationException("TenantMiddleware did not run for this request.");
}
$ curl localhost:5238/api/institution/profile
{"error":"Missing or unknown X-Tenant-Id header."}
$ curl localhost:5238/api/institution/profile -H "X-Tenant-Id: 102"
{"id":102,"institutionName":"Riverside School","name":"Library"}
Items bir IDictionary<object, object?> ve yalnız o request yaşadığı sürece durur. Key olarak string yerine private bir object kullandım: başka bir library'nin "unit" key'iyle çakışma ihtimali sıfır, okumak için de tek kapı GetUnit method'u. Bu header'a körü körüne güvenmek doğru değil tabii; gerçek uygulamada birim numarası token'daki claim'le karşılaştırılmalı. Bunu yetkilendirme yazısındaki resource-based authorization ile yapabilirsin.
Features ise server'ın ve middleware'lerin request'e eklediği yetenekler. Çoğuna HttpContext'in property'leri üzerinden dolaylı erişirsin; doğrudan istediğinde şuna benzer:
app.MapGet("/api/debug/features", (HttpContext context) =>
{
IHttpConnectionFeature connection = context.Features.GetRequiredFeature<IHttpConnectionFeature>();
IHttpMaxRequestBodySizeFeature? bodySize = context.Features.Get<IHttpMaxRequestBodySizeFeature>();
return new
{
connection.ConnectionId,
connection.LocalPort,
MaxRequestBodySize = bodySize?.MaxRequestBodySize,
BodySizeIsReadOnly = bodySize?.IsReadOnly,
HasStarted = context.Response.HasStarted,
Authenticated = context.User.Identity?.IsAuthenticated,
};
});
$ curl localhost:5238/api/debug/features
{"connectionId":"0HNQ2IQA1K6BU","localPort":5238,"maxRequestBodySize":30000000,"bodySizeIsReadOnly":false,"hasStarted":false,"authenticated":false}
Kestrel'in 30.000.000 byte'lık varsayılan body limiti burada görünüyor. IsReadOnly false olduğu sürece, yani body okunmaya başlamadan önce, bu limiti tek bir istek için değiştirebilirsin; dosya yükleme yazısında attribute'la yaptığımız ayarın alt seviyesi bu. Response tarafında IHttpResponseBodyFeature.DisableBuffering() gibi feature'lar da var; Server-Sent Events gibi akış yapan endpoint'ler bunlara dayanır. TraceIdentifier ise Kestrel'de bağlantı id'si ile istek sırasından oluşur ve log'larda aynı isteği bulmanı sağlar. RequestAborted'ı da async ve CancellationToken yazısında görmüştük; minimal API'de CancellationToken parametresi doğrudan ona bağlanır.
IHttpContextAccessor: Ne Zaman ve Neden Dikkatli
Endpoint'te HttpContext elinin altında. Peki sipariş kaydını yazan, bir service'in içindeki bir service ona nasıl ulaşır? AddHttpContextAccessor() ile register edilen IHttpContextAccessor bunu sağlar:
public class OrderAudit(IHttpContextAccessor accessor, ILogger<OrderAudit> logger)
{
public void Record(string orderNumber)
{
// Accessor yalnız request sürerken dolu bir HttpContext verir
HttpContext context = accessor.HttpContext
?? throw new InvalidOperationException("OrderAudit must be used inside a request.");
logger.LogInformation("Order {OrderNumber} placed by unit {UnitId} (trace {TraceId})",
orderNumber, TenantMiddleware.GetUnit(context).Id, context.TraceIdentifier);
}
}
info: Bookstore.Api.Institutional.OrderAudit[0]
Order INS-101-4047 placed by unit 101 (trace 0HNQ2IPUMTQPM:00000001)
info: Bookstore.Api[0]
Response for INS-101-4047 finished with 201
Çalışıyor ama bedeli var. OrderAudit artık HTTP'ye bağlı; bir background job'dan ya da unit test'ten çağırmak için sahte bir context kurman gerekiyor. Accessor AsyncLocal ile çalıştığı için her erişimin küçük bir maliyeti de var. Learn'ün önerisi de aynı yönde: mümkünse ihtiyacın olan değeri (birim numarası, kullanıcı id'si) endpoint'te oku ve method'a parametre olarak geçir. Accessor'ı, araya giremediğin framework kodunun çağırdığı yerler için sakla.
Asıl tehlike request bittikten sonra HttpContext'e dokunmak. Sipariş bildirimini "arka planda" göndermek isteyen şu koda bak:
app.MapPost("/api/institution/orders/{orderNumber}/notify", (string orderNumber, HttpContext context) =>
{
_ = Task.Run(async () =>
{
await Task.Delay(500);
try
{
app.Logger.LogInformation("Notifying unit about {OrderNumber} for {Path}", orderNumber, context.Request.Path);
}
catch (Exception ex)
{
app.Logger.LogError(ex, "Background notification failed");
}
});
return TypedResults.Accepted((string?)null);
});
fail: Bookstore.Api[0]
Background notification failed
System.ObjectDisposedException: IFeatureCollection has been disposed.
Object name: 'Collection'.
202 hemen döndü, yarım saniye sonra HttpContext artık yoktu. Kestrel bu object'leri bir sonraki istek için yeniden kullanır; şanssızsan exception yerine başka bir isteğin verisini okursun. Aynı şeyi accessor'la denediğimde exception gelmedi ama accessor.HttpContext null'dı. Kural basit: arka plana gidecek işe HttpContext'i değil, ihtiyaç duyduğu değerleri kopyalayarak ver ve işi gerçek bir queue'ya ya da hosted service'e bırak. Background job'ları Background Job'lar ve Scheduled Job'lar serisinde ayrıntılı ele alıyoruz.
Sık Düşülen Tuzaklar
Birincisi, response başladıktan sonra header eklemek. Tahmin bölümünde gördük: istemci 200 alıyor, header sessizce kayboluyor, hata yalnızca server log'unda. Header'ı OnStarting içinde yaz ya da next'ten önce ekle.
İkincisi, body'yi buffering olmadan iki kez okumak. İlk okuyan stream'i tüketir, ikinci okuyan boş bulur; minimal API bunu 400 olarak döndürür ve sebebini ancak Development'ta görürsün. EnableBuffering ve Position = 0 ikilisini unutma.
Üçüncüsü, HttpContext'i singleton'da saklamak. Bir singleton'ın field'ına accessor.HttpContext'i atarsan o field ilk isteğin context'ini tutar; sonraki isteklerde ya dispose edilmiş bir object'e ya da başka bir kullanıcının verisine bakarsın. Singleton'a IHttpContextAccessor'ı inject etmek güvenli, değerini saklamak değil.
Dördüncüsü, X-Forwarded-For'a gelişigüzel güvenmek. KnownProxies ve KnownIPNetworks listelerini temizleyip "her yerden kabul et" yaparsan IP tabanlı her kontrolü istemcinin eline bırakırsın.
Küçük Bir Deneme
★☆☆ RequestBodyLoggingMiddleware'den context.Request.Body.Position = 0; satırını sil. Log satırı yine gelirken endpoint'in hangi status code'u döndürdüğüne ve Production ortamında body'nin boş geldiğine bak.
★★☆ TenantMiddleware'deki Items yerine kendi feature'ını kullan: IInstitutionUnitFeature adında bir interface tanımla, context.Features.Set(...) ile ekle, endpoint'te context.Features.Get<IInstitutionUnitFeature>() ile oku. İki yaklaşımın okunabilirliğini karşılaştır.
★★★ /api/cart/items endpoint'inde body limitini IHttpMaxRequestBodySizeFeature ile 1 KB'a düşür ve 2 KB'lık bir body gönder. Hangi status code'u aldığını, limiti body okunduktan sonra değiştirmeye çalıştığında ne olduğunu gözlemle.
HttpContext'i doğrudan okumak her zaman mümkün ama minimal API çoğu işi senin yerine parametrelerle yapıyor. Bir sonraki yazıda bu binding mekanizmasının içine giriyoruz: hangi parametrenin nereden geldiğine nasıl karar verildiği, TryParse ve BindAsync ile kendi tiplerini bağlamak, [AsParameters] ve form binding.