🌐 Розділ 6 · Питання #14

Що таке HATEOAS

Простими словами: сервер повертає клієнту не лише «сухі» дані, але й карту допустимих подальших дій у вигляді посилань (URIs).


🟢 Junior Level

HATEOAS (Hypermedia As The Engine Of Application State) — це обмеження архітектурного стилю REST, згідно з яким клієнт взаємодіє з мережевим застосунком виключно через гіпермедіа (посилання), які сервер динамічно надає у відповідях.

Простими словами: сервер повертає клієнту не лише «сухі» дані, але й карту допустимих подальших дій у вигляді посилань (URIs).

Аналогія з веббраузером

Коли ви користуєтеся інтернет-банком через браузер:

  • Ви не вводите в адресний рядок URL /accounts/101/transfer-funds вручну.
  • Ви бачите залишок на рахунку і натискаєте кнопку з посиланням «Переказати кошти».
  • Якщо у вас немає прав на переказ або рахунок заморожено, кнопка (посилання) просто відсутня на сторінці.

HATEOAS переносить цей самий принцип людино-машинної взаємодії вебсайтів на рівень програмної взаємодії між сервером та API-клієнтом.

Порівняння відповідей API

1. Стандартний REST (Level 2):

{
  "orderId": 501,
  "status": "AWAITING_PAYMENT",
  "amount": 2500
}

Мінус: Клієнт зобов’язаний заздалегідь жорстко захардкодити у своєму коді URL для оплати (/api/v1/orders/501/payments) та самостійно перевіряти, у яких статусах оплата дозволена.

2. REST з підтримкою HATEOAS (Level 3 HAL):

{
  "orderId": 501,
  "status": "AWAITING_PAYMENT",
  "amount": 2500,
  "_links": {
    "self": { "href": "/api/v1/orders/501" },
    "payment": { "href": "/api/v1/orders/501/payments", "type": "POST" },
    "cancellation": { "href": "/api/v1/orders/501/cancellations", "type": "POST" }
  }
}

Плюс: Клієнт перевіряє наявність ключа payment в об’єкті _links. Якщо замовлення вже оплачено, сервер просто не поверне посилання payment, і клієнт автоматично приховає кнопку оплати.


🟡 Middle Level

Концепція можливостей (Affordances) та State Machine

У HATEOAS посилання представляють собою Affordances (доступні можливості):

  • Сервер виступає в ролі скінченного автомата (State Machine).
  • Переходи між станами бізнес-сутності визначаються сервером, а не клієнтом.
  • Керування доступом (RBAC/ABAC): Якщо в поточного користувача немає ролі адміністратора, посилання "delete": { "href": "..." } просто не включається у відповідь. Клієнту не потрібно дублювати складну матрицю прав доступу в коді мобільного чи вебдодатка: достатньо перевірити if (links.delete) renderDeleteButton().

Формати гіпермедіа: HAL та JSON:API

Для стандартизації структури посилань у REST API були розроблені спеціалізовані специфікації:

1. HAL (Hypertext Application Language) — application/hal+json

Найпопулярніший і найкомпактніший формат в екосистемі Java та Spring:

  • Кореневе поле _links містить словник відношень (rel) та посилань (href).
  • Поле _embedded містить вкладені колекції ресурсів (щоб уникнути окремих HTTP-запитів).

2. JSON:API — application/vnd.api+json

Більш суворий стандарт, що регламентує структуру об’єктів data, attributes, relationships та links.

Проблема роботи за Reverse Proxy (Forwarded Headers)

У мікросервісній архітектурі Spring Boot сервіс зазвичай розташований за балансувальником навантаження або API Gateway (NGINX, Envoy, Kubernetes Ingress):

  • Сервер генерує посилання на основі свого локального контексту: http://10.244.1.15:8080/orders/501.
  • Якщо відправити такий URL клієнту, перехід за посиланням завершиться помилкою мережі.

Рішення:

  1. Проксі-сервер зобов’язаний проставляти стандартні заголовки RFC 7239:
    • X-Forwarded-Host: api.example.com
    • X-Forwarded-Proto: https
    • X-Forwarded-Port: 443
    • X-Forwarded-Prefix: /api/v1
  2. У Spring Boot необхідно активувати фільтр:
    server.forward-headers-strategy=FRAMEWORK
    

    (реєструє ForwardedHeaderFilter, який автоматично підміняє хост і схему у WebMvcLinkBuilder).


🔴 Senior Level

Чому індустрія практично відмовилася від HATEOAS?

На співбесідах рівня Senior важливо розуміти не лише теоретичні ідеї Роя Філдінга, але й прагматичні причини, чому в 95% реальних проєктів HATEOAS не використовується:

  1. Невідповідність сучасному Frontend/Mobile UI: Сучасні SPA (React, Angular) та мобільні додатки (iOS/Android) створюються на основі жорстких макетів інтерфейсу (Figma). Розробник інтерфейсу не може довільно перебудовувати екран лише тому, що з сервера надійшов новий невідомий rel.
  2. Роздування обсягу переданих даних (Payload Overhead): Для одиничного об’єкта оверхед становить 30–50%. Для списків із сотень елементів (колекцій) обсяг JSON збільшується в 2–3 рази виключно через дублювання URL-рядків у кожному об’єкті.
  3. Навантаження на CPU (Reflection Overhead): Динамічна генерація посилань у RepresentationModelAssembler через WebMvcLinkBuilder використовує рефлексію та резолвінг методів контролерів, що створює помітне навантаження за високого RPS.
  4. Перемога OpenAPI / TypeScript Generator: Клієнтським розробникам значно зручніше згенерувати суворо типізований SDK за схемою OpenAPI 3.1, ніж парсити динамічний граф посилань у runtime.

Практична реалізація на Spring HATEOAS (Spring Boot 3)

Для якісної архітектури розділення доменних моделей і HATEOAS-представлень використовується RepresentationModelAssembler:

package com.example.dto;

import com.example.controller.OrderController;
import com.example.entity.Order;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

@Component
public class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<OrderDto>> {

    @Override
    public EntityModel<OrderDto> toModel(Order order) {
        OrderDto dto = new OrderDto(order.getId(), order.getStatus(), order.getTotalAmount());

        // Базове посилання на самого себе (self)
        EntityModel<OrderDto> orderModel = EntityModel.of(dto,
                linkTo(methodOn(OrderController.class).getOrderById(order.getId())).withSelfRel()
        );

        // Динамічне додавання посилань на основі стану скінченного автомата
        if ("AWAITING_PAYMENT".equals(order.getStatus())) {
            orderModel.add(
                    linkTo(methodOn(OrderController.class).payOrder(order.getId())).withRel("payment"),
                    linkTo(methodOn(OrderController.class).cancelOrder(order.getId())).withRel("cancellation")
            );
        } else if ("PAID".equals(order.getStatus())) {
            orderModel.add(
                    linkTo(methodOn(OrderController.class).getDeliveryStatus(order.getId())).withRel("delivery")
            );
        }

        return orderModel;
    }
}
package com.example.controller;

import com.example.dto.OrderDto;
import com.example.dto.OrderModelAssembler;
import com.example.entity.Order;
import com.example.service.OrderService;
import org.springframework.hateoas.EntityModel;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final OrderService orderService;
    private final OrderModelAssembler assembler;

    public OrderController(OrderService orderService, OrderModelAssembler assembler) {
        this.orderService = orderService;
        this.assembler = assembler;
    }

    @GetMapping(value = "/{id}", produces = "application/hal+json")
    public ResponseEntity<EntityModel<OrderDto>> getOrderById(@PathVariable Long id) {
        Order order = orderService.findById(id);
        return ResponseEntity.ok(assembler.toModel(order));
    }

    @PostMapping("/{id}/payments")
    public ResponseEntity<Void> payOrder(@PathVariable Long id) {
        orderService.processPayment(id);
        return ResponseEntity.noContent().build();
    }

    @PostMapping("/{id}/cancellations")
    public ResponseEntity<Void> cancelOrder(@PathVariable Long id) {
        orderService.cancel(id);
        return ResponseEntity.noContent().build();
    }

    @GetMapping("/{id}/delivery")
    public ResponseEntity<String> getDeliveryStatus(@PathVariable Long id) {
        return ResponseEntity.ok("IN_TRANSIT");
    }
}

Кешування HATEOAS та заголовок Vary

Якщо сервер динамічно генерує посилання залежно від прав авторизованого користувача (адміністратор бачить посилання rel="delete", а звичайний користувач — ні), відповідь не можна кешувати публічно без зазначення заголовка:

Cache-Control: private, max-age=300
Vary: Authorization

Інакше проміжний кеш (CDN / NGINX) віддасть відповідь із правами адміністратора звичайному користувачу.


🎯 Шпаргалка для інтерв’ю

30-секундна відповідь

HATEOAS (Level 3 моделі зрілості Річардсона) — це принцип REST, за якого сервер динамічно повертає клієнту посилання на доступні операції разом із тілом ресурсу.

Це дозволяє клієнту орієнтуватися в API без жорстко зашитих URL (decoupling) та реалізує скінченний автомат (State Machine): перелік доступних дій змінюється залежно від стану сутності та прав доступу користувача. Стандартом де-факто для розмітки посилань є формат HAL (_links).

На практиці HATEOAS застосовується рідко через роздування мережевого трафіку (у 2–3 рази для списків), навантаження на CPU і те, що фронтенд-командам значно зручніше працювати зі статичними контрактами OpenAPI.

4 каверзних питання з відповідями

  1. Що таке Affordance у контексті HATEOAS? Відповідь: Це гіпермедіа-підказка сервера про дію, яку клієнт може виконати просто зараз із поточного стану ресурсу. Якщо посилання з певним rel відсутнє в блоці _links, це означає, що операція наразі недоступна для ресурсу або заборонена для прав поточного користувача.

  2. Яка поширена проблема виникає з посиланнями HATEOAS під час деплою за NGINX або Kubernetes Ingress і як вона вирішується? Відповідь: Проблема спотворення URL: Spring HATEOAS бачить внутрішній IP пода (10.244.x.x:8080) і генерує посилання на внутрішню адресу за протоколом http. Вирішується налаштуванням проксіювання заголовків X-Forwarded-* та увімкненням у Spring Boot налаштування server.forward-headers-strategy=FRAMEWORK (ForwardedHeaderFilter).

  3. Чому HATEOAS вважається неефективним для високонавантажених систем (Highload)? Відповідь: По-перше, посилання роздувають розмір мережевої відповіді на 30–200%, перевантажуючи смугу пропускання. По-друге, побудова графа посилань на кожному вузлі через рефлексію створює додатковий оверхед на CPU та Garbage Collector під час формування відповідей для великих колекцій.

  4. Як коректно кешувати HATEOAS відповіді, якщо набір посилань залежить від ролі користувача? Відповідь: Відповіді повинні позначатися директивою Cache-Control: private (заборона кешування на публічних CDN/проксі) та обов’язково містити заголовок Vary: Authorization, щоб кеш браузера не використовував посилання одного облікового запису для іншого.

Червоні прапорці (чого категорично не можна говорити)

  • ❌ «API не можна називати REST, якщо в ньому немає HATEOAS» (в індустрії Level 2 моделі Річардсона вважається повноцінним і достатнім REST API).
  • ❌ «HATEOAS прискорює роботу застосунку та економить мережевий трафік» (навпаки, суттєво збільшує розмір payload і навантаження на CPU).
  • ❌ «HATEOAS потрібен у всіх мобільних додатках» (у мобільній розробці він практично не використовується через жорстку верстку UI під конкретні сценарії).
  • ❌ «Посилання в HATEOAS формуються вручну конкатенацією рядків у контролері» (у Spring використовується WebMvcLinkBuilder та RepresentationModelAssembler).

Пов’язані теми