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

Hata Contract'ı: RFC 9457 Problem Details, Hata Kodu Kataloğu, Validation Hataları ve Yerelleştirme

Mert Özen 15 Kas 2026 12 dk 6 görüntülenme
Hata Contract'ı: RFC 9457 Problem Details, Hata Kodu Kataloğu, Validation Hataları ve Yerelleştirme

İyi bir hata contract'ı, RFC 9457 Problem Details üzerine kurulur ve makine için sabit alanları (type, extension'lar, errors anahtarları) insan için yerelleşen alanlardan (title, detail) ayırır. Bookstore'da hata türlerini bir kataloğa bağlıyor, validation anahtarlarını JSON yollarına ve kural kodlarına çeviriyor, Accept-Language ile Türkçe hata dönüyor, ortama göre değişen 500'ü ve stack trace sızıntısını düzeltiyor ve Angular'da tek bir interceptor yazıyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 (ASP.NET Core 10.0.15) ile, Angular örneği Angular 22.2 ve TypeScript 6.0 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çı) Angular istemcisinde Emre, bütün API hatalarını tek bir interceptor'da yakalayıp kullanıcıya göstermek istiyor. Bir önceki yazının sonundaki API'ye dört hatalı istek atıyor:

$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":"two"}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-939e1bf0741b726533adf1265ed0158a-540c94a249a03f08-00"}

$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Dune","price":{"amount":0,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780441172719
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Author":["The Author field is required."],"Category":["The Category field is required."],"Price.Amount":["The field Amount must be between 0,01 and 1000000."]},"traceId":"00-fa938b52fd95da7c78790044ea8a9e98-8eb9fed14da9a4d2-00"}

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"price":{"amount":0}}' http://127.0.0.1:52318/books/9780441172719
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"price.amount":["The field Amount must be between 0,01 and 1000000."]},"traceId":"00-4f8fdf5956b1a5f007904037d70aad67-3caa2ecf3d8249b8-00"}

$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Insufficient stock","status":422,"detail":"Only 14 copies of Dune are available.","availableQuantity":14,"traceId":"00-7c73036e4555f070d219cb761103d753-84dd91a7fb96e625-00"}

Hepsi application/problem+json, yani format standart. Yine de interceptor'ın işi zor. Aynı fiyat hatası PUT'ta Price.Amount, PATCH'te price.amount anahtarıyla geliyor; Angular formundaki kontrolün adı ise price.amount. Mesajdaki 0,01 istemcinin değil, sunucunun dil ayarından geliyor. Bozuk JSON Development ortamında 500 alıyor; aynı istek uygulama Production ortamında çalışırken 400 alıyordu. 422'nin type'ı WebDAV'ın 2007 tarihli eski standardına işaret ediyor. Tahmin et: Emre'nin interceptor'ı hangi alana bakarak "stok yetersiz" ile "form hatası"nı ayırabilir, ve kullanıcıya hangi yanıtın metnini gösterebilir?

RFC 9457'nin Beş Alanı

Standart bir format kullanmak, contract'ın yarısı. Problem Details formatını IETF'in (internet standartlarını geliştiren kuruluş) RFC'lerinden (Request for Comments; numaralı standart dokümanları) 9457 tanımlar; 2023'te eski RFC 7807'nin yerini aldı. Beş alanı var. type problemin türünü tanımlayan bir URI'dır (Uniform Resource Identifier; bir şeyi tekil olarak tanımlayan adres) ve istemci için birincil kimliktir; yoksa about:blank sayılır, yani "status code'dan fazlasını söylemiyorum". title o türün kısa, insan için yazılmış özetidir ve yerelleştirme dışında her seferinde aynı kalmalıdır. status HTTP status code'unun tekrarıdır. detail bu seferki olayı anlatır ve istemcinin sorunu düzeltmesine yardım etmelidir, hata ayıklama bilgisi vermemelidir. instance ise bu olayın kendisini gösteren bir URI'dır.

RFC iki konuda açık konuşur. Birincisi extension'lar: bir problem türü kendi alanlarını ekleyebilir ve RFC'ye göre istemciler tanımadıkları extension'ları yok saymak zorundadır; bu, türün zamanla yeni alan kazanabilmesini sağlar. İkincisi type'ın adresi: https ile başlayan bir type açıldığında insan için dokümantasyon göstermeli, ama RFC'ye göre istemci bu adresi kendiliğinden açmamalı; yalnız geliştiriciye bilgi gösteren araçlar (örneğin bir debugging aracı) açabilir. İstemci her hatada bu adrese istek atmamalı. RFC 9457'nin 7807'den farkı da buralarda: ortak problem türleri için bir IANA (internet'in numara ve isim kayıtlarını tutan kuruluş) kaydı açıldı, çoklu hatanın nasıl dönüleceği netleşti ve açılamayan type URI'ları için rehberlik eklendi.

Azure rehberi kendi error object'ini, Google'ın AIP'lerinden (API Improvement Proposals; numaralı API tasarım kuralları) 193 numaralı olan ise google.rpc.Status içinde bir ErrorInfo kullanır; ikisinde de makine için kararlı bir kod ve insan için bir mesaj ayrımı var. Bookstore bu ayrımı RFC 9457'nin alanlarıyla kuruyor: makine için type, insan için title ve detail.

Hata Kodu Kataloğu: type Bir Contract'tır

İlk karar type'ın ne olacağı. ASP.NET Core'un varsayılanı her status code için RFC'deki ilgili bölümün adresi; bu, about:blank'ten fazlasını söylemiyor. Aynı 409'u üç ayrı durumda dönüyoruz: eklenen kitabın ISBN'i (uluslararası standart kitap numarası) zaten kayıtlıysa, sipariş iptal edilemiyorsa ve sipariş iade edilemiyorsa. İstemci her birinde başka bir şey yapmalı. Bu yüzden her durumun kendi türü var. Tür adresleri https://api.example.com/problems/ altında, son segmenti de türün kodu. Katalog bir endpoint olarak da açık:

$ curl -s -H "Accept-Language: tr" http://127.0.0.1:52318/problems | jq -c '.[] | {code, status, title}'
{"code":"validation-failed","status":400,"title":"Bazı alanlar geçersiz"}
{"code":"malformed-request","status":400,"title":"İstek gövdesi okunamadı"}
{"code":"unauthenticated","status":401,"title":"Kimlik doğrulaması gerekiyor"}
{"code":"forbidden","status":403,"title":"İzin yok"}
{"code":"unit-not-allowed","status":403,"title":"Bu birime erişim yok"}
{"code":"not-found","status":404,"title":"Bulunamadı"}
{"code":"method-not-allowed","status":405,"title":"Method desteklenmiyor"}
{"code":"book-already-exists","status":409,"title":"Kitap zaten var"}
{"code":"order-not-cancellable","status":409,"title":"Sipariş iptal edilemez"}
{"code":"order-not-returnable","status":409,"title":"Sipariş iade edilemez"}
{"code":"precondition-failed","status":412,"title":"Ön koşul tutmadı"}
{"code":"unsupported-media-type","status":415,"title":"Desteklenmeyen içerik tipi"}
{"code":"insufficient-stock","status":422,"title":"Stok yetersiz"}
{"code":"unsupported-currency","status":422,"title":"Desteklenmeyen para birimi"}
{"code":"internal-error","status":500,"title":"Sunucu hatası"}

Ayrı bir code alanı eklemedik; type zaten kimlik ve ikinci bir kimlik zamanla ondan ayrışır. Katalogdaki her satır bir contract maddesi: kod hiçbir zaman anlam değiştirmez, kaldırılmaz, yeni türler eklenebilir. Bir 404 için tek tür (not-found) yeterli, çünkü istemci kitap da bulamasa sipariş de bulamasa aynı şeyi yapıyor. Kararın style guide'daki karşılığı:

## Errors
- BS-20 [lint] Every 4xx and 5xx response is application/problem+json (RFC 9457).
- BS-21 type is an absolute URI under https://api.example.com/problems/ and resolves to a documentation page; its last segment is the catalog code (GET /problems). A type never changes meaning; new types may be added.
- BS-22 title is the same for every occurrence of a type and detail describes this occurrence; both are for humans, localized by Accept-Language (en, tr), never parsed by clients.
- BS-23 Validation errors (validation-failed): errors map, keys are JSON paths of the request (price.amount), values are rule codes (required, out-of-range, invalid-length, invalid-format, read-only).
- BS-24 instance is the request path and traceId is always present; no exception messages, stack traces or internal names.
- BS-25 Localized problems carry Content-Language and Vary: Accept-Language.

BS-20 lint ile kontrol ediliyor. Spectral kuralı, HEAD dışındaki bütün operation'ların 4xx ve 5xx yanıtlarında application/problem+json arıyor. İptal endpoint'inin 409 yanıtını yalnız bir açıklamayla bırakan bir değişiklikte çıktı:

$ npx spectral lint contract/bookstore.openapi.yaml

/home/user/bookstore/contract/bookstore.openapi.yaml
 291:15  error  bookstore-errors-are-problem-details  #/paths/~1orders~1%7BorderId%7D%3Acancel/post/responses/409 must use application/problem+json  paths./orders/{orderId}:cancel.post.responses[409]

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

Extension'lar da Contract'ın Parçası

Stok hatası artık katalogdaki türüyle dönüyor ve iki extension taşıyor:

$ curl -i -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 22:47:33 GMT
Server: Kestrel
Content-Language: en
Transfer-Encoding: chunked
Vary: Accept-Language

{"type":"https://api.example.com/problems/insufficient-stock","title":"Insufficient stock","status":422,"detail":"Only 14 copies of Dune are available.","instance":"/customers/me/cart/items/9780441172719","isbn":"9780441172719","availableQuantity":14,"traceId":"00-baf1eea3e5d68af55c3632c25fea81d8-cba2b7f347a994e8-00"}

availableQuantity sayesinde Angular, detail'deki cümleyi parse etmeden adet kutusuna 14 yazabiliyor. Bu yüzden extension'lar detail'den daha güçlü bir contract: istemci onları okur ve onlara güvenir. Bir extension'ın adını değiştirmek de bir alanın adını değiştirmek kadar breaking change. Her türün hangi extension'ları taşıdığı contract'ta örnekle yazılıyor; instance isteğin path'i, traceId ise destek ekibinin log'da arayacağı kimlik.

Validation Hataları: Alan Anahtarları ve Kural Kodları

Validation hatasında tek bir problem değil, alan başına birden çok sorun var. RFC 9457 bunun için tek bir problem türü ve onun içinde bir liste önerir; RFC'deki örnek, her biri bir JSON Pointer (bir JSON dokümanında bir alanı gösteren /price/amount gibi yol) ve bir açıklama içeren bir errors array'i kullanır. ASP.NET Core ise errors'ı alan adından mesaj listesine giden bir map olarak üretir. Bookstore map'i seçti, çünkü Angular'daki reactive form'lar kontrolleri adla bulur ve ASP.NET Core'un ürettiği şekle yakın kalmak ek kod istemez. Ama iki şeyi değiştirdik:

$ curl -s -X PUT -H "Authorization: Bearer ayse" -H "Content-Type: application/json" -d '{"title":"Dune","price":{"amount":0,"currency":"TRY"}}' http://127.0.0.1:52318/books/9780441172719 | jq .
{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Some fields are invalid",
  "status": 400,
  "detail": "Fix the fields listed in errors.",
  "instance": "/books/9780441172719",
  "errors": {
    "author": [
      "required"
    ],
    "category": [
      "required"
    ],
    "price.amount": [
      "out-of-range"
    ]
  },
  "traceId": "00-806374e559e0dd0f409d704c520d4b26-2139c0e676216dd4-00"
}

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"price":{"amount":0}}' http://127.0.0.1:52318/books/9780441172719 | jq -c .errors
{"price.amount":["out-of-range"]}

$ curl -s -X PATCH -H "Authorization: Bearer ayse" -H "Content-Type: application/merge-patch+json" -d '{"inStock":true}' http://127.0.0.1:52318/books/9780441172719 | jq -c .errors
{"inStock":["read-only"]}

Birincisi anahtarlar. Anahtar, isteğin JSON'undaki yol: price.amount. PUT da PATCH da, ASP.NET Core'un validation'ı da elle yazdığımız kontrol de aynı anahtarı üretiyor. C# property adının (Price.Amount) contract'a sızması, iç modeli dışarı açmanın en sessiz biçimiydi. İkincisi değerler. İngilizce cümle yerine kural kodu dönüyoruz: required, out-of-range, invalid-length, invalid-format, read-only. Alan altındaki metni istemci kendi dilinde ve kendi tasarımıyla üretiyor; sınır değerleri zaten OpenAPI şemasında yazıyor. Sunucu dil ayarına bağlı 0,01 sorunu da böylece kendiliğinden kayboldu.

Kullanıcı Mesajı, Geliştirici Mesajı ve Yerelleştirme

Bir hata body'sinde iki okur var. Program type'a, extension'lara ve errors anahtarlarına bakar; bunlar hiçbir dilde değişmez. İnsan title ve detail'i okur; bunlar Accept-Language'a göre yerelleşir. Ada'nın tarayıcısı Türkçe istediğinde:

$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Accept-Language: tr-TR,tr;q=0.9" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719 | jq .
{
  "type": "https://api.example.com/problems/insufficient-stock",
  "title": "Stok yetersiz",
  "status": 422,
  "detail": "Dune kitabından yalnızca 14 adet var.",
  "instance": "/customers/me/cart/items/9780441172719",
  "isbn": "9780441172719",
  "availableQuantity": 14,
  "traceId": "00-fec2eda4387d5821349836124b2b3569-7d421858e02ab531-00"
}

$ curl -s -D - -o /dev/null -X PUT -H "Authorization: Bearer ada" -H "Accept-Language: tr-TR,tr;q=0.9" -H "Content-Type: application/json" -d '{"quantity":50}' http://127.0.0.1:52318/customers/me/cart/items/9780441172719
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 22:47:33 GMT
Server: Kestrel
Content-Language: tr
Transfer-Encoding: chunked
Vary: Accept-Language

Content-Language yanıtın dilini söylüyor, Vary: Accept-Language ise ara cache'lere bu yanıtın dile göre değiştiğini bildiriyor; o olmasa Türkçe hata İngilizce isteyen birine cache'ten dönebilir. Hangi dilin kullanıcıya gösterileceği de bir tasarım kararı: partner'ın entegrasyonu Accept-Language göndermezse İngilizce alır, istemcinin dili desteklenmiyorsa varsayılan İngilizce'ye düşülür. Tek bir dil yerine "makine alanları sabit, insan alanları yerel" ayrımını kurmak bu yüzden önemli; partner'ın kodu Türkçe bir title yüzünden kırılmamalı.

Ortama Göre Değişen Contract ve Stack Trace

Önce Tahmin Et'teki bozuk JSON'un Development'ta 500, Production'da 400 alması yanlış bir ayardan değil, ASP.NET Core'un varsayılanından geliyordu: Minimal API Development'ta okunamayan body için exception fırlatıyor ve exception handler onu 500'e çeviriyor. Contract ortama göre değişmemeli; düzeltmeden sonra iki ortamda da aynı tür dönüyor. Production'daki iki gerçek yanıt:

$ curl -s -X PUT -H "Authorization: Bearer ada" -H "Content-Type: application/json" -d '{"quantity":"two"}' http://127.0.0.1:52318/customers/me/cart/items/9780441569595 | jq -c '{type, status, detail}'
{"type":"https://api.example.com/problems/malformed-request","status":400,"detail":"The body is not valid JSON or a value has the wrong type."}

$ curl -s "http://127.0.0.1:52318/books?pageToken=abc" | jq .
{
  "type": "https://api.example.com/problems/internal-error",
  "title": "Internal error",
  "status": 500,
  "detail": "Something went wrong on our side. Quote traceId when you contact support.",
  "instance": "/books",
  "traceId": "00-b8401674190e14ca7752341ada7f19b2-ece039a016e0c21f-00"
}

İkinci yanıt gerçek bir hata: ilk yazıdan beri duran sayfa token'ı kodu bozuk bir token'da patlıyor. Bunu bir sonraki yazıda düzelteceğiz. Burada önemli olan, 500'ün nasıl göründüğü: tür, genel bir cümle ve traceId. Exception mesajı, class adı, dosya yolu yok. Eski /legacy/rpc ise aynı tür bir hatada bambaşka davranıyor:

$ curl -s -X POST -H "Content-Type: application/json" -d '{"method":"GetBooks","params":{"page":"two"}}' http://127.0.0.1:52318/legacy/rpc
{"success":false,"error":"System.FormatException: The input string 'two' was not in a correct format.\n   at System.Number.ThrowFormatException[TChar](ReadOnlySpan`1 value)\n   at System.Int32.Parse(String s)\n   at Bookstore.Api.Legacy.LegacyEndpoints.<>c.<MapLegacyEndpoints>b__1_0(RpcRequest request, CatalogData catalog) in /home/user/bookstore/src/Bookstore.Api/Legacy/LegacyEndpoints.cs:line 20"}

Stack trace saldırgana framework'ü, namespace'leri, sunucudaki dosya yolunu ve hatanın tam satırını veriyor. RFC 9457'nin güvenlik bölümü de stack dump gibi iç ayrıntıların HTTP arayüzünden dışarı verilmemesini ister. Ayrıntı log'da kalır; istemciye log'u bulmasını sağlayan traceId gider.

Angular'da Tek Interceptor

Contract tutarlı olunca Emre'nin interceptor'ı kısa kalıyor. bookstore-web'deki functional interceptor:

import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { catchError, throwError } from 'rxjs';
import { NotificationService } from './notification.service';
import { isProblem, problemCode } from './problem-details';

// validation-failed'i formlar kendisi işler (errors anahtarları = form kontrol adları);
// diğer her problem, yerelleştirilmiş title ve detail ile tek bir bildirime dönüşür.
export const problemInterceptor: HttpInterceptorFn = (req, next) => {
  const notifications = inject(NotificationService);
  return next(req.clone({ setHeaders: { 'Accept-Language': navigator.language } })).pipe(
    catchError((error: HttpErrorResponse) => {
      const problem = error.error;
      if (isProblem(problem) && problemCode(problem) !== 'validation-failed') {
        notifications.show({ title: problem.title, detail: problem.detail, traceId: problem.traceId });
      }
      return throwError(() => error);
    }),
  );
};

isProblem ve problemCode yanındaki küçük dosyada: biri body'nin type ve status taşıyıp taşımadığına bakıyor, diğeri type'ın son segmentini alıyor. Interceptor hiçbir metni parse etmiyor, hiçbir status code için özel dal açmıyor; insufficient-stock gibi özel davranış isteyen bir tür varsa onu ilgili component, extension'larıyla birlikte ele alıyor. Kod, snapshot'taki clients/angular-snippets klasöründe Angular 22.2 ve TypeScript 6.0 ile npm run typecheck'ten hatasız geçiyor.

ASP.NET Core'da

Bütün dönüşüm tek bir yerde: AddProblemDetails(options => options.CustomizeProblemDetails = ProblemCatalog.Normalize). Framework'ün ürettiği her problem (routing'in 404 ve 405'i, authentication'ın 401'i, validation'ın 400'ü, exception handler'ın 500'ü) bu callback'ten geçiyor; callback status'a göre katalogdaki türü, yerelleştirilmiş başlığı, instance'ı ve camelCase anahtarları yazıyor. Handler'lar ise ProblemCatalog.Create(ProblemCatalog.InsufficientStock, [...]) ile doğrudan katalogdan üretiyor. .NET 9 ile gelen ExceptionHandlerOptions.StatusCodeSelector, BadHttpRequestException'ı 400'e çeviriyor; AddRequestLocalization yalnız Accept-Language'a bakıyor ve ApplyCurrentCultureToResponseHeaders ile Content-Language'ı yazıyor. Data Annotations'taki ErrorMessage'lar kural kodu; RangeAttribute'ta da ParseLimitsInInvariantCulture açık, yoksa Türkçe locale'de çalışan bir sunucu "0.01" sınırını okuyamıyor. Ayrıntılar ASP.NET Core Web API serisinin "ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails" ve "Web API'de Localization" yazılarında.

Sık Düşülen Tuzaklar

Stack trace ya da exception mesajı sızdırmak. Development ayarıyla deploy edilen bir uygulama, ya da catch (Exception ex) içinde ex.ToString() dönen bir endpoint yeter. Hata body'sine yalnız katalogdan gelen metin girsin.

Hata kodunu değiştirmek. out-of-stock'u insufficient-stock yapmak, istemcide o koda bakan her if'i sessizce kırar. Kodlar da contract'tır; yeni kod ekle, eskisini emekliye ayırırken versioning kurallarını uygula (06. yazı).

detail'i parse ettirmek. İstemcinin "Only 14 copies" cümlesinden sayıyı regex'le çıkarması, cümleyi düzelttiğin gün kırılır. Programın ihtiyaç duyduğu her değer bir extension olmalı.

Her status için tek tür. 409'un üç anlamı varsa üç türü olmalı. Tersine, istemcinin aynı şekilde ele aldığı durumlar için ayrı türler üretmek de kataloğu şişirir.

Çevrilmiş metne bağımlılık. Türkçe title'ı karşılaştırma için kullanan bir partner, metin değiştiğinde kırılır. Makine alanları dil bağımsızdır.

Küçük Bir Deneme

★☆☆ /problems/insufficient-stock'u tarayıcıda aç, sonra aynı isteği Accept-Language: tr ile curl ile tekrarla. Hangi alanlar değişti, hangileri aynı kaldı?

★★☆ Kurumsal siparişlerdeki en az 20 adet kuralı için kataloğa yeni bir tür ekle. Adını, status code'unu, Türkçe ve İngilizce başlığını, detail şablonunu ve istemcinin ihtiyaç duyacağı extension'ları (en az adet, gönderilen adet) belirle; contract'a örneğiyle yaz ve lint'ten geçir.

★★★ errors map'ini RFC 9457'deki örnek gibi, her biri JSON Pointer ve kural kodu taşıyan bir array'e çevirmek istediğini düşün. Bunu mevcut istemcileri kırmadan nasıl yaparsın? İpucu: yeni bir tür mü, yeni bir extension mı, yoksa bir media type parametresi mi?

Hata contract'ı hazır. Bir sonraki yazıda bu yazıda patlayan sayfa token'ından başlayıp koleksiyon endpoint'lerinin sözleşmesini tasarlıyoruz: pagination, filtreleme, sıralama, alan seçimi ve arama.