Mert Özen Her Satırda Daha İleri
Backend gRPC ve GraphQL — #09

GraphQL'i Güvenli İşletmek: Cost Analysis, Persisted Operation'lar, Authorization ve Schema Evolution

Mert Özen 14 Kas 2026 13 dk 4 görüntülenme
GraphQL'i Güvenli İşletmek: Cost Analysis, Persisted Operation'lar, Authorization ve Schema Evolution

GraphQL'i production'da güvenli işletmek için bilinmeyen sorguyu persisted operation'larla, pahalı sorguyu cost analysis ve derinlik sınırıyla durdurur; yetkiyi field ve kaynak düzeyinde, schema değişikliklerini CI'daki diff ile denetlersin. Hot Chocolate 16'da alias ve variable batching'in maliyeti nasıl çoğalttığını, semantic introspection'ı, hata maskelemeyi, @requiresOptIn'i ve Türkçe bölge ayarında açılmayan cache control'ü gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Hot Chocolate 16.7, Entity Framework Core (EF Core) 10.0 ve SQL Server 2025 ile derlenip denenmiştir; schema karşılaştırması GraphQL Inspector 7.0 ile yapılmıştır.

Önce Tahmin Et

Bir önceki yazıda books'u DataLoader'lı ve sayfalı hâle getirdik: 50 kitap ve yazarları iki SQL sorgusuyla geliyor, sayfa boyutu en fazla 50. Bir client aynı field'ı 20 farklı alias ile, her biri first: 50 olacak şekilde tek bir istekte gönderiyor:

{
  a0: books(first: 50) { nodes { title author { name } } }
  a1: books(first: 50) { nodes { title author { name } } }
  # ... a19'a kadar
}

Sayfa sınırı aşılmadı, derinlik üç seviye, DataLoader da yazarları cache'liyor. Hot Chocolate 16 bu isteği varsayılan ayarlarla çalıştırır mı? Çalıştırırsa kaç kitap satırı serileştirilir?

Cevap: çalıştırmıyor. Ama "neden" sorusunun cevabı bir sayıda saklı ve o sayıyı okumayı bilmek, GraphQL'i production'da işletmenin ilk adımı.

Kaynak Tüketimi: Cost, Derinlik, Alias ve Batching

REST (Representational State Transfer) API'de bir endpoint'in maliyeti aşağı yukarı sabittir. GraphQL'de maliyeti client'ın yazdığı sorgu belirler: derinlik (yazar → kitaplar → yazar → ...), genişlik (büyük sayfalar), alias ile aynı field'ı çoğaltmak ve birden çok operation'ı tek HTTP isteğinde göndermek. API Güvenliği serisinin API4 (Unrestricted Resource Consumption) yazısındaki risklerin GraphQL'e özgü hâlleri bunlar.

Hot Chocolate 14'ten beri IBM'in GraphQL Cost Directive spesifikasyonuna dayanan cost analysis varsayılan olarak açık ve zorlayıcı. Her field'a bir ağırlık veriliyor; bir önceki yazının SDL'inde (GraphQL şemasını yazmak için kullanılan dil) gördüğün @cost(weight: "10") ve @listSize(assumedSize: 50, ...) direktifleri bu hesabın girdileri. GraphQL-Cost: validate header'ı sorguyu çalıştırmadan maliyetini döndürüyor:

{ books(first: 5) { nodes { title author { name } } } }
{"extensions":{"operationCost":{"fieldCost":61,"typeCost":12}}}

{ books(first: 50) { nodes { title author { name } } } }
{"extensions":{"operationCost":{"fieldCost":511,"typeCost":102}}}

{ books(first: 50) { nodes { author { books { author { books { title } } } } } } }
{"extensions":{"operationCost":{"fieldCost":2011,"typeCost":252}}}

Maliyet sayfa boyutuyla doğrusal büyüyor: 5 kitapta 61, 50 kitapta 511. first bir slicing argument olduğu için Hot Chocolate listenin boyutunu client'ın verdiği sayıdan biliyor. Varsayılan sınırlar field cost için 1.000, type cost için 10.000. Üçüncü sorgu ve baştaki 20 alias'lı istek reddedildi:

400 application/graphql-response+json; charset=utf-8
{"errors":[{"message":"The maximum allowed field cost was exceeded.","extensions":{"code":"HC0047","fieldCost":10220,"maxFieldCost":1000}}]}

20 × 511 = 10.220. Alias'lar maliyeti çoğaltıyor ve cost analysis bunu tek tek topluyor. Bu reddetme çalıştırmadan önce, doğrulama aşamasında oluyor; veritabanına tek sorgu gitmedi.

Hot Chocolate 16'da yeni bir çarpan daha var: variable batching. Aynı operation'ı birden çok variable kümesiyle tek istekte göndermek varsayılan olarak açık ve yanıt JSON (JavaScript Object Notation) Lines biçiminde, her satırda bir nesne olarak akıyor. İlk bakışta her küme ayrı ölçülüyor gibi görünüyor; GraphQL-Cost: validate ile iki first: 10 kümesi gönderdiğimde her satırda ayrı "fieldCost":111 geldi. Ama sınır toplam üzerinden uygulanıyor: first: 30 ile 3 küme (3 × 311 = 933) çalıştı, 4 küme "fieldCost":1244 ile reddedildi. Birden çok farklı operation'ı bir dizi olarak göndermek (request batching) ise Hot Chocolate 16'da varsayılan olarak kapalı; denediğimde HC0009 "Invalid GraphQL Request." aldım. Açman gerekirse MaxBatchSize'ı (varsayılanı 1024) mutlaka düşür.

Cost analysis'in yanına iki ucuz kural daha ekledim:

builder.Services
    .AddGraphQLServer()
    // ...
    .AddMaxExecutionDepthRule(6)
    .ModifyRequestOptions(o =>
    {
        o.ExecutionTimeout = TimeSpan.FromSeconds(10);
        // ...
    });
{"errors":[{"message":"The GraphQL document has an execution depth of 7 which exceeds the max allowed execution depth of 6.","locations":[{"line":1,"column":1}],"extensions":{"allowedExecutionDepth":6,"detectedExecutionDepth":7}}]}

Derinlik sınırı cost'un kaçırabileceği patolojik iç içe sorguları kaba ama anlaşılır biçimde keser. Execution timeout'un varsayılanı 30 saniye; kuyrukta bekleme süresini de kapsıyor. Bir web ekranı için 10 saniye bile cömert.

Persisted (Trusted) Operation'lar

Cost analysis "ne kadar pahalı" sorusunu yanıtlar; persisted operation'lar ise daha temel bir soruyu: "bu sorguyu hiç tanıyor muyuz?". Bookstore'un tek client'ı kendi Angular uygulamamız. O uygulamanın gönderebileceği bütün sorgular build anında belli; production server'ın başka bir sorgu kabul etmesi için hiçbir sebep yok. Bu düzende client sorgu metnini değil, metnin hash'ini gönderir; server hash'i kendi deposunda arar.

builder.Services
    .AddGraphQLServer()
    // ...
    .ModifyRequestOptions(o =>
    {
        // ...
        o.PersistedOperations.OnlyAllowPersistedDocuments = !builder.Environment.IsDevelopment();
    })
    .UsePersistedOperationPipeline()
    .AddFileSystemOperationDocumentStorage("./persisted");

HotChocolate.PersistedOperations.FileSystem paketi her operation'ı persisted/<hash>.graphql dosyasında arıyor; varsayılan hash MD5 (artık güvenli sayılmayan eski hash algoritması) ve hex. Kitap listesi sorgusunu (query BookList($first: Int!)) bir dosyaya yazıp içeriğinin MD5 hash'ini hesapladım: b5923b0f7b178c314b18da2432c9799c. Production'da:

{"id":"b5923b0f7b178c314b18da2432c9799c","variables":{"first":2}}
200 {"data":{"books":{"nodes":[{"title":"Distributed Systems Vol. 1","price":155.00,"author":{"name":"Ada Byron"}},{"title":"Protobuf Vol. 1","price":160.00,"author":{"name":"Alan Turing"}}]}}}

{"query":"{ books(first: 2) { nodes { title } } }"}
400 {"errors":[{"message":"Only persisted operations are allowed.","extensions":{"code":"HC0067"}}]}

{"id":"00000000000000000000000000000000"}
400 {"errors":[{"message":"The specified persisted operation key is invalid.","extensions":{"code":"HC0020","requestedKey":"00000000000000000000000000000000"}}]}

Bu tek ayar, yukarıdaki alias çoğaltma ve derinlik saldırılarının saldırı yüzeyini neredeyse sıfıra indiriyor: client yalnız senin yazdığın sorguları çağırabiliyor. Persisted operation'lar yine doğrulanıyor; derin bir sorguyu persisted olarak kaydettiğimde de derinlik kuralı onu reddetti. Yani limitleri kaldırma, ikisi birlikte çalışsın.

Dosyaları elle üretmek ölçeklenmez. Doğru yer client'ın build'i: GraphQL Code Generator'ın client preset'i bütün operation'ları bir manifest'e (hash → sorgu) çıkarabiliyor; deploy hattı bu manifest'i server'ın deposuna (dosya sistemi, Redis ya da Azure Blob Storage) yazıyor. Bunu bir sonraki Angular yazısında kuruyoruz. Hash algoritmasını client ile aynı yapmak gerekiyor; Hot Chocolate'ta AddSha256DocumentHashProvider() ile SHA-256'ya (Secure Hash Algorithm 256) geçebilirsin.

Introspection ve Semantic Introspection

Hot Chocolate 16'nın yeni özelliklerinden biri semantic introspection: __search ile schema'da doğal dilde arama. LLM (büyük dil modeli) agent'larının doğru field'ı bulması için tasarlanmış. Development'ta denedim:

{ __search(query: "order price") { coordinate score } }

{"data":{"__search":[{"coordinate":"Book.price","score":1},{"coordinate":"Book.costPrice","score":0.8798894882202148},{"coordinate":"OrderItem.unitPrice","score":0.8798894882202148},{"coordinate":"Query.order","score":0.6310880184173584}, ...]}}

İkinci sonuca bak: Book.costPrice. Bu field birazdan göreceğimiz gibi yalnız personele açık, ama varlığı arama sonucunda görünüyor. Authorization veriyi korur, schema'nın şeklini değil. Production ortamında aynı istek ve klasik __type sorgusu, persisted-only ayarı kapalıyken bile reddedildi:

{"errors":[{"message":"Introspection is not allowed for the current request.","locations":[{"line":1,"column":3}],"extensions":{"code":"HC0046","field":"__search"}}]}

İkisi de Production'da varsayılan olarak kapalı; __typename ise çalışmaya devam ediyor, client'ların union'ları ayırt etmesi için şart. Kararın kısası: public bir API'de introspection'ı kapalı tut, schema'yı geliştiricilere SDL dosyası ya da bir schema registry üzerinden ver. İç bir API'de belirli bir rolle açmak makul; Hot Chocolate bunun için request interceptor'da istek bazında AllowIntrospection() ile izin vermeye olanak tanıyor.

Authorization: Field, Policy ve Resource-Based

HotChocolate.AspNetCore.Authorization paketi ASP.NET Core'un authentication ve authorization altyapısını GraphQL'e bağlıyor. JWT doğrulaması ve Staff policy'si, ASP.NET Core Web API serisindeki kurulumun aynısı; GraphQL tarafında builder'a .AddAuthorization() ekliyorsun. Üç düzey var:

[QueryType]
public static partial class OrderQueries
{
    [Authorize]
    public static async Task<List<Order>> GetMyOrdersAsync(
        ClaimsPrincipal user,
        BookstoreContext db,
        CancellationToken cancellationToken)
    {
        string email = user.FindFirstValue("email")!;
        return await db.Orders
            .AsNoTracking()
            .Where(o => o.CustomerEmail == email)
            .OrderByDescending(o => o.Id)
            .ToListAsync(cancellationToken);
    }

    [Authorize]
    public static async Task<Order?> GetOrderAsync(
        int id,
        ClaimsPrincipal user,
        BookstoreContext db,
        CancellationToken cancellationToken)
    {
        Order? order = await db.Orders.AsNoTracking().FirstOrDefaultAsync(o => o.Id == id, cancellationToken);

        // kaynak bazlı kontrol: personel her siparişi, müşteri yalnız kendi siparişini görür
        bool allowed = order is not null
            && (user.IsInRole("staff") || order.CustomerEmail == user.FindFirstValue("email"));
        return allowed ? order : null;
    }
}

[ObjectType<Book>]
public static partial class BookNode
{
    static partial void Configure(IObjectTypeDescriptor<Book> descriptor)
    {
        descriptor.Field(b => b.CostPrice).Type<DecimalType>().Authorize("Staff");
        descriptor.Ignore(b => b.SupplierNote);
    }
}

[Authorize] burada HotChocolate.Authorization namespace'inden. Alışkanlıkla Microsoft.AspNetCore.Authorization'ı import ettiğimde Hot Chocolate 16'nın analyzer'ı derlemeyi durdurdu: error HC0106: Use HotChocolate.Authorization.AuthorizeAttribute instead. Analyzer'ı #pragma warning disable HC0106 ile susturup denediğimde token'sız istek AUTH_NOT_AUTHENTICATED almadan doğrudan resolver'a ulaştı: Hot Chocolate Microsoft'un attribute'unu uygulamıyor, field korumasız kalıyor. Deniz ve Ali'nin birer siparişi var (1 ve 2); aynı sorgu üç kimlikle:

{ order(id: 1) { id customerEmail total } myOrders { id total } }

# token yok
{"errors":[{"message":"The current user is not authorized to access this resource.","path":["myOrders"],"extensions":{"code":"AUTH_NOT_AUTHENTICATED"}},{"message":"The current user is not authorized to access this resource.","path":["order"],"extensions":{"code":"AUTH_NOT_AUTHENTICATED"}}],"data":null}

# ali@example.com
{"data":{"order":null,"myOrders":[{"id":2,"total":160.00}]}}

# mert@example.com, role=staff
{"data":{"order":{"id":1,"customerEmail":"deniz@example.com","total":160.00},"myOrders":[]}}

Ali, Deniz'in 1 numaralı siparişini [Authorize]'ı geçtiği hâlde göremedi; kontrolü resolver yaptı. Attribute "giriş yapmış mı" sorusunu yanıtlar, "bu kaynak onun mu" sorusunu yanıtlamaz. API Güvenliği serisinin API1 BOLA (kayıt bazında yetki kontrolünün eksik olması) yazısında REST için kurduğumuz kural GraphQL'de de aynı: sahiplik kontrolü veri erişiminin yanında durmalı. Kaynak yoksa ya da başkasınınsa aynı yanıtı (null) dönmek, id tahmin ederek var olan siparişleri keşfetmeyi de engelliyor.

Field düzeyindeki costPrice'ın ilk hâli Decimal! idi. Ali kitap listesinde costPrice istediğinde yanıt "data":{"books":{"nodes":null}} oldu: yetkisiz field null'a döndü, non-null olduğu için null bütün listeyi götürdü. Type<DecimalType>() ile nullable yapınca kitaplar geldi, yalnız costPrice null ve AUTH_NOT_AUTHORIZED hatasıyla. Yetki ile korunan her field nullable olmalı.

Hata Maskeleme ve İz Sürme

Kitaplara stok bilgisini Inventory service'inden HTTP ile getiren bir available field'ı ekledim ve Inventory'nin adresini erişilemez bıraktım. Development'ta IncludeExceptionDetails'ı açtığım için yanıt şuydu:

{"errors":[{"message":"Unexpected Execution Error","path":["books","nodes",0,"available"],"extensions":{"traceId":"52bdb5e35ade3f31661463f4bc3b2c33","exception":{"message":"... (inventory.bookstore.internal:5299)","stackTrace":"   at System.Net.Http.HttpConnectionPool.ConnectToTcpHostAsync(String host, Int32 port, ..."}}}], ...}

İç host adı, port ve stack trace client'ta. Production'da bu bilgi bir saldırgana iç ağın haritasını verir. Hot Chocolate'ta IncludeExceptionDetails'ın varsayılanı Debugger.IsAttached; yani debugger bağlı değilse kapalı. Ben bunu ortama bağladım ve ayrıntıyı loga, client'a yalnız bir iz kimliği gönderen bir error filter yazdım:

public sealed class LoggingErrorFilter(ILoggerFactory loggerFactory) : IErrorFilter
{
    private readonly ILogger _logger = loggerFactory.CreateLogger<LoggingErrorFilter>();

    public IError OnError(IError error)
    {
        if (error.Exception is null)
        {
            return error;
        }

        // ayrıntı loga, client'a yalnız iz sürmeye yarayan kimlik
        string traceId = Activity.Current?.TraceId.ToString() ?? Guid.NewGuid().ToString("N");
        _logger.LogError(error.Exception, "GraphQL error at {Path} (traceId {TraceId})", error.Path, traceId);
        return error.SetExtension("traceId", traceId);
    }
}
.AddApplicationService<ILoggerFactory>()
.AddErrorFilter<LoggingErrorFilter>()

İlk denemede filter'ın constructor'ı ILogger<LoggingErrorFilter> alıyordu ve uygulama açılmadı: Unable to resolve service for type 'Microsoft.Extensions.Logging.ILogger`1[...LoggingErrorFilter]'. Hot Chocolate 16 schema service'lerini uygulama service'lerinden ayırdı; error filter, interceptor ve diagnostic listener'ların kullandığı service'leri AddApplicationService<T>() ile schema tarafına taşımak gerekiyor. Production'daki yanıt ve log:

{"errors":[{"message":"Unexpected Execution Error","path":["books","nodes",0,"available"],"extensions":{"traceId":"fab675a6b54c80dcb3cce46f16b5be12"}}],"data":{"books":{"nodes":[{"title":"Distributed Systems Vol. 1","available":null}]}}}

fail: Bookstore.Catalog.Types.LoggingErrorFilter[0]
      GraphQL error at books.nodes[0].available (traceId fab675a6b54c80dcb3cce46f16b5be12)
      System.Net.Http.HttpRequestException: ... (inventory.bookstore.internal:5299)

Client destek ekibine traceId'yi verebiliyor, ayrıntı bizde kalıyor.

Schema Evolution: @deprecated, @requiresOptIn ve CI'da Diff

GraphQL'de versiyonlama gelenekseldir ama URL'de /v2 ile değil, schema'nın kendisinde yapılır: yeni field eklenir, eskisi @deprecated ile işaretlenir, kullanımı sıfıra inince kaldırılır. Bookstore'da stok adedini artık herkese göstermemeye karar verdik; yerine bir durum enum'u geliyor. Bir de henüz deneme aşamasındaki bir öneri field'ı var:

[ObjectType<Book>]
public static partial class BookEvolution
{
    static partial void Configure(IObjectTypeDescriptor<Book> descriptor)
    {
        descriptor.Field(b => b.Stock).Deprecated("Use stockStatus. Exact counts are no longer public.");
    }

    public static StockStatus GetStockStatus([Parent(requires: nameof(Book.Stock))] Book book) =>
        book.Stock switch
        {
            0 => StockStatus.OutOfStock,
            < 5 => StockStatus.Low,
            _ => StockStatus.InStock
        };

    [RequiresOptIn("recommendations")]
    public static async Task<List<Book>> GetRecommendationsAsync(
        [Parent(requires: nameof(Book.AuthorId))] Book book,
        IBooksByAuthorIdDataLoader booksByAuthorId,
        CancellationToken cancellationToken) =>
        [.. (await booksByAuthorId.LoadAsync(book.AuthorId, cancellationToken) ?? []).Where(b => b.Id != book.Id).Take(3)];
}
.ModifyOptions(o => o.EnableOptInFeatures = true)
.OptInFeatureStability("recommendations", "experimental")

[RequiresOptIn], Hot Chocolate 16'nın GraphQL çalışma grubundaki @requiresOptIn önerisine dayanan özelliği. Field normal introspection'da görünmüyor; __type(name: "Book") { fields { name } } listesinde recommendations yoktu, fields(includeOptIn: ["recommendations"]) ile geldi. __schema { optInFeatureStability } da {"feature":"recommendations","stability":"experimental"} döndü. Önemli bir ayrıntı: opt-in bir erişim kontrolü değil, bir görünürlük ve sözleşme işareti. Opt-in demeden recommendations'ı doğrudan sorgulayınca da üç kitap döndü. Client'lara "bu field değişebilir, kendi riskinle kullan" demenin standart yolu.

Schema'yı dosyaya dökmek için HotChocolate.AspNetCore.CommandLine paketini ekleyip app.Run()'ı await app.RunWithGraphQLCommandsAsync(args) ile değiştirdim. Artık dotnet run -- schema export --output schema.graphql çalışıyor. Değişiklikten önceki ve sonraki schema'yı GraphQL Inspector ile karşılaştırdım:

npx graphql-inspector diff v1.graphql v2.graphql

Detected the following changes (7) between schemas:

⚠  Directive optInFeatureStability was added to schema
✔  Directive optInFeatureStability was added
✔  Directive requiresOptIn was added
✔  Field recommendations was added to object type Book
✔  Field Book.stock is deprecated
✔  Field stockStatus was added to object type Book
✔  Type StockStatus was added
[success] No breaking changes detected

Sonra bilerek kırıcı bir değişiklik yaptım, Book.isbn'i kaldırdım:

npx graphql-inspector diff v2.graphql v3.graphql

Detected the following changes (1) between schemas:

✖  Field isbn was removed from object type Book
[error] Detected 1 breaking change

Komutun çıkış kodu 1; CI hattında PR'ı durdurmak için yeterli. Akış: main'deki schema dosyası repo'da durur, her PR schema'yı yeniden üretir, diff kırıcı değişiklik bulursa build kırılır. Kaldırma gerçekten gerekiyorsa önce @deprecated, sonra kullanım ölçümü (operation adları ve field kullanımı loglarından), en son kaldırma.

HTTP Cache'i Geri Kazanmak: GET ve Persisted Operation

Serinin GraphQL temelleri yazısında tek POST endpoint'i yüzünden HTTP cache'in kaybolduğunu söylemiştik. Persisted operation'lar bunu geri getiriyor: sorgu artık bir hash, URL kısa ve kararlı. HotChocolate.Caching paketiyle cache süresini field düzeyinde tanımlıyorsun:

[UseConnection(IncludeTotalCount = true)]
[CacheControl(60)]
public static async Task<PageConnection<Book>> GetBooksAsync(...)
.AddCacheControl()
.ModifyCacheControlOptions(o => { o.DefaultMaxAge = 30; o.ApplyDefaults = true; })
.UseQueryCache();

app.MapGraphQLPersistedOperations();
GET /graphql/persisted/b5923b0f7b178c314b18da2432c9799c?variables={"first":2}
HTTP/1.1 200 OK
Cache-Control: max-age=30
Vary: Accept

Yanıtın max-age'i sorgudaki bütün field'ların en küçüğü. books 60 saniye dese de Book.author gibi resolver'lı field'lar varsayılan süreyi alıyor. DefaultMaxAge ayarlanmadan ilk denemede header Cache-Control: max-age=0 idi, yani hiç cache'lenemez. Bu en güvenli varsayılan: kullanıcıya özel bir field'ı yanlışlıkla public cache'e koymaktansa hiç cache'lememek. costPrice gibi yetkiye bağlı field'lar içeren sorguları ise CDN'e (statik dosyaları kullanıcıya yakın sunuculardan dağıtan ağ) asla public olarak verme.

Bu bölümü yazarken bir de beklemediğim bir sorunla karşılaştım. AddCacheControl()'u ekleyip uygulamayı çalıştırdığımda açılmadı:

HotChocolate.SchemaException: For more details look at the `Errors` property.
1. `PUBLİC` is not a valid GraphQL name.
 (Parameter 'value') (HotChocolate.Caching.CacheControlScopeType)

Noktalı büyük İ. Hot Chocolate 16.7.5, C# enum değerlerinden GraphQL adı üretirken (Public → PUBLIC) kültüre duyarlı büyük harf dönüşümü yapıyor; işletim sisteminin bölgesi Türkçe olduğunda küçük i noktalı İ'ye dönüşüyor ve sonuç geçerli bir GraphQL adı olmuyor. Burada kurban HotChocolate.Caching'in cache scope enum'u oldu; bir sonraki yazıda kendi OrderStatus enum'umuzdaki Shipped değeri de aynı nedenle SHİPPED hatasını verdi. İçinde küçük i geçen her enum değeri risk altında. Aynı uygulama İngilizce bölge ayarıyla açıldı, Türkçe (tr-TR) ile açılmadı; .NET varsayılan kültürü işletim sisteminin bölge ayarından aldığı için Türkçe bölge ayarlı bir Windows makinesinde de aynı şey olur. Çözümüm uygulamanın kültürünü sabitlemek oldu:

// Hot Chocolate 16.7.5 enum adlarını kültüre duyarlı büyütüyor; tr-TR'de Public "PUBLİC" olur
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var builder = WebApplication.CreateBuilder(args);

Bir API için invariant kültür zaten makul bir varsayılan. Sunucunun bölge ayarına göre sayı ve tarih biçimi değiştiren bir JSON yanıtı istemezsin.

Üretimde Dikkat

Limitleri katman katman kur ve hepsini birlikte tut: persisted operation'lar bilinmeyen sorguyu, cost analysis pahalı sorguyu, derinlik kuralı patolojik iç içe yapıyı, execution timeout da her şeye rağmen uzayan isteği durdurur. Rate limiting gibi HTTP düzeyindeki önlemler API Güvenliği serisinde anlattığımız gibi bunların önünde durur.

Cost sınırlarını gerçek trafiğe göre ayarla. Bir önceki yazıdaki OpenTelemetry span'ındaki graphql.operation.fieldCost attribute'unu operation adına göre topla; en pahalı meşru sorgunun biraz üstüne sınır koy.

Operation adını zorunlu tut ve logla. Hem schema evolution kararları (bu deprecated field'ı hâlâ kim kullanıyor?) hem de güvenlik incelemeleri buna dayanıyor.

Uygulamayı Türkçe bölge ayarlı bir makinede de test et. Kültüre duyarlı büyük-küçük harf dönüşümü .NET ekosisteminde hâlâ hata kaynağı; yukarıdaki gibi açılışta patlayan bir hata en iyi ihtimal, sessizce yanlış veri üreten hâli daha kötü.

Sık Düşülen Tuzaklar

Yalnız sayfa boyutunu sınırlamak. Alias'lar ve variable batching sayfa sınırını çoğaltır; maliyeti istek bütünü üzerinden ölçen cost analysis şart.

Yetki korumalı field'ları non-null bırakmak. Tek bir yetkisiz field bütün listeyi null'a çevirir.

[Authorize]'ı sahiplik kontrolü sanmak. Giriş yapmış her kullanıcı başkasının siparişini id ile isteyebilir; kontrolü resolver ya da veri erişimi yapmalı.

Microsoft.AspNetCore.Authorization.AuthorizeAttribute kullanmak. Hot Chocolate onu uygulamaz; 16'nın analyzer'ı bunu derleme hatasıyla yakalıyor, ama hatayı susturmak field'ı korumasız bırakır.

Production'da IncludeExceptionDetails'ı açık bırakmak. Exception mesajları iç host adlarını, SQL'i ve dosya yollarını taşır.

@requiresOptIn'i yetki sanmak. Yalnız introspection görünürlüğünü değiştirir; field herkes tarafından çağrılabilir.

Küçük Bir Deneme

★☆☆ GraphQL-Cost: validate header'ıyla books(first: 20) ve books(first: 40) sorgularının maliyetini al ve 511 sayısının nereden geldiğini @cost ve @listSize direktiflerinden hesapla.

★★☆ Development'ta da persisted operation zorunlu olsun ama X-Developer yerine yalnız staff rolünü taşıyan token'larla ad-hoc sorguya izin ver. İpucu: DefaultHttpRequestInterceptor ve AllowNonPersistedOperation().

★★★ Book.stock'u kullanan operation'ları bul: Hot Chocolate'ın diagnostic event'lerinden ya da OpenTelemetry span'larından field kullanımını operation adına göre say ve deprecated field'ı ne zaman güvenle kaldırabileceğine bu veriyle karar ver.

Server tarafı artık güvenli ve gözlemlenebilir. Sıra client'ta: bir sonraki yazıda Angular'dan Apollo Angular ile bu schema'ya bağlanıyor, GraphQL Code Generator ile typed operation'lar ve persisted operation manifest'i üretiyor, sipariş durumunu subscription ile canlı izliyor ve yorumları @defer ile geç yüklüyoruz.