Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi REST API Tasarımı — #05

Koleksiyon Endpoint'leri: Pagination, Filtering, Sorting, Field Selection ve Arama Sözleşmesi

Mert Özen 15 Kas 2026 11 dk 6 görüntülenme
Koleksiyon Endpoint'leri: Pagination, Filtering, Sorting, Field Selection ve Arama Sözleşmesi

Bir liste endpoint'inin contract'ı item şemasından ibaret değildir; sayfa boyutu kuralları, opaque ve sorguya bağlı bir sayfa token'ı, yazılı bir sıralama ve tie-breaker, bilinmeyen parametreye verilen hata da contract'tır. Bookstore'da çözülebilen Base64 token'ı şifreli keyset token'ıyla, sessizce yok sayılan filtreyi 400 ile değiştiriyor, Link header'ını, sparse fieldset'i, AIP-160 filtre dilini ve RFC 10008 QUERY method'unu gerçek yanıtlarla tartışıyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (ASP.NET Core 10.0.15) ile derlenip denenmiştir; HTTP örnekleri bu uygulamanın gerçek yanıtlarıdır, OpenAPI 3.2 dokümanı Redocly CLI 2.78.0 ve Spectral 6.17.1 ile lint edilmiştir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) kitap listesi ilk yazıdan beri sayfalı ve nextPageToken dönüyor. Nevin Hanım'ın okulunun entegrasyonunu yazan firma bu token'a merakla bakmış. Bir önceki yazının sonundaki API'de gördüğü şey:

$ curl -s "http://127.0.0.1:52318/books?pageSize=2" | jq -r .nextPageToken
b2Zmc2V0PTI

$ echo 'b2Zmc2V0PTI=' | base64 -d
offset=2

$ echo -n 'offset=20' | base64
b2Zmc2V0PTIw

$ curl -s "http://127.0.0.1:52318/books?pageSize=2&pageToken=b2Zmc2V0PTIw" | jq -c '.items[] | {isbn, title}'
{"isbn":"9781451673319","title":"Fahrenheit 451"}
{"isbn":"9780441172719","title":"Dune"}

$ curl -s "http://127.0.0.1:52318/books?catgory=dystopia&pageSize=2" | jq -c '.items[] | {title, category}'
{"title":"Emma","category":"classics"}
{"title":"Pride and Prejudice","category":"classics"}

Firma artık token'ı kendisi üretiyor ve istediği sayfaya atlıyor. Son istekte ise category yanlış yazılmış; API hata vermeden bütün kataloğu dönmüş ve entegrasyon bir hafta boyunca okula distopya diye klasik roman listelemiş. İki soru: Token'ın formatını değiştirdiğimiz gün bu entegrasyona ne olur, ve bu kimin hatası? Yanlış yazılmış parametreyi sessizce yok saymanın bedelini kim öder?

Koleksiyon Sözleşmesinde Neler Var?

Bir liste endpoint'inin contract'ı yalnız dönen item'ların şeması değil. Sayfanın kaç item olacağı, sayfa sonunun nasıl anlaşılacağı, sıralamanın ne olduğu, eşit değerlerde sıranın nasıl bozulacağı, filtrelerin adları ve tipleri, bilinmeyen parametreye ne olacağı, toplam sayının dönüp dönmeyeceği; bunların hepsi istemcinin gözlemleyip ona göre kod yazdığı davranışlar. Birinci yazıdaki Hyrum's Law burada en çok ısıran hâliyle karşımızda: belgelemediğin her davranış da birinin bağımlı olduğu bir davranış.

Rehberler bu konuda büyük ölçüde anlaşıyor. Google'ın AIP'lerinden (API Improvement Proposals; Google'ın numaralı API tasarım kuralları) 158 numaralı olan page_size, page_token ve next_page_token alanlarını tanımlar: sayfa boyutu verilmezse ya da 0 ise varsayılan kullanılır, üst sınırın üstündeki değer sınıra indirilir, negatif değer hatadır; sunucu istenenden az item dönebilir ve koleksiyonun bittiğini tek söyleyen şey boş next_page_token'dır. Token'lar opaque olmalı, istemcinin çözebileceği bir yapıda olmamalı; AIP, düz Base64'ün bunun için yeterli olmadığını açıkça yazar. Diğer parametreler sayfalar arasında değişirse hata dönülmelidir. Azure rehberi aynı fikri nextLink, maxpagesize ve son sayfada nextLink'in hiç gönderilmemesi kuralıyla anlatır ve toplam sayı dönmemeyi önerir. Zalando cursor tabanlı pagination'ı offset'e tercih eder ve toplam sayıdan kaçınmayı söyler.

Opaque Token: İçini Görmesin, Değiştiremesin

Bookstore'un ilk token'ı tam AIP'nin uyardığı şeydi: offset=2'nin Base64'ü. İstemci onu çözdü ve contract'ın parçası yaptı. Yeni token üç şey taşıyor: hangi sorguya ait olduğu (filtrelerin ve sıralamanın özeti), son dönen item'ın sıralama anahtarı ve bir son kullanma zamanı. Hepsi şifrelenip imzalanıyor; istemci için anlamsız bir string:

$ curl -i "http://127.0.0.1:52318/books?category=classics&inStock=true&sort=-price&pageSize=3&fields=isbn,title,price"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:00:58 GMT
Server: Kestrel
Content-Language: en
Transfer-Encoding: chunked
Link: </books?category=classics&inStock=true&sort=-price&pageSize=3&fields=isbn,title,price&pageToken=CfDJ8AAAAAAAAAAAAAAAAAAAAAA2vE5Z5s_kODkG5xK34K8XilhiSsq5suBMdqTVH0uVoJIPj-VFfU5Sb-YPH6cdkwFu-Kge2fUOax1FgLMINmMUZYpv262wHawQ_HYm4z9rYRk_im_ldgX1rPr_I6lq8V2DKZEYBtERZVmyGPbsuUfVbuWrU163v-9VdJRU8UpgLmHfMtNOPiCn99i-D-7lu8s>; rel="next"

{"items":[{"isbn":"9781400079988","title":"War and Peace","price":{"amount":549.00,"currency":"TRY"}},{"isbn":"9780060934347","title":"Don Quixote","price":{"amount":459.00,"currency":"TRY"}},{"isbn":"9780140449136","title":"The Brothers Karamazov","price":{"amount":419.00,"currency":"TRY"}}],"nextPageToken":"CfDJ8AAAAAAAAAAAAAAAAAAAAAA2vE5Z5s_kODkG5xK34K8XilhiSsq5suBMdqTVH0uVoJIPj-VFfU5Sb-YPH6cdkwFu-Kge2fUOax1FgLMINmMUZYpv262wHawQ_HYm4z9rYRk_im_ldgX1rPr_I6lq8V2DKZEYBtERZVmyGPbsuUfVbuWrU163v-9VdJRU8UpgLmHfMtNOPiCn99i-D-7lu8s"}

$ TOKEN=$(curl -s "http://127.0.0.1:52318/books?category=classics&inStock=true&sort=-price&pageSize=3" | jq -r .nextPageToken)

$ curl -s "http://127.0.0.1:52318/books?category=classics&inStock=true&sort=-price&pageSize=3&fields=isbn,title,price&pageToken=$TOKEN" | jq -c '.items[]'
{"isbn":"9780143058144","title":"Crime and Punishment","price":{"amount":359.00,"currency":"TRY"}}
{"isbn":"9780142437247","title":"Moby-Dick","price":{"amount":339.00,"currency":"TRY"}}
{"isbn":"9780141439549","title":"Middlemarch","price":{"amount":319.00,"currency":"TRY"}}

$ curl -s "http://127.0.0.1:52318/books?category=dystopia&inStock=true&sort=-price&pageSize=3&pageToken=$TOKEN" | jq .
{
  "type": "https://api.example.com/problems/invalid-page-token",
  "title": "Invalid page token",
  "status": 400,
  "detail": "The page token is invalid, has expired or belongs to a different query; start again without pageToken.",
  "instance": "/books",
  "traceId": "00-1f0cde1007f77ad7d89ec532c5088fb3-c27416d52d8d5d3b-00"
}

$ curl -s "http://127.0.0.1:52318/books?pageToken=b2Zmc2V0PTIw" | jq -c '{type, status}'
{"type":"https://api.example.com/problems/invalid-page-token","status":400}

Bir sonraki sayfanın adresi iki yerde: body'deki nextPageToken ve IETF'in (internet standartlarını geliştiren kuruluş) RFC'lerinden (Request for Comments; numaralı standart dokümanları) 8288 numaralı Web Linking'in tanımladığı Link header'ı. Angular istemcisi body'yi kullanıyor; header ise body'yi okumadan sayfa sayfa ilerleyen genel araçlar ve script'ler için. İkinci sayfayı alırken fields parametresi değişmedi ama değişebilirdi; sayfanın içeriğini değil yalnız görünümünü etkiliyor. category değişince token reddediliyor, çünkü başka bir sorgunun ortasından devam etmenin anlamı yok. Firmanın elle ürettiği eski token da artık 400 alıyor.

Token'ın içinde offset değil, son item'ın sıralama anahtarı var. Bu keyset pagination (bir sonraki sayfayı "son görülen anahtardan sonrası" olarak tanımlamak) demek: kullanıcı sayfaları gezerken kataloğa kitap eklenir ya da çıkarılırsa offset'li bir listede item'lar tekrar eder ya da atlanır; keyset'te sıra son görülen anahtardan devam eder. Veritabanı tarafında bunun nasıl uygulandığı, hangi index'in gerektiği ve ölçümleri Çözüm Desenleri serisinin "Pagination Stratejileri Derinlemesine: Offset, Keyset ve Opaque Cursor Token" yazısında; burada yalnız contract'ı tasarlıyoruz. Contract'ın söylediği: token'ın içi istemciyi ilgilendirmez, üç gün geçerlidir ve yalnız aynı filtre ve sıralamayla kullanılabilir.

Önce Tahmin Et'teki ilk sorunun cevabı rahatsız edici: format değişince firmanın entegrasyonu kırıldı ve teknik olarak bu onların hatası, çünkü dokümanda token'ın opaque olduğu yazıyordu. Ama tüketiciyi kırmak her durumda bizim sorunumuz. Asıl hata ilk günden çözülebilir bir token vermekti. Böyle bir değişikliği duyurmanın ve geçiş süresi tanımanın yolu, Deprecation ve Sunset header'larıyla birlikte 06. yazının konusu.

Sayfa Boyutu ve Toplam Sayı

Sayfa boyutunun kuralları AIP-158'deki gibi: verilmezse ya da 0 ise 10, 50'nin üstü 50'ye indirilir, negatif değer hata. Üst sınırın üstünü hata yerine sınıra indirmenin nedeni, sınırı ileride değiştirdiğimizde istemcilerin kırılmaması. Katalogda 26 kitap olduğu için 500 istemek bütün kataloğu tek sayfada veriyor ve token dönmüyor:

$ curl -s "http://127.0.0.1:52318/books?pageSize=-1" | jq -c '{status, errors}'
{"status":400,"errors":{"pageSize":["out-of-range"]}}

$ curl -s "http://127.0.0.1:52318/books?pageSize=0" | jq '.items | length'
10

$ curl -s "http://127.0.0.1:52318/books?pageSize=500" | jq '{count: (.items | length), nextPageToken}'
{
  "count": 26,
  "nextPageToken": null
}

Son çıktıdaki null, jq'nun olmayan alanı gösterme biçimi; JSON'da nextPageToken hiç yok. Toplam sayıyı ise dönmüyoruz. Toplam, her sayfada ayrı bir COUNT sorgusu demek ve büyük tablolarda bu sorgu sayfanın kendisinden pahalı olabilir. Üstelik sayfalar gezilirken değişen bir sayı. Angular ekranı "Daha fazla göster" düğmesiyle çalışıyor; "toplam 1.284 kitap" yazısı gerçekten gerekirse ayrı bir endpoint ya da AIP-158'in izin verdiği gibi tahmini olduğu belgelenmiş bir değer düşünülür.

Filtreleme: Basit Parametreler Nerede Durur?

Bookstore'un filtreleri representation'daki alanların adlarını taşıyan düz query parametreleri: category, author, inStock, aralıklar için minPrice/maxPrice ve tarihlerde createdAfter/createdBefore. Bilinmeyen bir parametre artık sessizce yok sayılmıyor; Azure rehberi de desteklenmeyen parametre için hata dönülmesini ister. Önce Tahmin Et'teki ikinci sorunun cevabı da bu: yanlış yazımın bedelini, hatayı hiç görmeyen okul ödüyordu.

$ curl -s "http://127.0.0.1:52318/books?catgory=dystopia" | jq -c '{type, status, errors}'
{"type":"https://api.example.com/problems/validation-failed","status":400,"errors":{"catgory":["unknown-parameter"]}}

$ curl -s -H "Authorization: Bearer linus" "http://127.0.0.1:52318/institutions/lakeside-school/orders?status=Confirmed,Shipped&createdAfter=2026-10-01T00:00:00Z&pageSize=1" | jq -c '{ids: [.items[] | {id, unitId, status, createdAt}], more: (.nextPageToken != null)}'
{"ids":[{"id":"01a1a612-9c80-7e46-8590-67e08b9d2434","unitId":"east-campus","status":"Confirmed","createdAt":"2026-11-04T10:20:00Z"}],"more":true}

$ curl -s -H "Authorization: Bearer linus" "http://127.0.0.1:52318/institutions/lakeside-school/orders?status=shipped" | jq -c '{status, errors}'
{"status":400,"errors":{"status":["invalid-value"]}}

Kurumsal sipariş geçmişinde status virgülle birden çok değer alıyor ve değerler representation'daki gibi büyük-küçük harfe duyarlı; shipped bir değer değil. Basit parametrelerin sınırı, "A ya da B" ve iç içe koşullar geldiğinde ortaya çıkıyor. Bu noktada üç yol var:

YaklaşımÖrnekNe zamanBedeli
Düz query parametreleri?category=classics&inStock=trueFiltreler az ve hep AND ile birleşiyorsaOR ve gruplama yok; her yeni filtre yeni bir parametre
AIP-160 tarzı filtre ifadesi?filter=category = "classics" AND price < 300Kullanıcı tanımlı, birleşik sorgular gerekiyorsaParser, desteklenen alan ve operatörlerin dokümantasyonu, index'lenmemiş alanlara karşı koruma
OData ya da GraphQL?$filter=...&$expand=...Veri erişimi ürünün kendisiyseSorgu gücü istemciye geçer; performans ve yetki kontrolü zorlaşır

AIP-160 kendi grameri olan bir filter alanı tanımlar: AND, OR, NOT, karşılaştırma operatörleri ve varlık kontrolü için :. Bilinmeyen bir alana ya da hatalı ifadeye hata dönülmesini ister. Bookstore şimdilik düz parametrelerde kalıyor; okulların ihtiyacı birkaç sabit filtre. Bir gün ifade dili gerekirse filter parametresi mevcut parametrelerin yanına eklenir; bu additive bir değişiklik. Sorgunun tamamen istemciye açılması ise GraphQL'in alanı ve gRPC ve GraphQL serisinde anlatılıyor.

Sıralama, Alan Seçimi ve Arama

sort, virgülle ayrılmış alan listesi; azalan sıra için başına - konuyor (-price,title). Yalnız dokümante edilmiş alanlara izin var ve her sıralamanın sonuna eşsiz bir alan ekleniyor: kitapta ISBN (uluslararası standart kitap numarası), siparişte kimlik. Bu tie-breaker olmazsa aynı fiyattaki iki kitabın sırası her sorguda değişebilir ve keyset sayfalar arasında item kaybeder. Varsayılan sıralama da artık yazılı: başlığa göre. İlk yazılardaki liste, belgelenmemiş bir şekilde ekleme sırasıyla geliyordu; buna dayanan bir istemci olsaydı bu değişiklik onu kırardı. Belgelenmemiş sıra da bir sıradır.

fields (sparse fieldset), her item'ın yalnız istenen alanlarını döndürüyor; yukarıdaki ilk istekte item'larda yalnız isbn, title ve price vardı. Mobil uygulamanın liste ekranı için payload'ı küçültür, ama bedeli var: aynı URL'in farklı biçimleri cache'te ayrı yer tutar, contract'tan üretilen client'ta item tipi "her alanı opsiyonel" bir tipe dönüşür ve her yeni alan seçilebilir alan listesine bilinçli olarak eklenmelidir. İç alanlar seçilemez:

$ curl -s "http://127.0.0.1:52318/books?sort=stock" | jq -c '{status, errors}'
{"status":400,"errors":{"sort":["unknown-field"]}}

$ curl -s "http://127.0.0.1:52318/books?fields=isbn,supplier" | jq -c '{status, errors}'
{"status":400,"errors":{"fields":["unknown-field"]}}

$ curl -s "http://127.0.0.1:52318/books?q=tolkien&fields=title,price" | jq -c '.items[]'
{"title":"The Hobbit","price":{"amount":349.00,"currency":"TRY"}}
{"title":"The Lord of the Rings","price":{"amount":899.00,"currency":"TRY"}}

$ curl -i -X QUERY -H "Content-Type: application/json" -d '{"isbns":["9780441172719","9780261102217","9780000000002"]}' http://127.0.0.1:52318/books
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 22:56:29 GMT
Server: Kestrel
Content-Language: en
Transfer-Encoding: chunked

{"items":[{"isbn":"9780441172719","title":"Dune","author":"Frank Herbert","category":"science-fiction","price":{"amount":489.00,"currency":"TRY"},"inStock":true},{"isbn":"9780261102217","title":"The Hobbit","author":"J. R. R. Tolkien","category":"fantasy","price":{"amount":349.00,"currency":"TRY"},"inStock":true}]}

q, başlık ve yazarda serbest metin araması yapan tek bir parametre. Gerçek bir arama motoru (relevance, yazım hatası toleransı) arkasında Elasticsearch gibi bir sistem ister; contract tarafında önemli olan, q'nun neyi aradığının ve sonuçların hangi sırayla geldiğinin yazılı olması. Son istek ise başka bir ihtiyaç: okulun elindeki 500 ISBN'lik listenin hangilerinin katalogda olduğunu sormak. Bu liste URL'e sığmaz; POST ise safe olmadığı için cache'lenmez ve tekrar gönderilmesi güvenli sayılmaz. Haziran 2026'da yayımlanan RFC 10008, tam bu boşluk için QUERY'yi (kriterleri body'de taşıyan ama GET gibi safe ve idempotent olan HTTP method'u) tanımladı. OpenAPI 3.2 de query operation'ını tanıyor. Denediğimiz araçlardan Redocly CLI 3.2'deki query operation'ını doğruluyor; Spectral 6.17.1 ise bu operation'ı kurallarına dahil etmiyor.

Contract'ta ve Lint'te

Pagination kuralları ortak parametre tanımlarında yazılı ve her liste endpoint'i bunlara referans veriyor:

    PageSize:
      name: pageSize
      in: query
      description: >-
        Maximum number of items in the page. Absent or 0 means 10; values above 50
        are reduced to 50; negative values return 400. A page may hold fewer items.
      schema:
        type: integer
        minimum: 0
        default: 10
    PageToken:
      name: pageToken
      in: query
      description: >-
        Opaque nextPageToken from the previous page. Valid for 3 days and only with the
        same filters and sort; pageSize and fields may change. Otherwise 400 invalid-page-token.
      schema:
        type: string

"Her liste ilk sürümünden sayfalı" kuralı (BS-30) Spectral'de, operationId'si list ile başlayan her GET'te pageSize ve pageToken arıyor. Kural ilk çalıştığında kendi kataloğumuzu yakaladı: GET /problems 16 satırlık, sınırlı bir liste. Bunu sayfalamak yerine istisnayı bir ADR (Architecture Decision Record; mimari karar kaydı) olarak yazdık (ADR-0003) ve .spectral.yaml'da yalnız o operation için kuralı kapattık. Siparişlerin pageToken parametresini bilerek silen bir değişiklikte kuralın çıktısı:

$ npx spectral lint contract/bookstore.openapi.yaml

/home/user/bookstore/contract/bookstore.openapi.yaml
 316:18  error  bookstore-paginated-lists  #/paths/~1orders/get/parameters is a list without pageSize and pageToken  paths./orders.get.parameters

✖ 1 problem (1 error, 0 warnings, 0 infos, 0 hints)

ASP.NET Core'da

Token, ASP.NET Core'un Data Protection API'siyle üretiliyor: CreateProtector("Bookstore.PageToken.v1").ToTimeLimitedDataProtector() payload'ı şifreleyip imzalıyor, Protect(payload, TimeSpan.FromDays(3)) son kullanma zamanını ekliyor ve Unprotect değiştirilmiş ya da süresi dolmuş token'da exception fırlatıyor; biz onu invalid-page-token'a çeviriyoruz. Örnek uygulama UseEphemeralDataProtectionProvider() kullanıyor, yani anahtarlar bellekte ve uygulama yeniden başlayınca token'lar geçersiz oluyor; birden çok instance'lı bir deployment'ta bütün instance'ların paylaştığı, kalıcı bir key ring gerekir. Link header'ı QueryHelpers.AddQueryString ile mevcut query'den kuruluyor, QUERY ise MapMethods("", ["QUERY"], LookupBooks) ile tanımlı; Kestrel bu method'u ek ayar olmadan kabul ediyor. Filtre, sıralama ve sayfalamanın EF (Entity Framework; Microsoft'un .NET için veritabanı erişim library'si) Core ile veritabanında uygulanması ASP.NET Core Web API serisinin "Pagination, Filtering ve Sorting" yazısında.

Sık Düşülen Tuzaklar

Limitsiz sayfa boyutu. pageSize=100000'i kabul eden bir endpoint, tek istekle veritabanını ve belleği zorlayabilir. Üst sınır contract'ta yazılı olmalı.

İstemcinin parse ettiği cursor. Base64 bir offset, bir JSON ya da bir tarih, ilk meraklı tüketicinin contract'ı olur. Token'ı şifrele ya da en azından imzala, ve dokümana opaque olduğunu yaz.

Değişen veride offset. Kullanıcı ikinci sayfaya geçerken birinci sayfaya bir kitap eklenirse, birinci sayfanın son kitabı ikinci sayfanın başında tekrar görünür. Keyset ve eşsiz bir tie-breaker bunu önler.

Her sayfada toplam sayı. Ekranın tek bir yerinde gösterilen bir sayı için her sayfa isteğine ikinci bir sorgu eklemek, listenin en pahalı kısmı olabilir.

Bilinmeyen parametreyi yok saymak ve sınırsız filtre dili. İlki yanlış yazılmış filtreyi "her şey" sonucuna çevirir; ikincisi index'lenmemiş her alanda sorgu çalıştırma yetkisini istemciye verir.

Küçük Bir Deneme

★☆☆ Link header'ındaki adresi takip ederek kitap listesinin bütün sayfalarını gezen küçük bir shell döngüsü yaz. Döngü hangi koşulda duruyor?

★★☆ Bir sayfa aldıktan sonra Ayşe olarak o sayfadaki bir kitabı sil, sonra nextPageToken ile devam et. Sonraki sayfada bir item kayboldu mu ya da tekrar etti mi? Aynı deneyi ilk yazının offset token'ıyla yapsaydın ne olurdu?

★★★ Kurumsal sipariş geçmişine filter parametresiyle AIP-160 tarzı bir ifade desteği eklemeyi tasarla: hangi alanları ve operatörleri destekleyeceğini, desteklenmeyen bir alana hangi problem türünü döneceğini ve mevcut status, createdAfter parametreleriyle birlikte kullanıldığında ne olacağını contract'a yaz.

Bu yazıyla serinin ilk bloğu bitti: contract'ın neden bir mimari karar olduğu, resource'lar ve URL'ler, method ve status code'lar, hata contract'ı ve koleksiyonlar. İkinci blok, contract'ın zamanla nasıl değiştiğiyle başlıyor: bir sonraki yazıda breaking change kurallarına, versioning stratejilerine ve Deprecation ile Sunset header'larına bakıyoruz.