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:
{
"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:
{
"id": 1,
"durum": "beklemede",
"tutar": 100,
"links": {
"self": "/v1/siparisler/1",
"kalemler": "/v1/siparisler/1/kalemler",
"iptal": "/v1/siparisler/1/iptal"
}
}{
"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.
Link başlığı: RFC 8288

Bağlantıları gövde yerine HTTP başlığında da taşıyabilirsiniz. RFC 8288 (Web Linking), bir bağlantıyı dört parçalı bir ifade olarak tanımlar: bağlantının bağlamı (context), ilişki türü (relation type), hedefi (target) ve isteğe bağlı hedef öznitelikleri. Okunuşu "bağlamın, şu öznitelikleri taşıyan şu hedefte şu türde bir kaynağı var" şeklindedir.
Link başlığında her bağlantı açılı parantez içinde bir URI ve noktalı virgülle ayrılmış parametrelerle yazılır. Birden fazla bağlantı virgülle ayrılarak aynı başlıkta ya da ayrı Link satırlarında gönderilebilir; RFC 8288 bu iki biçimin eşdeğer olduğunu örnekle gösterir. Başlıktaki bağlantının bağlamı varsayılan olarak yanıtın ait olduğu kaynağın URL'sidir.
HTTP/1.1 200 OK
Content-Type: application/json
Link: </v1/siparisler?page=3&limit=3>; rel="next", </v1/siparisler?page=1&limit=3>; rel="previous"
{"data": [4, 5, 6]}Söz dizimiyle ilgili standardın koyduğu kurallar şunlardır:
- rel zorunludur. Her bağlantıda
relparametresi bulunmalı ve bir kez geçmelidir; sonraki tekrarlar yok sayılır. Tek birreldeğeri boşlukla ayrılmış birden fazla ilişki türü içerebilir. - Göreli adresler çözülür. Açılı parantez içindeki URI göreli ise ayrıştırıcının onu URI kurallarına göre tam adrese çevirmesi gerekir. Gövdedeki bir temel URL bu çözümlemeye katılmaz.
- Kayıtlı ve uzantı türleri vardır. Kayıtlı ilişki türleri, RFC 8288 örneklerindeki
next,previousvestartgibi kısa küçük harfli adlardır ve büyük ve küçük harfe duyarsız karşılaştırılır. Kayıt dışı bir tür kullanmak isterseniz, kendi denetiminizdeki mutlak bir URI'yi uzantı türü olarak kullanırsınız. - İlişki türü medya türü değildir.
relyalnızca iki kaynağın nasıl ilişkili olduğunu söyler; hedefin hangi biçimde döneceğini söylemez. Bunun için isteğe bağlıtypeözniteliği bir ipucu olarak eklenebilir, ama gerçek yanıtınContent-Typedeğerini geçersiz kılmaz. - title ve hreflang ipucudur.
titlebağlantıya insanların okuyacağı bir etiket verir; ASCII dışı karakterler içintitle*biçimi kullanılır.
Örnekte gövdedeki anahtar prev, başlıktaki ilişki türü previous. Bu bilinçli bir ayrımdır: Gövdedeki anahtar adları sizin JSON sözleşmenizdir ve skill'in sayfalama örneği prev kullanır. Link başlığındaki değer ise bir ilişki türüdür; RFC 8288'in kendi örnekleri previous ve next kullanır. Hangisini seçerseniz seçin, belgelerinizde yazın ve her uç noktada aynı tutun.
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:
{
"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.
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ı?

| Ölçüt | Gövdede links nesnesi | Link başlığı (RFC 8288) |
|---|---|---|
| Standart | Biçim sizin sözleşmeniz | RFC 8288 ile tanımlı söz dizimi |
| Görünürlük | JSON ile birlikte okunur | Gövdeyi açmadan okunabilir |
| Liste öğeleri | Her öğeye ayrı bağlantı konabilir | Yanıtın bütünü için uygundur |
| Gövdesiz yanıtlar | Kullanılamaz | HEAD veya 204 ile de gönderilebilir |
| Duruma bağlı işlemler | Kaynağın yanında doğal durur | Mü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ğerindeurl, üçüncüsündehrefkullanmak 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
relher 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