Bir kaynak başka bir kaynağa aitse, bu ilişkiyi URL'de iç içe yazarak gösterebilirsiniz: /kullanicilar/123/yazilar 123 numaralı kullanıcının yazılarıdır, /yazilar/456/yorumlar 456 numaralı yazının yorumlarıdır. Yaygın tasarım tercihi bu iç içeliği en fazla üç seviyede tutmaktır. Daha derine inen bir yol gördüğünüzde, en alttaki kaynağı kendi kökünden adreslemek çoğu zaman daha iyidir: /kullanicilar/123/yazilar/456/yorumlar/789/yanitlar yerine /yorumlar/789/yanitlar.

Bu yazıda iç içe URL'nin ne anlattığını, "seviye" kavramının nasıl sayılacağını, derin yolların neden sorun çıkardığını ve üst kaynağın sunucuda neden doğrulanması gerektiğini çalışan örneklerle ele alıyoruz. Kaynak adlarının kendisi isimlendirme yazısında, kimliği yolda ve filtreyi sorgu dizesinde taşımanın genel kuralı ise path ve query parametresi yazısında anlatılıyor.

İç içe URL ne anlatır?

İç içe URL ne anlatır? Sahiplik: alt kaynak üst kaynak olmadan yok, Parça: alt kaynak bütünün parçası, Görünüm: aynı kaynağın bir alt kümesi, Bağımsız kaynaklar arasında bağ

İç içe bir yol, istemciye kaynakların arasındaki bağı tek bakışta gösterir. GET /kullanicilar/123/yazilar okunduğunda "123 numaralı kullanıcının yazıları" diye anlaşılır; bir sorgu parametresine bakmaya gerek kalmaz. Aynı biçim oluşturma isteğinde de işe yarar: POST /kullanicilar/123/yazilar yeni yazının hangi kullanıcıya ait olacağını gövdeye bir alan eklemeden söyler.

Bu yüzden iç içe yol en çok sahiplik ve kapsam ilişkilerine uyar. Bir yorum bir yazı olmadan var olamıyorsa, bir sipariş kalemi bir siparişin parçasıysa, alt kaynağı üst kaynağın altında adreslemek doğaldır. İki kaynak birbirinden bağımsız yaşıyorsa, örneğin bir ürün birçok siparişte geçebiliyorsa, ürünü siparişin altına yerleştirmek ilişkiyi olduğundan sıkı gösterir.

İlişkiÖrnekİç içe yol uygun mu?
Sahiplik: alt kaynak üst kaynak olmadan yokYazının yorumlarıEvet: /yazilar/456/yorumlar
Parça: alt kaynak bütünün parçasıSiparişin kalemleriEvet: /siparisler/1001/siparis-kalemleri
Görünüm: aynı kaynağın bir alt kümesiKullanıcının yazılarıEvet: /kullanicilar/123/yazilar
Bağımsız kaynaklar arasında bağSiparişte geçen ürünGenelde hayır: /urunler/7
İsteğe bağlı, birleşebilen ölçütBelirli tarihteki aktif siparişlerHayır: sorgu parametresi

"En fazla 3 seviye" nasıl sayılır?

Üç seviye sınırı bir standart değildir; URL'leri kısa ve öngörülebilir tutmak için benimsenmiş bir tasarım tercihidir. Seviyeyi saymanın pratik yolu, yoldaki koleksiyon adlarını saymaktır. /kullanicilar/123/yazilar iki koleksiyon içerir; /kullanicilar/123/yazilar/456 hâlâ iki koleksiyondur ve tekil bir yazıyı gösterir. /kullanicilar/123/yazilar/456/yorumlar üçüncü koleksiyona iner ve sınırın tam kıyısındadır.

bash
# Kısa ve okunaklı (1-2 koleksiyon)
/kullanicilar/123
/kullanicilar/123/yazilar
/yazilar/456/yorumlar

# Sınırda (3 koleksiyon)
/kullanicilar/123/yazilar/456/yorumlar

# Çok derin (4 koleksiyon): düzleştirin
/kullanicilar/123/yazilar/456/yorumlar/789/yanitlar
# Yerine:
/yorumlar/789/yanitlar

Sınırın amacı bir sayıyı korumak değil, istemcinin bir kaynağa ulaşmak için bilmesi gereken bilgiyi azaltmaktır. İkinci seviyede bile gereksiz bir üst kaynak görüyorsanız onu da kaldırabilirsiniz.

Derin URL'ler neden sorun çıkarır?

Derin bir yolun sorunu estetik değil, pratiktir. Dört koleksiyon içeren bir adresle bir yanıtı okumak isteyen istemci, kullanıcı, yazı ve yorum kimliklerinin hepsini bilmek zorundadır. Oysa bir yorumun kimliği zaten benzersizse, 789 numaralı yorumun yanıtlarını bulmak için başka hiçbir kimliğe gerek yoktur. Derin yol, istemciden fazladan bilgi ister ve bu bilgi yanlış olduğunda yeni bir hata türü doğurur.

Sunucu tarafında da her seviye ek iş demektir. Yoldaki her kimliğin var olduğunu ve bir alttakiyle gerçekten ilişkili olduğunu kontrol etmeniz gerekir; aksi hâlde /kullanicilar/1/yazilar/456 adresi, yazı 456 başka bir kullanıcıya ait olsa bile o yazıyı döndürebilir. Seviye arttıkça bu kontroller çoğalır, rota tanımları uzar ve aynı kaynağa birden çok yoldan ulaşılabilir hâle gelir. Bir yorumun hem /yazilar/456/yorumlar/789 hem de /kullanicilar/123/yazilar/456/yorumlar/789 adresinden okunabildiği bir API'de istemcinin hangi adresi kullanacağı belirsizleşir.

Düzleştirmenin ön koşulu, alt kaynağın kendi başına benzersiz bir kimliğe sahip olmasıdır. Yorum kimlikleri yalnızca bir yazı içinde benzersizse (her yazının yorumları 1'den başlıyorsa) /yorumlar/789 belirsiz kalır. Kimlikleri koleksiyon genelinde benzersiz üretmek, düzleştirmeyi mümkün kılar.

Üst kaynağı doğrulamak: iç içe yolun gizli zorunluluğu

İç içe yol kullanıyorsanız sunucunun iki şeyi kontrol etmesi gerekir: üst kaynak var mı ve alt kaynak gerçekten bu üst kaynağa mı ait? Aşağıdaki örnekte 457 numaralı yazı başka bir kullanıcıya ait; 123 numaralı kullanıcının yolundan istendiğinde 404 dönüyor:

python
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient

app = FastAPI()
kullanicilar = {123: {"id": 123, "ad": "Mehmet"}}
yazilar = {
    456: {"id": 456, "kullanici_id": 123, "baslik": "İlk yazı"},
    457: {"id": 457, "kullanici_id": 999, "baslik": "Başkasının yazısı"},
}

@app.get("/kullanicilar/{kullanici_id}/yazilar")
def kullanicinin_yazilari(kullanici_id: int):
    if kullanici_id not in kullanicilar:
        raise HTTPException(status_code=404, detail="Kullanıcı bulunamadı")
    return {"data": [y for y in yazilar.values() if y["kullanici_id"] == kullanici_id]}

@app.get("/kullanicilar/{kullanici_id}/yazilar/{yazi_id}")
def kullanicinin_yazisi(kullanici_id: int, yazi_id: int):
    yazi = yazilar.get(yazi_id)
    if kullanici_id not in kullanicilar or yazi is None or yazi["kullanici_id"] != kullanici_id:
        raise HTTPException(status_code=404, detail="Yazı bulunamadı")
    return yazi

c = TestClient(app)
print(c.get("/kullanicilar/123/yazilar").json())
# çıktı: {'data': [{'id': 456, 'kullanici_id': 123, 'baslik': 'İlk yazı'}]}
print(c.get("/kullanicilar/123/yazilar/456").status_code)  # çıktı: 200
print(c.get("/kullanicilar/123/yazilar/457").status_code)  # çıktı: 404
print(c.get("/kullanicilar/5/yazilar").json())  # çıktı: {'detail': 'Kullanıcı bulunamadı'}

Son satırdaki istek, var olmayan bir kullanıcının yazılarını istediği için boş bir liste değil 404 alır. Bu bilinçli bir karardır: boş liste "kullanıcı var ama yazısı yok" anlamına gelir, 404 ise "bu adreste bir kaynak yok" der. RFC 9110, 404'ün sunucunun hedef kaynak için güncel bir temsil bulamadığını ya da var olduğunu açıklamak istemediğini bildirdiğini söyler. Üçüncü satırda başkasına ait yazı için 404 dönmek, kaynağın varlığını ele vermemek açısından da uygun bir seçimdir; yetki ile ilgili 403 ve 404 tercihi 401 ve 403 farkı yazısında ele alınır. Örnekteki sözlükler yalnızca gösterim içindir; gerçek bir API'de bu kontroller veritabanı sorgusuyla yapılır.

Oluşturma: iç içe yoldan ekle, kendi adresinden göster

İç içe yolun en güçlü olduğu yer oluşturma isteğidir. Yeni bir yorum eklerken hangi yazıya ait olduğunu yoldan almak, gövdede ayrı bir yazi_id alanı gönderip yolla çelişme riskini ortadan kaldırır:

http
POST /yazilar/456/yorumlar HTTP/1.1
Content-Type: application/json

{"metin": "Güzel yazı"}

Yanıttaki Location başlığı ise yeni kaynağın asıl adresini göstermelidir. RFC 9110'a göre 201 Created yanıtında Location, istekle oluşturulan birincil kaynağı gösterir. Bu adresin iç içe olması gerekmez; yorumlar kendi kökünden adreslenebiliyorsa en kısa adres onun kalıcı adresidir:

http
HTTP/1.1 201 Created
Location: /yorumlar/789
Content-Type: application/json

{"id": 789, "yazi_id": 456, "metin": "Güzel yazı"}

Aynı fikir derin yolu düzleştirirken de işe yarar. Bir yoruma yanıt eklemek için dört seviyeli bir yol yerine yorumun kendi adresinin altındaki yanitlar koleksiyonu yeterlidir. Yanıt da bir yorumdur; sunucu yanıtın hangi yazıya ait olduğunu üst yorumdan çıkarır:

python
from fastapi import FastAPI, HTTPException, Response
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()
yazilar = {456: {"id": 456, "baslik": "İlk yazı"}}
yorumlar = {789: {"id": 789, "yazi_id": 456, "ust_id": None, "metin": "Güzel yazı"}}

class YorumGirdi(BaseModel):
    metin: str

def yeni_id():
    return max(yorumlar) + 1

@app.post("/yazilar/{yazi_id}/yorumlar", status_code=201)
def yorum_ekle(yazi_id: int, girdi: YorumGirdi, response: Response):
    if yazi_id not in yazilar:
        raise HTTPException(status_code=404, detail="Yazı bulunamadı")
    yorum = {"id": yeni_id(), "yazi_id": yazi_id, "ust_id": None, "metin": girdi.metin}
    yorumlar[yorum["id"]] = yorum
    response.headers["Location"] = f"/yorumlar/{yorum['id']}"
    return yorum

@app.post("/yorumlar/{yorum_id}/yanitlar", status_code=201)
def yanit_ekle(yorum_id: int, girdi: YorumGirdi, response: Response):
    ust = yorumlar.get(yorum_id)
    if ust is None:
        raise HTTPException(status_code=404, detail="Yorum bulunamadı")
    yanit = {"id": yeni_id(), "yazi_id": ust["yazi_id"], "ust_id": yorum_id, "metin": girdi.metin}
    yorumlar[yanit["id"]] = yanit
    response.headers["Location"] = f"/yorumlar/{yanit['id']}"
    return yanit

@app.get("/yorumlar/{yorum_id}/yanitlar")
def yanitlar(yorum_id: int):
    if yorum_id not in yorumlar:
        raise HTTPException(status_code=404, detail="Yorum bulunamadı")
    return {"data": [y for y in yorumlar.values() if y["ust_id"] == yorum_id]}

c = TestClient(app)
r = c.post("/yorumlar/789/yanitlar", json={"metin": "Katılıyorum"})
print(r.status_code, r.headers["location"])  # çıktı: 201 /yorumlar/790
print(r.json())
# çıktı: {'id': 790, 'yazi_id': 456, 'ust_id': 789, 'metin': 'Katılıyorum'}
print(len(c.get("/yorumlar/789/yanitlar").json()["data"]))  # çıktı: 1
print(c.post("/yazilar/1/yorumlar", json={"metin": "x"}).status_code)  # çıktı: 404

Örnekte istemci yalnızca 789 numaralı yorumu biliyor ve yanıtı ekleyebiliyor; yazi_id değerini sunucu üst yorumdan dolduruyor. Var olmayan bir yazıya yorum eklemeye çalışan son istek 404 alıyor. Yeni kimlik üretmek için kullanılan max çözümü yalnızca örnek içindir; gerçek bir uygulamada kimliği veritabanı üretir.

İç içe yol mu, sorgu parametresi mi?

İç içe yol mu, sorgu parametresi mi? Okunaklılık, Üst kaynak zorunlu mu?, Ölçüt birleştirme, Üst kaynak yoksa

Aynı veri iki biçimde istenebilir: GET /kullanicilar/123/yazilar ya da GET /yazilar?kullanici_id=123. Kaynaktaki tercih, sahiplik ilişkisi için iç içe yolun daha açık olduğu yönündedir. Ancak sorgu parametresinin üstün olduğu durumlar da vardır ve ikisini birlikte sunmak çelişki değildir.

Ölçütİç içe yolSorgu parametresi
Okunaklılıkİlişkiyi doğrudan gösterirFiltre olarak okunur
Üst kaynak zorunlu mu?Evet, yol onsuz anlamsızHayır, parametre verilmeyebilir
Ölçüt birleştirmeZor: her bileşim yeni yol isterKolay: kullanici_id=123 ve durum=yayinda
Üst kaynak yoksa404 dönmek doğaldırBoş liste dönmek doğaldır
Oluşturma isteğiÜst kaynak yoldan gelirÜst kaynak gövdede gönderilir

Pratik bir kural: ilişki kaynağın kimliğinin parçasıysa yolu, sonuçları daraltan isteğe bağlı bir ölçütse sorgu parametresini kullanın. Bütün yazıları kullanıcıya, duruma ve tarihe göre süzmek isteyen bir yönetim ekranı için /yazilar?kullanici_id=123&durum=yayinda daha esnektir. Filtre parametrelerinin tasarımı filtreleme yazısında anlatılır.

Değiştirme ve silme: kalıcı adresi kullanın

Oluşturma için iç içe yolu kullanan bir API'nin, değiştirme ve silme için de iç içe yol tanımlaması gerekmez. Yorum oluşturulduktan sonra onun kalıcı adresi /yorumlar/789 ise PATCH /yorumlar/789 ve DELETE /yorumlar/789 bu adrese yapılır. Böylece her kaynak için tek bir okuma, değiştirme ve silme adresi olur ve Location başlığında verdiğiniz adres gerçekten kullanılabilir bir adres hâline gelir.

RFC 9110'un DELETE tanımı da bu kullanımı örnekler: Location başlığıyla bildirilen, 201 yanıtıyla oluşturulan bir kaynağın, bu işlemi geri almak için ilgili bir DELETE isteğine izin verebileceğini söyler. İç içe yolları yalnızca "bu üst kaynağın altında ne var" sorusu ve "bu üst kaynağın altına ekle" isteği için ayırmak, rota sayısını da yarı yarıya azaltır.

Alt koleksiyon da bir koleksiyondur

/kullanicilar/123/yazilar bir koleksiyon döndürdüğü için koleksiyonlara uyguladığınız bütün kurallar burada da geçerlidir. Çok yazısı olan bir kullanıcı için sınırsız liste döndürmek, üst düzey /yazilar koleksiyonundaki sorunun aynısıdır. Sayfalama, sıralama ve filtre parametrelerini alt koleksiyonda da aynı adlarla destekleyin:

bash
GET /kullanicilar/123/yazilar?page=2&limit=20
GET /kullanicilar/123/yazilar?sort=-created_at
GET /yazilar/456/yorumlar?limit=50

İstemcinin bir kez öğrendiği page, limit ve sort parametrelerinin her koleksiyonda aynı davranması, iç içe olsun ya da olmasın API'nin öngörülebilirliğini korur. Sayfalama seçenekleri offset sayfalama yazısında anlatılır.

Mini örnek: blog API'sinin URL haritası

Bu yazıdaki kararları bir araya getirince küçük bir blog API'sinin uç nokta listesi şöyle görünür. Her kaynağın kendi kökünde kalıcı bir adresi var; iç içe yollar yalnızca listeleme ve ekleme için kullanılıyor; hiçbir yol üç koleksiyonu aşmıyor:

bash
GET    /kullanicilar/123
GET    /kullanicilar/123/yazilar
POST   /kullanicilar/123/yazilar
GET    /yazilar/456
PATCH  /yazilar/456
DELETE /yazilar/456
GET    /yazilar/456/yorumlar
POST   /yazilar/456/yorumlar
GET    /yorumlar/789
DELETE /yorumlar/789
GET    /yorumlar/789/yanitlar
POST   /yorumlar/789/yanitlar

Listede en uzun yol iki koleksiyonlu. Bir yorumla ilgili her işlem için istemcinin bilmesi gereken tek şey yorumun kimliği. Yazının sahibi değiştiğinde ya da bir yazı başka bir kullanıcıya aktarıldığında yorumların adresleri değişmiyor, çünkü yorum adresinde kullanıcı kimliği yok. Derin yolların bir başka gizli maliyeti de budur: üst kaynaklardan biri değiştiğinde alttaki bütün adresler değişir.

Çoka çok ilişkiler

Takip ilişkisi gibi çoka çok bağlarda iç içe yol, ilişkinin bir yönünü gösteren bir görünüm olarak kullanılabilir: /kullanicilar/123/takipciler 123 numaralı kullanıcıyı takip edenleri listeler. Bu adres kullanıcıların bir alt kümesini döndürür; listedeki her öğenin asıl adresi yine /kullanicilar/{id} olmalıdır. Yanıt gövdesine bu tür ilgili adresleri bağlantı olarak eklemek HATEOAS yazısının konusudur.

Sık yapılan hatalar

Sık yapılan hatalar: Her ilişkiyi yola dökmek ve dört beş seviyeli adresler üretmek., Yoldaki üst kaynağı kontrol etmemek, Aynı kaynağı birden çok iç içe yoldan sunup hangisinin...
  • Her ilişkiyi yola dökmek ve dört beş seviyeli adresler üretmek.
  • Yoldaki üst kaynağı kontrol etmemek: alt kaynak başka bir üst kaynağa ait olsa bile döndürmek.
  • Aynı kaynağı birden çok iç içe yoldan sunup hangisinin asıl adres olduğunu belirlememek.
  • Oluşturma isteğinde üst kaynağı hem yolda hem gövdede istemek ve ikisi farklı olduğunda ne yapılacağını tanımlamamak.
  • Yalnızca bir üst kaynak içinde benzersiz kimlikler üretip sonradan düzleştirmeyi imkânsız kılmak.
  • İsteğe bağlı filtreleri (/kullanicilar/123/yazilar/yayinda) yol parçası yapmak.

Sık Sorulan Sorular

Üç seviye sınırı bir kural mı?

Hayır, bir tasarım tercihidir. Amacı istemcinin bir kaynağa ulaşmak için bilmesi gereken kimlik sayısını azaltmak ve sunucudaki doğrulama yükünü sınırlamaktır.

Alt kaynağın yanıtında üst kaynağın kimliği bulunmalı mı?

Genellikle evet. Yorum /yorumlar/789 adresinden okunduğunda hangi yazıya ait olduğu yoldan anlaşılmaz, bu yüzden yazi_id alanını gövdede döndürmek istemcinin işini kolaylaştırır.

İç içe yol ve sorgu parametresini birlikte sunabilir miyim?

Evet. /kullanicilar/123/yazilar bir kullanıcının sayfası için, /yazilar?kullanici_id=123 esnek filtreleme için birlikte var olabilir. İkisinin aynı biçimde ve aynı sayfalama kurallarıyla yanıt vermesine dikkat edin.

Yoldaki kimlik ile gövdedeki kimlik farklıysa ne yapmalıyım?

En temiz çözüm, üst kaynağın kimliğini gövdede hiç istememektir: yoldan gelen değer tek doğru kaynak olur. Gövdede de kabul ediyorsanız iki değer çeliştiğinde isteği bir istemci hatasıyla reddedin ve hangi alanın çeliştiğini hata gövdesinde söyleyin. Sessizce birini seçmek, istemcinin yanlış yere kayıt eklediğini fark etmemesine yol açar. Hata gövdesinin biçimi hata mesajı formatı yazısında anlatılır.

Üst kaynak silinirse alt kaynaklar ne olur?

Bu bir iş kuralıdır ve API'nin belgesinde açıkça yazılmalıdır. RFC 9110, DELETE'in sunucudaki bağı kaldırdığını ama ilişkili verinin silinip silinmeyeceğini uygulamaya bıraktığını belirtir.

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 · Endpoint isimlendirme kuralları · Path ve query parametresi · CRUD endpoint tasarımı · HATEOAS