REST API yanıtlarında yaygın ve tutarlı bir düzen şudur: tek bir kaynak istendiğinde (örneğin GET /kullanicilar/42) gövde doğrudan o kaynağın JSON nesnesidir; bir koleksiyon istendiğinde (örneğin GET /kullanicilar) gövde, kayıtları taşıyan bir data dizisi ile sayfa bilgisini taşıyan bir meta nesnesinden oluşur. Bu yazıda bu iki biçimi, aralarındaki farkın nedenini ve tüm uç noktalarda (endpoint) aynı yapıyı nasıl koruyacağınızı çalışan bir FastAPI örneğiyle anlatıyoruz.

Yanıt yapısı bir standart tarafından dayatılmaz. HTTP yalnızca gövdenin bir temsil (representation) olduğunu ve türünün Content-Type başlığıyla bildirildiğini söyler; JSON da yalnızca sözdizimini tanımlar. data ve meta adları bir tasarım tercihidir. Bu yüzden asıl kural, hangi biçimi seçerseniz seçin onu API'nin her yerinde aynı şekilde uygulamaktır. Alan adlarının yazımı ve tarih biçimi ayrı bir konudur ve JSON alan adlandırma ve tarih formatı yazısında ele alınır.

Tekil kaynak yanıtı: zarfsız nesne

Bir kaynağı kimliğiyle istediğinizde istemcinin beklediği şey o kaynağın kendisidir. En sade çözüm, kaynağın alanlarını doğrudan gövdenin en üst seviyesine koymaktır. Aşağıdaki yanıt GET /kullanicilar/42 isteğine verilmiş bir 200 OK yanıtıdır:

http
HTTP/1.1 200 OK
Content-Type: application/json

{
  "id": 42,
  "email": "ayse@example.com",
  "ad": "Ayşe Yılmaz",
  "rol": "editor",
  "created_at": "2026-10-24T12:00:00Z"
}

Bu yanıtta dikkat edilecek birkaç nokta var. Birincisi, nesnenin kendi kimliği (id) gövdede yer alır; istemci URL'yi kaybetse bile kaydın hangisi olduğunu bilir. İkincisi, oluşturulma zamanı gibi meta veriler (created_at) kaynağın bir alanı olarak durur. İncelediğimiz tasarım rehberi yanıtlara zaman damgası ve kimlik gibi meta verilerin eklenmesini önerir; tekil kaynakta bunlar ayrı bir zarfa değil, kaynağın kendisine aittir. Üçüncüsü, Content-Type: application/json başlığı gövdenin nasıl yorumlanacağını söyler. RFC 9110, içerik taşıyan bir mesajda göndericinin bu başlığı üretmesi gerektiğini (SHOULD) belirtir; başlık yoksa alıcı gövdeyi application/octet-stream sayabilir ya da içeriği inceleyerek tahmin yürütebilir. Tahmine bırakmak her iki taraf için de risklidir.

Tekil kaynağı da bir data zarfına koyan API'ler vardır: {"data": {"id": 42, ...}}. Bu da tutarlı bir seçimdir ve tekil ile koleksiyon yanıtlarını aynı kalıba sokar. Bedeli, her istemcinin her seferinde bir seviye daha açması ve gövdenin artık doğrudan kaynağın temsili olmamasıdır. İncelediğimiz rehberin örnekleri tekil kaynağı zarfsız, koleksiyonu zarflı gösterir; bu yazıda da o düzeni izliyoruz. Hangisini seçerseniz seçin, bir uç noktada zarflı, ötekinde zarfsız tekil yanıt döndürmek istemcide gereksiz dallanmaya yol açar.

Koleksiyon yanıtı: data ve meta

Koleksiyon yanıtı: data ve meta

Bir liste döndürdüğünüzde istemcinin iki şeye ihtiyacı vardır: kayıtların kendisi ve o kayıtların bütün içindeki yeri. Kayıtlar data dizisine, sayfa bilgisi meta nesnesine konur. Aşağıda toplam 42 kullanıcının, sayfa başına 20 kayıtla üçüncü sayfası görülüyor; son sayfada yalnızca iki kayıt kaldığı için dizide iki eleman var:

json
{
  "data": [
    {"id": 41, "email": "mehmet@example.com", "ad": "Mehmet Kaya"},
    {"id": 42, "email": "ayse@example.com", "ad": "Ayşe Yılmaz"}
  ],
  "meta": {
    "total": 42,
    "page": 3,
    "limit": 20,
    "pages": 3
  }
}

meta içindeki alanların anlamı açıktır: total filtreye uyan toplam kayıt sayısı, page şu anki sayfa, limit sayfa başına kayıt, pages toplam sayfa sayısıdır. Toplam sayfa, tam sayı bölmesinin yukarı yuvarlanmasıyla bulunur: 42 kayıt ve 20 limit için (42 + 20 - 1) // 20 = 3. Bu alanların nasıl hesaplandığı, sınır değerleri ve next/prev bağlantıları offset sayfalama yazısının konusu; imleç (cursor) tabanlı listelerde meta bambaşka alanlar taşır (next_cursor, has_more) ve o da cursor pagination yazısında anlatılır. Buradaki ilke şu: sayfalama türü ne olursa olsun, kayıtlar her zaman aynı adlı dizide, yardımcı bilgi her zaman aynı adlı nesnede durur.

Listedeki öğelerin tekil yanıttaki nesneyle aynı şekle sahip olması gerekmez. Örnekte liste öğeleri yalnızca id, email ve ad taşıyor; ayrıntı için istemci tekil uç noktaya gider. Bu, büyük listelerde gövdeyi küçültür. Önemli olan, aynı adın her iki yerde aynı anlama ve aynı tipe sahip olmasıdır: listede id sayı, tekil yanıtta metin olursa istemci kodu iki ayrı yol yazmak zorunda kalır.

Neden çıplak dizi yerine nesne?

Bir koleksiyonu en kısa biçimde, doğrudan bir JSON dizisi olarak da döndürebilirsiniz:

json
[
  {"id": 41, "email": "mehmet@example.com"},
  {"id": 42, "email": "ayse@example.com"}
]

Bu geçerli bir JSON metnidir. RFC 8259'a göre bir JSON metni herhangi bir değer olabilir; eski tanımlar en üst seviyeyi nesne ya da diziyle sınırlıyordu, güncel tanım bu sınırı kaldırmıştır. Yani sorun sözdiziminde değil, büyümededir. En üst seviyesi dizi olan bir yanıta sonradan toplam kayıt sayısı ya da sonraki sayfa bilgisi eklemek istediğinizde ekleyecek yer yoktur; gövdeyi nesneye çevirmek mevcut istemcileri bozan bir değişikliktir. En üst seviye baştan bir nesne olduğunda ise isteğe bağlı yeni bir alan eklemek geriye uyumlu bir değişikliktir: eski istemciler yeni alanı okumadıkları için çalışmaya devam eder. Kırıcı (breaking) ve uyumlu değişiklik ayrımı API breaking change ve geriye uyumluluk yazısında ayrıntılı işlenir.

Oluşturma ve silme yanıtları

Oluşturma ve silme yanıtları

Yanıt yapısı yalnızca okuma uç noktalarını ilgilendirmez. Bir kaynak oluşturulduğunda (POST /kullanicilar) yaygın tasarım 201 Created durum kodu, yeni kaynağın adresini gösteren Location başlığı ve gövdede oluşturulan kaynağın kendisidir. Gövde, tekil GET yanıtıyla aynı biçimde olmalıdır; sunucunun atadığı id ve created_at gibi alanlar böylece istemciye ek bir istek gerekmeden ulaşır:

http
HTTP/1.1 201 Created
Content-Type: application/json
Location: /kullanicilar/43

{
  "id": 43,
  "email": "zeynep@example.com",
  "ad": "Zeynep Demir",
  "rol": "okur",
  "created_at": "2026-11-07T09:15:00Z"
}

Silme işleminde ise genellikle 204 No Content döner ve gövde yoktur. Bu durumda zarf da olmaz; boş bir {"data": null} göndermek yerine 204 kullanmak niyeti daha açık anlatır. Uç nokta bazında hangi metodun hangi kodla eşleştiği CRUD endpoint tasarımı yazısında, senaryoya göre kod seçimi durum kodu seçimi yazısında anlatılıyor.

Boş koleksiyon 404 değildir

Filtreye uyan kayıt olmadığında ya da sayfa numarası son sayfayı aştığında birçok API 404 döndürme hatasına düşer. Oysa /kullanicilar koleksiyonu vardır; yalnızca o sorgu için içi boştur. Doğru yanıt, boş bir dizi taşıyan 200 OK'dir:

json
{
  "data": [],
  "meta": {
    "total": 0,
    "page": 1,
    "limit": 20,
    "pages": 0
  }
}

Bu yaklaşımın pratik faydası istemci tarafında görülür: istemci her listede aynı kodu çalıştırır, dizi boşsa "kayıt yok" mesajı gösterir. 404 dönseydi istemci "koleksiyon yok mu, yoksa sadece sonuç mu yok?" ayrımını yapmak zorunda kalırdı. 404'ü, kimliği verilmiş tekil bir kaynağın bulunmadığı durumlara saklayın (örneğin GET /kullanicilar/999).

Hata yanıtları başka bir biçimdir

Başarılı yanıtlar data/meta düzenini izlerken hata yanıtlarının da kendi tek ve sabit biçimi olmalıdır. Hata durumunda data anahtarını boş bırakıp yanına bir error alanı eklemek ya da her uç noktada farklı bir hata şekli üretmek istemcinin işini zorlaştırır. Hataları HTTP durum koduyla ayırt edip gövdeyi ayrı bir şemaya bırakmak daha temiz bir sınır çizer. Standart seçenek Problem Details (RFC 9457), özel bir biçim isterseniz hata mesajı formatı ve hata kodları yazısına bakın.

FastAPI ile data ve meta yapısını zorlamak

Yapıyı belgelemek yetmez; kodun da onu garanti etmesi gerekir. FastAPI'de response_model ile her uç noktanın dönüş şeklini bir Pydantic modeline bağlayabilirsiniz. Model, döndürdüğünüz sözlüğü doğrular ve yalnızca tanımlı alanları gövdeye yazar. Aşağıdaki örnek veritabanı yerine bellekteki bir listeyi kullanıyor; gerçek uygulamada sorgu ve sayım veritabanında yapılır. İstekler TestClient ile gönderiliyor, yani sunucu açmadan çalışıyor:

python
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()

class Kullanici(BaseModel):
    id: int
    email: str
    ad: str
    created_at: datetime

class Meta(BaseModel):
    total: int
    page: int
    limit: int
    pages: int

class KullaniciListesi(BaseModel):
    data: list[Kullanici]
    meta: Meta

# Gerçek uygulamada veritabanı; burada bellekte bir liste
t = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
DB = [Kullanici(id=i, email=f"k{i}@example.com", ad=f"Kullanıcı {i}", created_at=t) for i in range(1, 6)]

@app.get("/kullanicilar", response_model=KullaniciListesi)
def listele(page: int = Query(1, ge=1), limit: int = Query(2, ge=1, le=100)):
    bas = (page - 1) * limit
    total = len(DB)
    return {"data": DB[bas:bas + limit],
            "meta": {"total": total, "page": page, "limit": limit, "pages": (total + limit - 1) // limit}}

@app.get("/kullanicilar/{kullanici_id}", response_model=Kullanici)
def getir(kullanici_id: int):
    for k in DB:
        if k.id == kullanici_id:
            return k
    raise HTTPException(status_code=404, detail="Kullanıcı bulunamadı")

c = TestClient(app)
print(c.get("/kullanicilar/2").text)
# çıktı: {"id":2,"email":"k2@example.com","ad":"Kullanıcı 2","created_at":"2026-10-24T12:00:00Z"}
r = c.get("/kullanicilar", params={"page": 3, "limit": 2})
print(r.json()["meta"], len(r.json()["data"]))
# çıktı: {'total': 5, 'page': 3, 'limit': 2, 'pages': 3} 1
r = c.get("/kullanicilar", params={"page": 9, "limit": 2})
print(r.status_code, r.json()["data"])
# çıktı: 200 []

Çıktıdan üç şey okunuyor. Tekil yanıt zarfsız ve created_at alanı UTC zamanı Z ekiyle yazılmış. Üçüncü sayfada beş kaydın yalnızca biri kaldığı için data tek elemanlı, pages değeri 3. Var olmayan dokuzuncu sayfa istendiğinde yanıt 404 değil, boş dizili bir 200. KullaniciListesi modeli bir kez tanımlandıktan sonra, başka bir liste uç noktası yazan geliştirici de aynı modeli kullanırsa yapı kendiliğinden tutarlı kalır.

Tutarlılık kontrol tablosu

Tutarlılık kontrol tablosu: Tekil kaynak bulundu, Koleksiyon, kayıt var, Koleksiyon, kayıt yok, Kaynak oluşturuldu

Aşağıdaki tablo bu yazıdaki kararları tek bakışta özetliyor. Bir uç nokta yazarken ya da incelerken her satırı kontrol edebilirsiniz:

DurumDurum koduGövde
Tekil kaynak bulundu200 OKKaynak nesnesi (zarfsız)
Koleksiyon, kayıt var200 OKdata dizisi + meta nesnesi
Koleksiyon, kayıt yok200 OKBoş data dizisi + meta (total 0)
Kaynak oluşturuldu201 CreatedOluşan kaynak + Location başlığı
Kaynak silindi204 No ContentGövde yok
Tekil kaynak yok404 Not FoundAPI genelindeki hata biçimi

Sık yapılan hatalar

Yanıt yapısında karşılaşılan sorunların çoğu tasarımdan değil, zamanla biriken tutarsızlıklardan çıkar. Aşağıdaki örnek, bu sorunların birkaçını aynı anda taşıyor:

json
{
  "success": true,
  "result": {
    "users": [
      {"id": 41, "email": "mehmet@example.com"}
    ]
  },
  "count": 1
}
  • Durum kodunu gövdede tekrarlamak: "success": true alanı HTTP durum kodunun zaten verdiği bilgiyi kopyalar. Daha kötüsü, bazı API'ler hata durumunda bile 200 ile "success": false döndürür; ara katmanlar ve istemci kütüphaneleri yanıtı başarılı sanır.
  • Kaynağa özel ara katman: result.users gibi her kaynakta değişen bir anahtar, istemcinin her uç nokta için ayrı ayrıştırma kodu yazmasını gerektirir. data gibi sabit bir ad bu sorunu ortadan kaldırır.
  • Sayfa bilgisini dağıtmak: count gibi alanların en üst seviyeye serpiştirilmesi, ileride yeni bir sayfa alanı eklenirken nereye konacağı sorusunu doğurur. Tüm yardımcı bilgi tek bir meta nesnesinde toplanmalıdır.
  • Aynı alanın farklı tipte gelmesi: bir uç noktada "id": 42, başka birinde "id": "42" dönmesi istemci tarafında hata ayıklaması zor sorunlara yol açar.
  • Boş sonuç için 404 ya da null: "data": null veya 404, boş dizinin anlattığı durumu bulanıklaştırır.
  • Yanıtta gizli alan: veritabanı satırını olduğu gibi döndürmek parola özeti gibi alanları sızdırabilir. Tasarım rehberinin kontrol listesi yanıtlarda sır bulunmamasını açıkça ister; response_model yalnızca tanımlı alanları yazdığı için bu riski azaltır.

Sık Sorulan Sorular

Tekil kaynağı da data içine koymak yanlış mı?

Yanlış değil; bir tasarım tercihidir. Tüm yanıtları data ile sarmak tek kalıp sağlar, zarfsız tekil yanıt ise daha kısadır. Önemli olan seçtiğiniz kalıbı tüm uç noktalarda istisnasız uygulamaktır.

meta içine sayfa dışında ne konabilir?

Listeye ait, kayıtların kendisi olmayan bilgi: toplam sayı, sayfa ve limit, imleç tabanlı sayfalamada sonraki imleç. Kaynağın kendi alanları meta'ya değil, data içindeki nesnelere aittir. Bağlantıların nereye konacağı için HATEOAS yazısına bakabilirsiniz.

Content-Type başlığına charset eklemeli miyim?

RFC 8259, application/json için bir charset parametresi tanımlamaz ve eklenmesinin uyumlu alıcılar üzerinde etkisi olmadığını söyler. Aynı RFC, kapalı bir ekosistem dışında değiş tokuş edilen JSON metninin UTF-8 ile kodlanmasını zorunlu tutar.

Liste öğeleri tekil yanıttaki tüm alanları taşımalı mı?

Zorunlu değil. Listede özet alanlar, tekil uç noktada tam kaynak vermek yaygın bir yaklaşımdır. Yalnızca ortak alanların adı ve tipi iki yerde aynı kalmalıdır.

Marka adları sahiplerine aittir. Bu yazı bağımsız bir teknik rehberdir; adı geçen kuruluşlarla bayilik veya ortaklık ilişkisi bulunduğu anlamına gelmez.

İlgili içerikler: REST API tasarımı öğrenme rehberi · JSON alan adlandırma · Offset sayfalama · Problem Details · CRUD endpoint tasarımı · HTTP istek ve yanıt yapısı