Kısa cevap: HATEOAS (Hypermedia as the Engine of Application State), bir API yanıtının yalnızca veriyi değil, o veriyle ilgili diğer kaynaklara ve o anda yapılabilecek işlemlere giden bağlantıları da taşıması demektir. İstemci URL'leri kendi kodunda birleştirmek yerine yanıttaki bağlantıları izler. En basit hâli, JSON gövdesine self, posts, followers gibi anahtarlarla bağlantı koyan bir links nesnesidir. Bağlantılar ayrıca RFC 8288'de tanımlı Link başlığıyla da gönderilebilir; sayfalamadaki "sonraki" ve "önceki" sayfa bağlantıları bunun tipik bir örneğidir.

Bu yazıda gövde içi bağlantıları, kaynağın durumuna göre değişen bağlantıları, Link başlığının söz dizimini ve sayfalama bağlantılarıyla ilişkisini FastAPI ile çalışan bir örnekle anlatıyoruz. Sayfalamanın kendisi offset sayfalama ve cursor sayfalama yazılarında, REST'in temel kavramları REST API nedir yazısında anlatılıyor. Serinin tamamı REST API tasarımı öğrenme rehberi sayfasında.

HATEOAS fikri: İstemci URL kurmasın, bağlantı izlesin

Bir web tarayıcısı, bir sitedeki tüm sayfaların adresini önceden bilmez; bir sayfayı açar ve oradaki bağlantıları izler. HATEOAS aynı fikri API'lere taşır. İstemci tek bir giriş noktasını bilir, diğer adresleri sunucunun verdiği bağlantılardan öğrenir. Adın içindeki "uygulama durumunun motoru" ifadesi buna işaret eder: İstemcinin bir sonraki adımda ne yapabileceğini, sunucunun yanıtta sunduğu bağlantılar belirler.

Bunun pratik faydası, istemci ile sunucu arasındaki bağın gevşemesidir. İstemci "/v1/kullanicilar/" + id + "/posts" gibi bir dizeyi kodunda kurmak yerine yanıttaki posts bağlantısını kullanırsa, sunucu tarafında URL yapısı değiştiğinde istemci kodu da değişmek zorunda kalmaz. Skill'in kontrol listesi bu deseni "ilgili kaynaklara bağlantı ekleyin" diye özetler ve şu örneği verir:

json
{
  "id": 123,
  "email": "user@example.com",
  "links": {
    "self": "/users/123",
    "posts": "/users/123/posts",
    "followers": "/users/123/followers"
  }
}

self, kaynağın kendi kanonik adresidir. Liste içinde gelen bir öğenin tekil adresini istemciye vermenin en temiz yolu budur. posts ve followers ise ilişkili alt koleksiyonlara gider; bu tür iç içe yolların ne kadar derin olması gerektiği iç içe kaynaklar ve URL derinliği yazısında tartışılıyor. Skill İngilizce kaynak adlarıyla /users kullanıyor; bu yazının örneklerinde Türkçe adlarla /v1/siparisler kullanıyoruz, iki yaklaşım da yaygındır.

Duruma bağlı bağlantılar

HATEOAS'ın en güçlü yanı, bağlantıların kaynağın o anki durumuna göre değişebilmesidir. Bir sipariş henüz kargoya verilmemişse iptal edilebilir; kargoya verildiyse edilemez. Sunucu bu kuralı yalnızca iptal mümkünken iptal bağlantısını ekleyerek istemciye iletebilir:

json
{
  "id": 1,
  "durum": "beklemede",
  "tutar": 100,
  "links": {
    "self": "/v1/siparisler/1",
    "kalemler": "/v1/siparisler/1/kalemler",
    "iptal": "/v1/siparisler/1/iptal"
  }
}
json
{
  "id": 2,
  "durum": "kargoda",
  "tutar": 200,
  "links": {
    "self": "/v1/siparisler/2",
    "kalemler": "/v1/siparisler/2/kalemler"
  }
}

İstemci "iptal düğmesini göster" kararını kendi koduna sipariş durumlarının listesini yazarak değil, iptal bağlantısının varlığına bakarak verir. Yarın iş kuralı değişir de "kargodaki sipariş de ilk bir saat içinde iptal edilebilir" olursa, değişiklik yalnızca sunucuda yapılır. Yine de bu bir kolaylıktır, güvenlik önlemi değildir: Sunucu, iptal isteği geldiğinde kuralı yeniden kontrol etmek zorundadır; aksi hâlde bağlantıyı kendisi uyduran bir istemci kuralı aşabilir. Bu durumda verilecek yanıt genellikle 409 Conflict olur; ayrıntı için durum kodu seçimi yazısına bakın.

Koleksiyonda her öğeye self bağlantısı

Koleksiyonda her öğeye self bağlantısı

Bir liste yanıtında öğeler genellikle özet hâlde gelir. Her öğeye kendi self bağlantısını eklemek, istemcinin ayrıntıya geçmek için adres kurmasını gereksiz kılar. Koleksiyon düzeyindeki bağlantılar ise listenin kendisine aittir:

json
{
  "data": [
    {"id": 4, "durum": "kargoda", "links": {"self": "/v1/siparisler/4"}},
    {"id": 5, "durum": "beklemede", "links": {"self": "/v1/siparisler/5"}}
  ],
  "links": {
    "self": "/v1/siparisler?page=2&limit=2",
    "next": "/v1/siparisler?page=3&limit=2",
    "prev": "/v1/siparisler?page=1&limit=2"
  }
}

Bağlantıların bir başka doğal yeri de oluşturma yanıtıdır. RFC 9110, 201 yanıtında oluşan ana kaynağın Location başlığıyla gösterildiğini ve gövdenin genellikle oluşan kaynağı tarif edip ona bağlantı verdiğini söyler. Yani Location başlığı ile gövdedeki self bağlantısı aynı adresi göstermelidir. Bu yanıtların ayrıntısı CRUD endpoint tasarımı yazısında.

Link başlığının bir de anchor parametresi vardır. Varsayılan bağlam yanıtın kaynağıdır; anchor bu bağlamı başka bir URI ile, örneğin belgenin bir bölümüyle değiştirir. RFC 8288'e göre bir uygulama anchor içeren bağlantıları tamamen yok sayabilir, ama işleyecekse anchor değerini uygulamadan işleyemez. Basit bir API için bu parametreye genellikle ihtiyaç duyulmaz; kullanıyorsanız istemcilerinizin onu desteklediğinden emin olun.

Sayfalama bağlantıları

HATEOAS fikrinin en doğal uygulama alanlarından biri sayfalamadır. Skill'in offset sayfalama örneği, yanıtın meta bölümüne next ve prev adreslerini koyar. İstemci, sayfa numarasını kendisi hesaplamak yerine next bağlantısını izler; bağlantı yoksa son sayfaya gelmiş demektir. Cursor tabanlı sayfalamada bu daha da önemlidir, çünkü istemci bir sonraki imleci (cursor) kendisi üretemez, sunucunun verdiği değeri kullanmak zorundadır. Sayfalama meta alanlarının tasarımı offset sayfalama yazısında ayrıntılı olarak işleniyor; burada bağlantıların kendisine odaklanıyoruz.

FastAPI ile çalışan örnek

Aşağıdaki uygulama iki şey yapıyor: Tekil sipariş yanıtına duruma bağlı bağlantılar ekliyor ve liste yanıtında sayfa bağlantılarını hem gövdede hem Link başlığında veriyor. Veri bellekteki bir sözlük. İstemci tarafında TestClient yanıtı bir httpx yanıtıdır ve httpx, Link başlığını ayrıştıran bir links özelliği sunar.

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

app = FastAPI()
SIPARISLER = {i: {"id": i, "durum": "beklemede" if i % 2 else "kargoda", "tutar": i * 100} for i in range(1, 8)}

def baglantilar(s: dict) -> dict:
    yol = f"/v1/siparisler/{s['id']}"
    links = {"self": yol, "kalemler": f"{yol}/kalemler"}
    if s["durum"] == "beklemede":
        links["iptal"] = f"{yol}/iptal"  # yalnızca iptal edilebilir durumdaysa
    return links

@app.get("/v1/siparisler/{siparis_id}")
def getir(siparis_id: int):
    s = SIPARISLER.get(siparis_id)
    if s is None:
        raise HTTPException(404, "Sipariş bulunamadı")
    return {**s, "links": baglantilar(s)}

@app.get("/v1/siparisler")
def listele(response: Response, page: int = 1, limit: int = 3):
    ids = sorted(SIPARISLER)
    dilim = ids[(page - 1) * limit: page * limit]
    son = (len(ids) + limit - 1) // limit
    links = {"self": f"/v1/siparisler?page={page}&limit={limit}"}
    if page < son:
        links["next"] = f"/v1/siparisler?page={page + 1}&limit={limit}"
    if page > 1:
        links["prev"] = f"/v1/siparisler?page={page - 1}&limit={limit}"
    baslik = [f'<{links["next"]}>; rel="next"'] if "next" in links else []
    if "prev" in links:
        baslik.append(f'<{links["prev"]}>; rel="previous"')
    if baslik:
        response.headers["Link"] = ", ".join(baslik)
    return {"data": [SIPARISLER[i]["id"] for i in dilim], "links": links}

c = TestClient(app)
print(c.get("/v1/siparisler/1").json())
# çıktı: {'id': 1, 'durum': 'beklemede', 'tutar': 100, 'links': {'self': '/v1/siparisler/1', 'kalemler': '/v1/siparisler/1/kalemler', 'iptal': '/v1/siparisler/1/iptal'}}
print(c.get("/v1/siparisler/2").json()["links"])
# çıktı: {'self': '/v1/siparisler/2', 'kalemler': '/v1/siparisler/2/kalemler'}
r = c.get("/v1/siparisler?page=2")
print(r.json())
# çıktı: {'data': [4, 5, 6], 'links': {'self': '/v1/siparisler?page=2&limit=3', 'next': '/v1/siparisler?page=3&limit=3', 'prev': '/v1/siparisler?page=1&limit=3'}}
print(r.headers["link"])
# çıktı: </v1/siparisler?page=3&limit=3>; rel="next", </v1/siparisler?page=1&limit=3>; rel="previous"
print(r.links["next"]["url"])
# çıktı: /v1/siparisler?page=3&limit=3
print(r.links["previous"]["url"])
# çıktı: /v1/siparisler?page=1&limit=3
print(r.request.url.join(r.links["next"]["url"]))
# çıktı: http://testserver/v1/siparisler?page=3&limit=3
print(c.get("/v1/siparisler?page=3").headers["link"])
# çıktı: </v1/siparisler?page=2&limit=3>; rel="previous"

İlk iki satır duruma bağlı bağlantıları gösteriyor: Beklemedeki 1 numaralı siparişte iptal bağlantısı var, kargodaki 2 numarada yok. İkinci sayfa isteğinde hem next hem previous bağlantısı dönüyor; son sayfada ise yalnızca previous kalıyor. İlk sayfada Link başlığında yalnızca next olur.

Bir ayrıntıya dikkat edin: httpx'in links özelliği göreli adresi olduğu gibi döndürdü, tam adrese çevirmedi. RFC 8288 göreli adreslerin çözülmesini ayrıştırıcıdan bekler; bu kütüphanede çözümlemeyi sizin yapmanız gerekiyor. Örnekteki r.request.url.join() çağrısı göreli adresi isteğin adresine göre tam URL'ye çeviriyor. Kendi istemci kodunuzda da aynı adımı unutmayın.

Gövdede mi, başlıkta mı?

Gövdede mi, başlıkta mı? Standart, Görünürlük, Liste öğeleri, Gövdesiz yanıtlar
ÖlçütGövdede links nesnesiLink başlığı (RFC 8288)
StandartBiçim sizin sözleşmenizRFC 8288 ile tanımlı söz dizimi
GörünürlükJSON ile birlikte okunurGövdeyi açmadan okunabilir
Liste öğeleriHer öğeye ayrı bağlantı konabilirYanıtın bütünü için uygundur
Gövdesiz yanıtlarKullanılamazHEAD veya 204 ile de gönderilebilir
Duruma bağlı işlemlerKaynağın yanında doğal dururMümkün ama okunması zor

İkisini birlikte kullanmak da mümkündür: Kaynağa özgü bağlantılar gövdede, koleksiyon düzeyindeki sayfa bağlantıları başlıkta. Link başlığının bir başka kullanım alanı da kullanımdan kaldırma bildirimleridir; eski bir sürümün kaldırılma takvimini anlatan bağlantılar Deprecation ve Sunset başlıkları yazısında anlatılıyor.

Ne kadar ileri gitmeli?

HATEOAS'ı en katı biçimde uygulayan bir API'de istemci, giriş noktası dışında hiçbir URL bilmez. Daha ılımlı bir yol da seçebilirsiniz: self ve sayfalama bağlantılarını her zaman verir, duruma bağlı işlemleri önemli kaynaklarda ekler. Bunun sebebi bedeldir. Her yanıta bağlantı eklemek gövdeyi büyütür, bağlantıları üreten kodun her uç noktada tutarlı tutulması gerekir ve istemciler bağlantıları kullanmayı seçmezse bu emek boşa gider.

Bağlantılar dokümantasyonun yerini de tutmaz. İstemci bir iptal bağlantısı gördüğünde hangi metotla, hangi gövdeyle çağıracağını yine belgelerden öğrenir. Uç noktaların açık tanımı için OpenAPI ve Swagger yazısına bakın. Tekil kaynak ve koleksiyon yanıtlarının genel yapısı ise JSON yanıt yapısı yazısında anlatılıyor.

Sık yapılan hatalar

  • Bağlantı verip istemcide yine URL kurmak. Bağlantıların faydası ancak istemci onları kullandığında ortaya çıkar.
  • Anahtar adlarında tutarsızlık. Bir uç noktada self, diğerinde url, üçüncüsünde href kullanmak istemciyi her yanıt için ayrı kod yazmaya zorlar.
  • Göreli adresleri çözmeden kullanmak. Göreli bağlantıyı başka bir temel adrese eklemek yanlış hedefe istek atılmasına yol açar.
  • İç ağ adreslerini sızdırmak. Ters vekil sunucunun arkasında çalışan bir uygulama mutlak bağlantı üretirken kendi iç adresini kullanabilir; göreli yollar ya da yapılandırılmış bir dış adres bu riski azaltır.
  • Bağlantıyı yetki kontrolü sanmak. Bir işlem bağlantısının yokluğu o işlemi engellemez; sunucu her istekte kuralı yeniden denetlemelidir.
  • Link başlığında rel unutmak. RFC 8288'e göre rel her bağlantıda bulunmak zorundadır.

Sık Sorulan Sorular

HATEOAS kullanmayan bir API REST değil midir?

Bu yazının kaynakları böyle bir hüküm vermiyor. Skill HATEOAS'ı keşfedilebilir API'ler için yaygın desenlerden biri olarak sunar, zorunlu bir kural olarak değil. Kendi API'nizde hangi bağlantıların istemcilerinize gerçekten fayda sağladığına bakarak karar verin.

Bağlantılar mutlak mı göreli mi olmalı?

İkisi de mümkündür. Göreli yollar daha kısadır ve sunucunun dış adresini bilmesini gerektirmez; ancak istemci bunları isteğin adresine göre çözmelidir. RFC 8288, Link başlığındaki göreli adreslerin URI kurallarına göre çözülmesini ister.

Sayfalamada Link başlığı mı, gövde mi?

İkisini birden verebilirsiniz; örnekteki uygulama böyle yapıyor. Gövdedeki bağlantılar JSON okuyan istemciler için kolaydır, başlık ise gövdeyi ayrıştırmadan sayfa gezmek isteyen araçlar için. Seçiminizi belgelerinizde açıkça yazın.

Bağlantılara HTTP metodunu da eklemeli miyim?

RFC 8288 bağlantının metodunu tanımlayan bir parametre içermez. Gövdedeki bağlantı nesnesine metot gibi kendi alanlarınızı ekleyebilirsiniz; bu tamamen sizin JSON sözleşmenizdir ve belgelenmesi gerekir. Daha basit yol, metodu dokümantasyonda tanımlamaktır.

Beklediğim bağlantı yanıtta yoksa istemci ne yapmalı?

Bağlantının yokluğu da bir bilgidir. Sayfalamada next yoksa son sayfadasınız; siparişte iptal yoksa o anda iptal mümkün değildir. İstemci bu durumda adresi kendisi kurup denemek yerine ilgili işlemi kullanıcıya sunmamalıdır. Sunucu da bağlantıyı yalnızca işlem gerçekten mümkünse eklemelidir; aksi hâlde bu sinyal güvenilirliğini kaybeder.

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 · Offset sayfalama · Cursor sayfalama · REST API nedir · JSON yanıt yapısı · Deprecation ve Sunset başlıkları · İç içe kaynaklar