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

Web API'de Localization: Accept-Language, RequestLocalization, Yerelleştirilmiş Validation ve ProblemDetails Mesajları

Mert Özen 15 Kas 2026 9 dk 14 görüntülenme
Web API'de Localization: Accept-Language, RequestLocalization, Yerelleştirilmiş Validation ve ProblemDetails Mesajları

ASP.NET Core Web API'de localization, request culture'ı Accept-Language, query ya da cookie'den belirleyip insanın okuduğu metinleri (validation ve ProblemDetails mesajları) dile göre üretmektir; type, code ve error key'leri gibi makine alanları hiç değişmez. .NET 10'da resx, IStringLocalizer ve ErrorMessageResourceType ile Bookstore'un kurumsal sipariş endpoint'ini TR/EN yapı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çı) Angular istemcisinde kullanıcı arayüz dilini Türkçe seçmiş ve fiyat filtresine Türkçe alışkanlıkla 300,5 yazmış. İstemci isteği şöyle gönderiyor:

GET /api/books?maxPrice=300,5
Accept-Language: tr-TR

API'de request localization açık ve Türkçe destekleniyor. BookQuery'deki decimal? MaxPrice parametresi hangi değeri alır: 300,5 mi, 400 mü, yoksa 3005 mi? Dönen listede hangi kitaplar var?

Bir önceki yazıda API'nin bütün hata yanıtlarını ProblemDetails'e çevirdik, ama hepsi İngilizce. Bookstore'un kullanıcıları Türkçe ve İngilizce, kurumsal portalı kullanan okul personeli de çoğunlukla Türkçe. Bu yazıda API'de localization'ı kuruyoruz: request culture'ı belirlemek, mesajları resource dosyalarından okumak, validation ve ProblemDetails mesajlarını dile göre üretmek ve en önemlisi neyin yerelleştirilmemesi gerektiği. View tarafındaki localization, yani IViewLocalizer ve Razor, ASP.NET Core MVC ve Razor Pages serisinin localization yazısında anlatılıyor.

RequestLocalization: Culture'ı Belirlemek

builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
builder.Services.AddRequestLocalization(options =>
{
    string[] cultures = ["en-US", "tr-TR"];
    options.SetDefaultCulture(cultures[0])
        .AddSupportedCultures(cultures)
        .AddSupportedUICultures(cultures);
    options.ApplyCurrentCultureToResponseHeaders = true;
});

// ...

app.UseForwardedHeaders();
app.UseRequestLocalization();
app.UseExceptionHandler();

Middleware her istekte culture provider'larını sırayla dener: önce query string (?culture=tr-TR&ui-culture=tr-TR), sonra .AspNetCore.Culture cookie'si, en son Accept-Language header'ı. İlk eşleşen kazanır ve CultureInfo.CurrentCulture ile CultureInfo.CurrentUICulture o istek için ayarlanır. Eşleşen yoksa ya da istenen dil desteklenmiyorsa varsayılana düşer. ApplyCurrentCultureToResponseHeaders seçilen dili Content-Language header'ıyla istemciye de söylüyor:

$ curl -i -X POST localhost:5238/api/institution/orders -H "X-Tenant-Id: 101" -H "Accept-Language: de-DE" -H "Content-Type: application/json" -d '{"items":[{"isbn":"978-0441013593","quantity":5},{"isbn":"978-0141439587","quantity":0}]}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Content-Language: en-US

Almanca desteklenmediği için en-US'e düştü. tr-TR,tr;q=0.9 gönderen bir tarayıcı da tr-TR aldı. İki culture'ın farkı önemli: CurrentCulture sayı, tarih ve para biçimlendirmesini, CurrentUICulture ise hangi resource dosyasından metin okunacağını belirliyor. API'lerde ikisini genelde aynı tutarsın, ama "arayüz İngilizce, sayılar Türk biçiminde" gibi bir ihtiyaç varsa ayrı ayrı da seçilebiliyor. Angular tarafında dil seçimini bir interceptor ile her isteğin Accept-Language header'ına koymak, query string'i kirletmekten daha temiz.

Culture Neyi Değiştirir, Neyi Değiştirmez

Tahminin cevabı:

$ curl -s "localhost:5238/api/books?maxPrice=300.5" -H "Accept-Language: tr-TR" | jq -r '.[].title'
Emma

$ curl -s "localhost:5238/api/books?maxPrice=300,5" -H "Accept-Language: tr-TR" | jq -r '.[].title'
Dune
Emma
Middlemarch
The Hobbit

300,5 3005 olarak okundu ve dört kitabın hepsi döndü; istek hata bile vermedi. 39'uncu binding yazısında söylediğimiz gibi minimal API, TryParse'a request culture'ını değil invariant culture'ı veriyor. Invariant culture'da virgül binlik ayırıcı; 300,5 geçerli bir sayı ve değeri 3005. Aynı denemeyi Accept-Language: en-US ile yaptığımda sonuç değişmedi, yani request localization query binding'e hiç dokunmuyor. Controller'da [FromQuery] decimal max ile denediğimde de aynı sonucu aldım; MVC de query ve route değerlerini invariant culture ile çeviriyor.

Bu aslında doğru tasarım: URL bir makine contract'ı ve aynı istek her dilde aynı anlama gelmeli. Hata, sayıyı kullanıcının yazdığı haliyle URL'ye koyan istemcide. Angular'da form değerini number tipine çevirip öyle göndermek gerekiyor. API tarafında yapabileceğin şey, para filtresi gibi parametrelerde virgül içeren değeri reddetmek ya da bir önceki yazılardaki gibi kendi TryParse'ı olan bir tip kullanmak. JSON body'de durum daha net: JSON sayıları her zaman nokta kullanıyor ve System.Text.Json culture'a hiç bakmıyor.

Culture'ın gerçekten etkilediği yer çıktı tarafı: decimal.ToString(), tarih biçimleri, string.Format ile üretilen mesajlar. .NET 11 yazısındaki Range mesajında 0,01 görmüştük; bu yazıdaki mesajlarda da sayılar culture'a göre biçimleniyor. JSON response'lardaki sayılar ise yine culture'dan bağımsız.

IStringLocalizer ve Resource Dosyaları

Metinler .resx dosyalarında. Her dil için ayrı bir dosya var; dosya adındaki culture eki (.tr) hangi dile ait olduğunu söylüyor:

Resources/
  SharedMessages.resx
  SharedMessages.tr.resx
  ValidationMessages.resx
  ValidationMessages.tr.resx
<data name="MinimumInstitutionalQuantity" xml:space="preserve">
  <value>Kurumsal siparişlerde toplam en az {0} adet olmalı; bu siparişte {1} adet var.</value>
</data>
<data name="IsbnNotFound.Title" xml:space="preserve">
  <value>Kitap bulunamadı.</value>
</data>
<data name="Status404" xml:space="preserve">
  <value>İstenen kaynak bulunamadı.</value>
</data>

IStringLocalizer<T>, T'nin tam adından resource adını üretiyor. Bir API'de mesajları class class dağıtmak yerine tek bir işaret class'ında toplamak pratik:

namespace Bookstore.Api;

// IStringLocalizer<SharedMessages> için işaret class'ı; metinler Resources/SharedMessages*.resx'te
public sealed class SharedMessages;

ResourcesPath = "Resources" ve root namespace Bookstore.Api olduğu için IStringLocalizer<SharedMessages> Bookstore.Api.Resources.SharedMessages adlı resource'u arıyor. Build, Türkçe dosyayı bin/Debug/net10.0/tr/Bookstore.Api.resources.dll adlı bir satellite assembly'ye derledi. Key bulunamazsa localizer exception fırlatmıyor, key'in kendisini dönüyor ve ResourceNotFound'u true yapıyor. Bu, eksik çeviriyi sessizce ekrana taşıyabilir; birazdan bunu ProblemDetails'te bilerek kullanacağız.

Validation Mesajlarını Yerelleştirmek

.NET 10'da AddValidation()'ın mesajları için IStringLocalizer entegrasyonu yok; o, .NET 11 yazısında gördüğümüz bir yenilik. .NET 10'da Data Annotations'ın eski yolu çalışıyor: attribute'a mesajın hangi resource property'sinden okunacağını söylüyorsun. Bu property'ler public static olmalı:

// Data Annotations'ın ErrorMessageResourceType'ı public static string property'ler ister
public static class ValidationMessages
{
    private static readonly ResourceManager Resources =
        new("Bookstore.Api.Resources.ValidationMessages", typeof(ValidationMessages).Assembly);

    public static string Range => Resources.GetString(nameof(Range), CultureInfo.CurrentUICulture)!;

    public static string Required => Resources.GetString(nameof(Required), CultureInfo.CurrentUICulture)!;
}

public record InstitutionOrderLine(
    [Required(ErrorMessageResourceType = typeof(ValidationMessages), ErrorMessageResourceName = nameof(ValidationMessages.Required))]
    string Isbn,
    [Range(1, 500, ErrorMessageResourceType = typeof(ValidationMessages), ErrorMessageResourceName = nameof(ValidationMessages.Range))]
    int Quantity);

Visual Studio'nun resx designer'ı bu class'ı kendisi üretir ama internal yapar; attribute'un erişebilmesi için designer'da erişimi public'e çevirmen gerekir. Elle yazmak bir kerelik iş ve ne yaptığı açıkça görünüyor. Kurumsal siparişin "toplam en az 20 adet" kuralı birden fazla satıra baktığı için IValidatableObject'te; mesajı oradan localizer'la üretiyoruz:

public record InstitutionOrderRequest(List<InstitutionOrderLine> Items) : IValidatableObject
{
    public const int MinimumQuantity = 20;

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        int total = Items.Sum(i => i.Quantity);
        if (total < MinimumQuantity)
        {
            IStringLocalizer<SharedMessages> localizer =
                validationContext.GetRequiredService<IStringLocalizer<SharedMessages>>();
            yield return new ValidationResult(
                localizer["MinimumInstitutionalQuantity", MinimumQuantity, total],
                [nameof(Items)]);
        }
    }
}
$ curl -X POST localhost:5238/api/institution/orders -H "X-Tenant-Id: 101" -H "Accept-Language: tr-TR" -H "Content-Type: application/json" -d '{"items":[{"isbn":"978-0441013593","quantity":5},{"isbn":"978-0141439587","quantity":0}]}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bir veya daha fazla doğrulama hatası oluştu.","status":400,"errors":{"Items[1].Quantity":["Quantity alanı 1 ile 500 arasında olmalı."]},"traceId":"00-b68d6f2c0947b2b3a89bfdfdde91c348-348f5a15b8476ca9-00"}

$ curl -X POST ... -H "Accept-Language: en-US" ...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Items[1].Quantity":["Quantity must be between 1 and 500."]},"traceId":"00-690ef4d66f635055648686ef3fef9649-29c306607bfd332e-00"}

$ curl -X POST ... -H "Accept-Language: tr-TR" -d '{"items":[{"isbn":"978-0441013593","quantity":5}]}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bir veya daha fazla doğrulama hatası oluştu.","status":400,"errors":{"Items":["Kurumsal siparişlerde toplam en az 20 adet olmalı; bu siparişte 5 adet var."]},"traceId":"00-e2558ddfdc479ca2563aac05c99584ff-12fcf769b77df50b-00"}

İlk istekte toplam 5 adet olduğu halde "en az 20" mesajı gelmedi. Bu Data Annotations'ın bilinen davranışı: property seviyesindeki attribute'lardan biri hata verirse object seviyesindeki Validate çalışmıyor. İstemci önce satır hatalarını, düzeltince toplam kuralını görüyor. Mesajdaki Quantity ise hâlâ İngilizce, çünkü {0} property'nin adı. Onu da çevirmek istersen [Display(Name = ..., ResourceType = ...)] kullanırsın; ama errors sözlüğünün key'i (Items[1].Quantity) her dilde aynı kalmalı.

ProblemDetails'i Yerelleştirmek

Validation dışındaki hatalar için iki ayrı durum var. Framework'ün kendi ürettiği ProblemDetails'lerin (404, 405, validation 400) title'larını CustomizeProblemDetails içinde değiştiriyoruz:

builder.Services.AddProblemDetails(options => options.CustomizeProblemDetails = context =>
{
    // Kendi code'u olan problem'ler zaten yerelleştirilmiş geliyor
    if (context.ProblemDetails.Extensions.ContainsKey("code") || context.ProblemDetails.Status is not { } status)
    {
        return;
    }

    IStringLocalizer<SharedMessages> localizer =
        context.HttpContext.RequestServices.GetRequiredService<IStringLocalizer<SharedMessages>>();
    LocalizedString title = localizer[$"Status{status}"];
    if (!title.ResourceNotFound)
    {
        context.ProblemDetails.Title = title;
    }
});
$ curl -i localhost:5238/api/nope -H "Accept-Language: tr-TR"
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: tr-TR

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"İstenen kaynak bulunamadı.","status":404,"traceId":"00-ce4c00348c59ff46b69ab284b9192798-467bad1dfe342b73-00"}

ResourceNotFound kontrolü sayesinde çevirisi olmayan bir status code framework'ün İngilizce title'ıyla kalıyor, key adı (Status418 gibi) istemciye sızmıyor. ?culture=tr-TR query string'iyle ve .AspNetCore.Culture=c=tr-TR|uic=tr-TR cookie'siyle gönderdiğim istekler de aynı Türkçe title'ı aldı.

İkinci durum bizim iş kurallarımız. Katalogda olmayan bir ISBN ile kurumsal sipariş geldiğinde endpoint kendi problem'ini üretiyor ve metni orada yerelleştiriyor:

if (order.Items.FirstOrDefault(i => !Isbn.TryParse(i.Isbn, null, out Isbn isbn) || catalog.Find(isbn) is null) is { } missing)
{
    // type ve code makine için: hiçbir dilde değişmez
    return TypedResults.Problem(
        type: "https://bookstore.example.com/problems/isbn-not-found",
        title: localizer["IsbnNotFound.Title"],
        detail: localizer["IsbnNotFound.Detail", missing.Isbn],
        statusCode: StatusCodes.Status422UnprocessableEntity,
        extensions: new Dictionary<string, object?> { ["code"] = "catalog.isbn_not_found", ["isbn"] = missing.Isbn });
}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Content-Language: tr-TR

{"type":"https://bookstore.example.com/problems/isbn-not-found","title":"Kitap bulunamadı.","status":422,"detail":"Katalogda 978-0000000000 ISBN'li bir kitap yok.","code":"catalog.isbn_not_found","isbn":"978-0000000000","traceId":"00-fbdb9a7ab3ceb27d9b855ec5c81cd3cc-4833809c8cfc2dfa-00"}

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Content-Language: en-US

{"type":"https://bookstore.example.com/problems/isbn-not-found","title":"The book could not be found.","status":422,"detail":"No book with ISBN 978-0000000000 is in the catalog.","code":"catalog.isbn_not_found","isbn":"978-0000000000","traceId":"00-7e875bb4ae78a9df1ee99015c79110ae-038ad5822ec4e190-00"}

Neyi Yerelleştirmemeli

İki 422'yi yan yana koyunca kural ortaya çıkıyor: insanın okuduğu alanlar (title, detail, validation mesajları) dile göre değişti; makinenin okuduğu alanlar (type, status, code, isbn, errors'un key'leri) değişmedi. Angular istemcisi davranışını code'a göre seçmeli, örneğin catalog.isbn_not_found geldiğinde sepetteki satırı işaretlemeli; mesajı ise yalnızca kullanıcıya göstermeli. title'a göre if yazan bir istemci, kullanıcı dili değiştirdiği gün bozulur. type URI'si RFC 9457'deki (HTTP API'leri için Problem Details standardı) tanımıyla hata türünün kimliği; dile göre değişen bir kimlik olmaz. Aynı mantıkla enum değerleri, JSON property adları, sipariş durumları ("placed") ve log mesajları da yerelleştirilmez. Log'ları her dilden ekip üyesi okuyabilmeli ve aynı hatanın log'u dile göre farklı görünmemeli. Hata contract'ının tasarım tarafı, yani code kataloğu ve type URI'lerinin nasıl seçileceği, ilerideki REST API Tasarımı serisinin hata contract'ı yazısında ele alınıyor.

Controller Karşılığı

Controller'larda Data Annotations mesajları için AddControllers().AddDataAnnotationsLocalization() var; attribute'lara ErrorMessage olarak bir key yazıyorsun, MVC onu IStringLocalizer üzerinden çözüyor. Yani .NET 10'da controller tarafı minimal API'den daha rahat; .NET 11 bu farkı AddValidation() için kapattı. Model binding'in kendi mesajları ("The value 'abc' is not valid." gibi) MvcOptions.ModelBindingMessageProvider üzerinden değiştiriliyor; minimal API'de bu mesajlar zaten Production'da istemciye gitmiyor. UseRequestLocalization, IStringLocalizer, resource dosyaları ve ProblemDetails customization iki tarafta da aynı.

Sık Düşülen Tuzaklar

Birincisi, UseRequestLocalization'ı pipeline'da geç koymak. Middleware'i UseRouting'den sonraya taşıyıp denedim:

$ curl -i localhost:5238/api/nope -H "Accept-Language: tr-TR"
HTTP/1.1 404 Not Found
Content-Type: application/problem+json
Content-Language: tr-TR

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"The resource could not be found.","status":404,"traceId":"00-0c37c6de2f0361591ab8e0af6b7af6dc-4abc35fa2692a5e7-00"}

Header Türkçe diyor, title İngilizce. Validation 400'ü Türkçe kaldı, çünkü endpoint'in içinde yazılıyor. 404 ise status code pages middleware'i tarafından, iç middleware'ler döndükten sonra yazılıyor. Localization middleware culture'ı async akışın içinde ayarlıyor ve iç akış bitince dıştaki middleware eski culture'ı görüyor. Kural: response yazabilen her middleware'den, yani exception handler ve status code pages'ten önce. RouteDataRequestCultureProvider (culture'ı route'tan okuyan provider) kullanıyorsan routing'den sonra gelmesi gerekiyor; o zaman exception handler'ın mesajlarını culture feature'ından kendin okursun.

İkincisi, JSON body'deki ya da query'deki ondalık sayıyı culture'a göre parse etmeye çalışmak. Tahminde gördük: 300,5 hata vermeden 3005 oldu. Makineler arası contract invariant kalmalı; çeviriyi istemci yapmalı.

Üçüncüsü, makine tarafından okunan alanları yerelleştirmek: code, type, enum değerleri ve errors key'leri. Bir kere yerelleştirilmiş bir code yayınlarsan geri almak breaking change olur.

Dördüncüsü, Türkçe büyük-küçük harf dönüşümünü unutmak. Request culture'ı tr-TR olan bir istekte "title".ToUpper() sonucu "TİTLE" olur. Karşılaştırma ve key üretiminde ToUpperInvariant ya da StringComparison.OrdinalIgnoreCase kullan; bu tuzak MVC serisinin localization yazısında ve ilerideki C# ile Günlük .NET API'leri serisinin globalization yazısında ayrıntılı.

Küçük Bir Deneme

★☆☆ InstitutionOrderLine.Quantity'ye [Display(Name = "Quantity", ResourceType = typeof(ValidationMessages))] ekle ve ValidationMessages'a Quantity property'sini, resx'lere de "Quantity"/"Adet" çevirisini koy. Türkçe mesajın "Adet alanı 1 ile 500 arasında olmalı." olduğunu, errors key'inin ise değişmediğini doğrula.

★★☆ BookQuery.MaxPrice'ı virgül içeren değerleri reddeden bir Price tipiyle değiştir (TryParse içinde NumberStyles.AllowDecimalPoint ve CultureInfo.InvariantCulture). ?maxPrice=300,5'in artık 400 döndüğünü gör.

★★★ Projeyi net11.0'a taşı ve AddLocalization() ile AddValidation() birlikteyken ErrorMessageResourceType olmadan, yalnızca RangeAttribute_Error gibi key'lerle mesajları çevir. İki yaklaşımın aynı DTO'da birlikte nasıl davrandığını karşılaştır.

Bu yazıyla 38'de başlayan günlük API yüzeyi bloğu da bitti: HttpContext'ten binding'e, filter'lardan JSON'a, middleware'lerden localization'a kadar her gün dokunduğun parçaları .NET 10 üzerinde gördük. Seri, .NET 12 çıktığında yeni sürüm yazısıyla devam edecek.