HATEOAS
Giriş — Neden Bu Konu Önemli?
HATEOAS, REST mimarisinin en üst olgunluk seviyesidir (Richardson Maturity Model Level 3). İstemcinin bir kaynağı aldığında, o kaynakla yapabileceği tüm işlemlerin bağlantılarını (hypermedia links) da almasını sağlar. Bu sayede istemci, URL'leri hardcode etmek yerine API'nin dinamik olarak sunduğu linkleri takip eder.
Gerçek Hayat Analojisi: Bir web sitesini düşünün. Bir sayfayı açtığınızda o sayfadaki linklerle diğer sayfalara gidebilirsiniz — URL'leri ezberlemeniz gerekmez. HATEOAS, REST API'lere aynı deneyimi kazandırır. API bir "web sitesi" gibi davranır — her yanıtta ilgili sayfaların (endpoint'lerin) linklerini sunar. İstemci, bu linkleri takip ederek API'de "gezinir".
Geleneksel bir REST API'de istemci tüm endpoint URL'lerini bilmek zorundadır. Bu, API ile istemci arasında sıkı bir bağımlılık (tight coupling) yaratır. URL yapısı değiştiğinde tüm istemcilerin güncellenmesi gerekir. HATEOAS bu bağımlılığı kırar.
HATEOAS'lı vs HATEOAS'sız API
// ❌ HATEOAS olmadan — istemci URL'leri bilmek zorunda
GET /api/users/42
{
"id": 42,
"name": "Ali",
"email": "ali@example.com"
}
// İstemci "/api/users/42/orders" URL'ini kendisi oluşturmak zorunda!
// URL yapısı değişirse istemci bozulur.
// ✅ HATEOAS ile — API linklerle navigasyon sağlar
GET /api/users/42
{
"id": 42,
"name": "Ali",
"email": "ali@example.com",
"_links": {
"self": { "href": "http://localhost:8080/api/users/42" },
"orders": { "href": "http://localhost:8080/api/users/42/orders" },
"update": { "href": "http://localhost:8080/api/users/42" },
"all-users": { "href": "http://localhost:8080/api/users" }
}
}
// İstemci linkleri takip ederek navigasyon yapar
// URL yapısı değişse bile link'ler güncellenirSpring HATEOAS Kurulumu
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>Spring HATEOAS üç temel sınıf sunar:
| Sınıf | Amaç | Kullanım |
|---|---|---|
EntityModel<T> | Tek bir kaynağı linklerle sarar | GET /users/42 |
CollectionModel<T> | Kaynak koleksiyonlarını linklerle sarar | GET /users |
RepresentationModel | Kendi model sınıflarınız için temel sınıf | Özel modeller |
EntityModel ile Tekil Kaynak
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/{id}")
public EntityModel<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id);
return EntityModel.of(user,
// self link — bu kaynağın kendisine işaret eder
linkTo(methodOn(UserController.class).getUser(id))
.withSelfRel(),
// orders link — kullanıcının siparişlerine
linkTo(methodOn(OrderController.class).getOrdersByUser(id))
.withRel("orders"),
// all-users link — tüm kullanıcılar listesine
linkTo(methodOn(UserController.class).getAllUsers(Pageable.unpaged()))
.withRel("all-users")
);
}
}linkTo(methodOn(...)) Nasıl Çalışır?
linkTo(methodOn(...)) Spring HATEOAS'ın en güçlü özelliğidir. Controller metotlarına tip güvenli referans oluşturur:
// methodOn() → Controller'ın proxy'sini oluşturur
// linkTo() → Proxy çağrısından URL üretir
// withSelfRel() / withRel() → Link'e relation adı verir
linkTo(methodOn(UserController.class).getUser(42L))
// → "http://localhost:8080/api/users/42"
linkTo(methodOn(OrderController.class).getOrdersByUser(42L))
// → "http://localhost:8080/api/users/42/orders"💡 Avantaj: Eğer
@RequestMappingdeğişirse, linkler de otomatik olarak güncellenir — hardcode edilmiş string URL'ler yok. Refactoring güvenli.
Yanıt:
{
"id": 42,
"name": "Ali",
"email": "ali@example.com",
"_links": {
"self": {
"href": "http://localhost:8080/api/users/42"
},
"orders": {
"href": "http://localhost:8080/api/users/42/orders"
},
"all-users": {
"href": "http://localhost:8080/api/users"
}
}
}CollectionModel ile Koleksiyon
@GetMapping
public CollectionModel<EntityModel<UserDto>> getAllUsers(Pageable pageable) {
List<EntityModel<UserDto>> users = userService.findAll(pageable).stream()
.map(user -> EntityModel.of(user,
linkTo(methodOn(UserController.class).getUser(user.id()))
.withSelfRel(),
linkTo(methodOn(UserController.class).getAllUsers(pageable))
.withRel("all-users")))
.toList();
return CollectionModel.of(users,
linkTo(methodOn(UserController.class).getAllUsers(pageable)).withSelfRel());
}Yanıt:
{
"_embedded": {
"userDtoList": [
{
"id": 1,
"name": "Ali",
"_links": {
"self": { "href": "http://localhost:8080/api/users/1" },
"all-users": { "href": "http://localhost:8080/api/users" }
}
},
{
"id": 2,
"name": "Ayşe",
"_links": {
"self": { "href": "http://localhost:8080/api/users/2" },
"all-users": { "href": "http://localhost:8080/api/users" }
}
}
]
},
"_links": {
"self": { "href": "http://localhost:8080/api/users" }
}
}RepresentationModel Assembler Pattern
Her controller metodunda linkleri tekrar tekrar oluşturmak yerine, bir assembler sınıfı kullanmak çok daha temiz ve DRY bir yaklaşımdır:
@Component
public class UserModelAssembler
implements RepresentationModelAssembler<UserDto, EntityModel<UserDto>> {
@Override
public EntityModel<UserDto> toModel(UserDto user) {
EntityModel<UserDto> model = EntityModel.of(user,
linkTo(methodOn(UserController.class)
.getUser(user.id())).withSelfRel(),
linkTo(methodOn(OrderController.class)
.getOrdersByUser(user.id())).withRel("orders"),
linkTo(methodOn(UserController.class)
.getAllUsers(Pageable.unpaged())).withRel("all-users"));
// Koşullu linkler — duruma göre farklı aksiyonlar
if (user.active()) {
model.add(linkTo(methodOn(UserController.class)
.deactivateUser(user.id())).withRel("deactivate"));
} else {
model.add(linkTo(methodOn(UserController.class)
.activateUser(user.id())).withRel("activate"));
}
return model;
}
}Controller artık çok daha temiz:
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
private final UserModelAssembler assembler;
@GetMapping("/{id}")
public EntityModel<UserDto> getUser(@PathVariable Long id) {
UserDto user = userService.findById(id);
return assembler.toModel(user);
}
@GetMapping
public CollectionModel<EntityModel<UserDto>> getAllUsers(Pageable pageable) {
List<EntityModel<UserDto>> users = userService.findAll(pageable)
.stream()
.map(assembler::toModel)
.toList();
return CollectionModel.of(users,
linkTo(methodOn(UserController.class)
.getAllUsers(pageable)).withSelfRel());
}
@PostMapping
public ResponseEntity<EntityModel<UserDto>> createUser(
@Valid @RequestBody CreateUserDto dto) {
UserDto created = userService.create(dto);
EntityModel<UserDto> model = assembler.toModel(created);
return ResponseEntity
.created(model.getRequiredLink(IanaLinkRelations.SELF).toUri())
.body(model);
}
}Link Sınıfı ve IANA Relation Types
Link nesneleri href ve rel (relation) bilgisi taşır:
// self — kaynağın kendisi
linkTo(methodOn(UserController.class).getUser(id)).withSelfRel();
// İlişkili kaynaklar — custom relation
linkTo(methodOn(OrderController.class).getOrdersByUser(id))
.withRel("orders");
// IANA standart relation tipleri — sayfalama
linkTo(...).withRel(IanaLinkRelations.NEXT); // sonraki sayfa
linkTo(...).withRel(IanaLinkRelations.PREV); // önceki sayfa
linkTo(...).withRel(IanaLinkRelations.FIRST); // ilk sayfa
linkTo(...).withRel(IanaLinkRelations.LAST); // son sayfa
linkTo(...).withRel(IanaLinkRelations.COLLECTION); // koleksiyonKoşullu Link Ekleme
İş mantığına göre farklı linkler sunmak HATEOAS'ın en güçlü yönüdür:
EntityModel<OrderDto> model = EntityModel.of(order);
model.add(linkTo(methodOn(OrderController.class)
.getOrder(order.id())).withSelfRel());
// Sadece uygun durumlarda aksiyon linkleri ekle
switch (order.status()) {
case PENDING -> {
model.add(linkTo(methodOn(OrderController.class)
.confirmOrder(order.id())).withRel("confirm"));
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.id())).withRel("cancel"));
}
case CONFIRMED -> {
model.add(linkTo(methodOn(OrderController.class)
.shipOrder(order.id())).withRel("ship"));
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.id())).withRel("cancel"));
}
case SHIPPED -> {
model.add(linkTo(methodOn(OrderController.class)
.deliverOrder(order.id())).withRel("deliver"));
}
// DELIVERED ve CANCELLED durumlarında ek aksiyon yok
}Bu yaklaşım, frontend'in "hangi butonları göstermeli?" sorusunu API'nin kendisinin yanıtlamasını sağlar. Frontend, link yoksa butonu göstermez — iş mantığı backend'de kalır.
Sayfalama ile HATEOAS
Spring Data'nın PagedResourcesAssembler'ı, sayfalama linklerini otomatik olarak ekler:
@GetMapping
public PagedModel<EntityModel<UserDto>> getAllUsers(
@PageableDefault(size = 20) Pageable pageable,
PagedResourcesAssembler<UserDto> pagedAssembler) {
Page<UserDto> page = userService.findAll(pageable);
return pagedAssembler.toModel(page, assembler);
}Yanıt otomatik olarak sayfalama linkleri içerir:
{
"_embedded": {
"userDtoList": [...]
},
"_links": {
"first": { "href": "http://localhost:8080/api/users?page=0&size=20" },
"self": { "href": "http://localhost:8080/api/users?page=1&size=20" },
"next": { "href": "http://localhost:8080/api/users?page=2&size=20" },
"last": { "href": "http://localhost:8080/api/users?page=7&size=20" }
},
"page": {
"size": 20,
"totalElements": 150,
"totalPages": 8,
"number": 1
}
}HAL (Hypertext Application Language) Formatı
Spring HATEOAS varsayılan olarak HAL formatını kullanır. HAL, JSON tabanlı bir hypermedia formatıdır:
_links→ linkleri taşır_embedded→ gömülü kaynakları taşır
{
"id": 42,
"name": "Ali",
"_links": {
"self": { "href": "/api/users/42" },
"orders": { "href": "/api/users/42/orders" }
},
"_embedded": {
"latestOrder": {
"id": 7,
"total": 299.99,
"_links": {
"self": { "href": "/api/orders/7" }
}
}
}
}HAL Explorer
Spring HATEOAS ile birlikte HAL Explorer UI'ı da gelir — Swagger UI benzeri, HATEOAS API'leri keşfetmek için:
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-rest-hal-explorer</artifactId>
</dependency>http://localhost:8080/explorer adresinden API'nizi interaktif olarak keşfedebilirsiniz.
Ne Zaman HATEOAS Kullanmalı?
| Durum | HATEOAS Gerekli mi? |
|---|---|
| Public API (üçüncü parti istemciler) | ✅ Kesinlikle — API keşfedilebilirliği önemli |
| Microservices arası iletişim | ❌ Genellikle gereksiz — servisler birbirini tanır |
| Frontend-backend (React/Vue) | ⚠️ Tartışmalı — çoğu frontend takım kullanmaz |
| Mobile app API | ⚠️ Faydalı olabilir — koşullu aksiyonlar için |
| Enterprise/B2B API | ✅ Güçlü tavsiye — loose coupling önemli |
Gerçekçi bakış: HATEOAS teoride mükemmeldir ama pratikte çoğu API Level 2'de kalır. HATEOAS'ın en büyük faydası koşullu linkler (durum makinesi) ve API keşfedilebilirliğidir. Bu ihtiyaçlarınız yoksa Level 2 yeterlidir.
Gerçek Dünya Örneği: Sipariş Yönetimi API'si
Sipariş durumuna göre farklı aksiyonlar sunan tam bir HATEOAS örneği:
// Order Assembler — durum makinesine göre linkler
@Component
public class OrderModelAssembler
implements RepresentationModelAssembler<OrderDto, EntityModel<OrderDto>> {
@Override
public EntityModel<OrderDto> toModel(OrderDto order) {
EntityModel<OrderDto> model = EntityModel.of(order);
// Her zaman: self ve user linkleri
model.add(linkTo(methodOn(OrderController.class)
.getOrder(order.id())).withSelfRel());
model.add(linkTo(methodOn(UserController.class)
.getUser(order.userId())).withRel("customer"));
// Durum bazlı aksiyonlar
switch (order.status()) {
case PENDING -> {
model.add(linkTo(methodOn(OrderController.class)
.confirmOrder(order.id())).withRel("confirm"));
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.id())).withRel("cancel"));
model.add(linkTo(methodOn(OrderController.class)
.updateOrder(order.id(), null)).withRel("update"));
}
case CONFIRMED -> {
model.add(linkTo(methodOn(OrderController.class)
.shipOrder(order.id())).withRel("ship"));
model.add(linkTo(methodOn(OrderController.class)
.cancelOrder(order.id())).withRel("cancel"));
}
case SHIPPED -> {
model.add(linkTo(methodOn(OrderController.class)
.deliverOrder(order.id())).withRel("deliver"));
model.add(linkTo(methodOn(OrderController.class)
.trackOrder(order.id())).withRel("tracking"));
}
case DELIVERED -> {
model.add(linkTo(methodOn(OrderController.class)
.returnOrder(order.id())).withRel("return"));
model.add(linkTo(methodOn(ReviewController.class)
.createReview(order.id(), null)).withRel("review"));
}
case CANCELLED -> {
model.add(linkTo(methodOn(OrderController.class)
.reorder(order.id())).withRel("reorder"));
}
}
// Items linki — her zaman
model.add(linkTo(methodOn(OrderItemController.class)
.getItems(order.id())).withRel("items"));
return model;
}
}Frontend Kullanımı
// React frontend — HATEOAS linkleri kullanarak
function OrderActions({ order }) {
const links = order._links;
return (
<div>
{links.confirm && (
<button onClick={() => fetch(links.confirm.href, { method: 'POST' })}>
Siparişi Onayla
</button>
)}
{links.cancel && (
<button onClick={() => fetch(links.cancel.href, { method: 'POST' })}>
İptal Et
</button>
)}
{links.ship && (
<button onClick={() => fetch(links.ship.href, { method: 'POST' })}>
Kargoya Ver
</button>
)}
{links.tracking && (
<a href={links.tracking.href}>Kargom Nerede?</a>
)}
{links.review && (
<button onClick={() => navigate(links.review.href)}>
Değerlendir
</button>
)}
</div>
);
}
// Frontend hiçbir URL hardcode etmiyor!
// İş mantığı tamamen backend'de — frontend sadece link var mı diye bakıyor.Bu yaklaşımın güzelliği: sipariş durumlarına yeni bir geçiş eklediğinizde (örneğin "PROCESSING" durumu), sadece backend'deki assembler'ı güncellemeniz yeterli. Frontend kodu değişmez — yeni link otomatik olarak render edilir.
Test Yazma
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private UserService userService;
@Autowired
private UserModelAssembler assembler;
@Test
void shouldReturnUserWithLinks() throws Exception {
UserDto user = new UserDto(42L, "Ali", "ali@example.com", true);
when(userService.findById(42L)).thenReturn(user);
mockMvc.perform(get("/api/users/42"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.name").value("Ali"))
.andExpect(jsonPath("$._links.self.href").exists())
.andExpect(jsonPath("$._links.orders.href").exists())
.andExpect(jsonPath("$._links.all-users.href").exists())
// Aktif kullanıcı — deactivate linki olmalı
.andExpect(jsonPath("$._links.deactivate.href").exists())
.andExpect(jsonPath("$._links.activate").doesNotExist());
}
@Test
void shouldReturnCollectionWithSelfLink() throws Exception {
when(userService.findAll(any())).thenReturn(
new PageImpl<>(List.of(
new UserDto(1L, "Ali", "ali@example.com", true),
new UserDto(2L, "Ayşe", "ayse@example.com", true)
)));
mockMvc.perform(get("/api/users"))
.andExpect(status().isOk())
.andExpect(jsonPath("$._embedded.userDtoList").isArray())
.andExpect(jsonPath("$._embedded.userDtoList.length()").value(2))
.andExpect(jsonPath("$._links.self.href").exists());
}
}Özet
HATEOAS (Richardson Level 3) API yanıtlarına navigasyon linkleri ekler — istemci URL hardcode etmez
EntityModel tekil kaynak, CollectionModel koleksiyon, PagedModel sayfalı koleksiyon için kullanılır
linkTo(methodOn(...)) tip güvenli link oluşturur — URL mapping değişirse linkler otomatik güncellenir
RepresentationModelAssembler link oluşturma mantığını tek yerde toplar — DRY prensibine uygun
Koşullu linkler HATEOAS'ın en güçlü yönüdür — iş durumuna göre farklı aksiyonlar sunulur
PagedResourcesAssembler sayfalama linklerini (first, prev, next, last) otomatik ekler
HATEOAS teoride mükemmel, pratikte koşullu aksiyonlar ve public API'ler için değerlidir
AI Asistan
Sorularını yanıtlamaya hazır