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

Як правильно іменувати REST endpoints

Іменування REST ендпоінтів (URIs) формує інтуїтивний та передбачуваний інтерфейс вашої системи. У RESTful архітектурі URI ідентифікує ресурс (іменник), а дія над ним визначаєтьс...


🟢 Junior Level

Іменування REST ендпоінтів (URIs) формує інтуїтивний та передбачуваний інтерфейс вашої системи. У RESTful архітектурі URI ідентифікує ресурс (іменник), а дія над ним визначається HTTP-методом.

Базові правила іменування

  1. Використовуйте іменники, а не дієслова:
    • ✅ GET /api/v1/users (отримати користувачів)
    • ❌ GET /api/v1/getUsers або POST /api/v1/createUser
  2. Використовуйте множину для колекцій:
    • Колекція представляє набір об’єктів: /users, /orders, /products.
    • Конкретний елемент колекції адресується за ID: /users/42, /orders/105.
    • ❌ Використання однини (/user/42) створює плутанину під час звернення до списку.
  3. Використовуйте kebab-case та нижній регістр (RFC 3986):
    • URL чутливі до регістру на більшості серверів (Linux, NGINX).
    • ✅ /user-profiles, /audit-logs
    • ❌ /user_profiles (snake_case) або /userProfiles (camelCase)
  4. Відображайте ієрархію зв’язків (Sub-resources):
    • Ресурс, що належить іншому ресурсу:
    • ✅ GET /api/v1/users/42/orders (усі замовлення користувача 42)
    • ✅ GET /api/v1/users/42/orders/10 (замовлення 10 користувача 42)

🟡 Middle Level

Розподіл відповідальності: Path Variable vs Query Parameter

Поширена помилка проєктування — розміщення параметрів пошуку та фільтрації безпосередньо в ієрархії URI:

❌ Погано:  /api/v1/users/status/active/country/ua
✅ Добре:   /api/v1/users?status=ACTIVE&country=UA

Правило вибору:

  • Path Variable (@PathVariable): Використовується суворо для унікальної ідентифікації ресурсу або його власника в ієрархії (/users/{id}, /departments/{deptId}/employees).
  • Query Parameter (@RequestParam): Використовується для фільтрації, сортування, пагінації та пошуку (?status=ACTIVE&sort=createdAt,desc&page=0&size=20).

Як проєктувати бізнес-дії, що не вкладаються в CRUD?

У реальних проєктах часто виникають дії, які складно звести до простого оновлення полів: «відправити на модерацію», «скасувати замовлення», «заблокувати користувача».

Існує 3 загальноприйнятих REST-патерни:

1. Модифікація стану через PATCH (Рекомендовано для простих переходів станів)

PATCH /api/v1/orders/100 HTTP/1.1
Content-Type: application/json

{
  "status": "CANCELLED",
  "reason": "Customer request"
}

2. Створення підпорядкованого ресурсу-дії (Action Sub-resource)

Дія розглядається як доменна подія або факт створення бізнес-запису:

POST /api/v1/orders/100/cancellations HTTP/1.1
POST /api/v1/users/42/password-resets HTTP/1.1

3. Контролер команд (RPC-style підресурс через двокрапку або дієслово)

Допустимо у складних сценаріях відповідно до Google Cloud API Design Guide:

POST /api/v1/orders/100:cancel HTTP/1.1
POST /api/v1/documents/50:publish HTTP/1.1

Проблема завершального слеша (Trailing Slash)

За специфікацією RFC 3986 адреси /users та /users/ є двома різними URI. У Spring Boot 3 обробку TrailingSlashMatch за замовчуванням вимкнено для запобігання дублюванню маршрутів та вразливостям безпеки. Запит /users/ за налаштованого /users поверне 404 Not Found (або 308 Permanent Redirect).


🔴 Senior Level

Глибина вкладеності та антипатерн “URI Deep Nesting”

Створення глибоких ланцюжків ресурсів вважається серйозним антипатерном:

❌ /api/v1/companies/1/departments/5/teams/12/projects/99/tasks/1001

Проблеми глибокої вкладеності:

  1. Роздування довжини URL та ускладнення клієнтської інтеграції.
  2. Необхідність валідації всього ланцюжка зовнішніх ключів (Foreign Keys) у SQL-запитах.
  3. Проблема авторизації: потрібно окремо перевірити права на компанію 1, відділ 5, команду 12 та проєкт 99.

Senior-рішення: Плоский дизайн (Flat Resource URLs) Якщо ідентифікатор дочірнього ресурсу є глобально унікальним (UUID або унікальний Primary Key), обмежуйте вкладеність не більше ніж 2 рівнями:

✅ GET  /api/v1/projects/99/tasks   (список завдань конкретного проєкту)
✅ GET  /api/v1/tasks/1001          (доступ до конкретного завдання напряму)
✅ PUT  /api/v1/tasks/1001          (оновлення завдання напряму)

Захист від IDOR через псевдоресурс /me

У веб- та мобільних додатках передача ID авторизованого користувача в URL створює вразливість IDOR (Insecure Direct Object Reference), якщо розробник забув перевірити відповідність переданого {userId} поточному JWT токену.

Архітектурне рішення: Для даних поточного авторизованого користувача використовуйте ресурс /me:

GET /api/v1/users/me
GET /api/v1/users/me/orders
GET /api/v1/users/me/cart

Сервер витягує ідентифікатор користувача безпосередньо з контексту безпеки (SecurityContextHolder), виключаючи можливість підміни ID клієнтом.

Реалізація RESTful контролера у Spring Boot 3

package com.example.controller;

import com.example.dto.OrderResponseDto;
import com.example.dto.UserResponseDto;
import com.example.service.OrderService;
import com.example.service.UserService;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.*;

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

    private final UserService userService;
    private final OrderService orderService;

    public ApiController(UserService userService, OrderService orderService) {
        this.userService = userService;
        this.orderService = orderService;
    }

    // 1. Безпечне отримання профілю поточного користувача (/me)
    @GetMapping("/users/me")
    public ResponseEntity<UserResponseDto> getCurrentUser(@AuthenticationPrincipal Jwt jwt) {
        Long currentUserId = Long.valueOf(jwt.getSubject());
        return ResponseEntity.ok(userService.findById(currentUserId));
    }

    // 2. Вкладений ресурс: отримання замовлень конкретного користувача
    @GetMapping("/users/{userId}/orders")
    public ResponseEntity<Page<OrderResponseDto>> getUserOrders(
            @PathVariable Long userId,
            @RequestParam(required = false, defaultValue = "COMPLETED") String status,
            Pageable pageable) {
        return ResponseEntity.ok(orderService.findOrdersByUserId(userId, status, pageable));
    }

    // 3. Плоский доступ до конкретного замовлення (глибина = 1 рівень)
    @GetMapping("/orders/{orderId}")
    public ResponseEntity<OrderResponseDto> getOrderById(@PathVariable Long orderId) {
        return ResponseEntity.ok(orderService.findById(orderId));
    }
}

Стандарт RFC 9110: Помилка 405 Method Not Allowed

Якщо ресурс /api/v1/users/42 підтримує лише GET, PUT, DELETE, а клієнт надіслав POST /api/v1/users/42:

  • Сервер не має права повертати 404 Not Found.
  • Сервер зобов’язаний повернути 405 Method Not Allowed із заголовком:
    HTTP/1.1 405 Method Not Allowed
    Allow: GET, PUT, DELETE, OPTIONS
    

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

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

  • Іменники у множині: /users, /orders, /products. Ресурс — сутність, дія — HTTP-метод.
  • Kebab-case та lowercase: Виключно рядкові літери з дефісами (/user-profiles).
  • Ієрархія ресурсів: /users/{userId}/orders, але глибина вкладеності не повинна перевищувати 2 рівнів (для дочірніх ресурсів з унікальним ID використовується плоский URL: /orders/{orderId}).
  • Query-параметри: Фільтрація, пагінація та сортування передаються через query (/orders?status=NEW&page=0), а не через сегменти шляху.
  • Захист від IDOR: Використання ендпоінта /me (/users/me/settings) для ресурсів поточного користувача.
  • Якщо ресурс існує, але викликано непідтримуваний HTTP-метод — повертається 405 Method Not Allowed з обов’язковим заголовком Allow.

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

  1. Чому в іменуванні ресурсів використовується множина (/users), а не однина (/user)? Відповідь: Сегмент URI позначає колекцію об’єктів предметної області. GET /users повертає всю колекцію, а GET /users/42 вказує на конкретний елемент цієї колекції. Використання множини забезпечує сувору ортогональність та однаковість схеми API.

  2. Як у REST API спроєктувати ендпоінт для переказу грошей з рахунку на рахунок без використання дієслова transferMoney? Відповідь: Переказ коштів оформлюється як створення ресурсу транзакції: POST /api/v1/transfers з тілом запиту {"sourceAccountId": 1, "targetAccountId": 2, "amount": 1000}. Відповідь: 201 Created зі створеним об’єктом переказу та його статусом.

  3. Що робити, якщо ієрархія сутностей налічує 4–5 рівнів (компанія -> філія -> відділ -> команда -> завдання)? Відповідь: Застосовувати патерн плоских URL (Flat URLs). Вкладені ендпоінти залишати лише для створення та списків: GET /teams/{teamId}/tasks, а для маніпуляцій з конкретним завданням звертатися напряму через його ідентифікатор: GET /tasks/{taskId}, PUT /tasks/{taskId}.

  4. Що зобов’язаний повернути сервер, якщо клієнт надіслав POST на ендпоінт, що підтримує лише GET? Відповідь: Сервер зобов’язаний повернути статус 405 Method Not Allowed і обов’язково включити заголовок відповіді Allow зі списком підтримуваних методів (Allow: GET, HEAD, OPTIONS), згідно з RFC 9110 §15.5.6. Повернення коду 404 у такій ситуації є грубою архітектурною помилкою.

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

  • ❌ «В URL можна використовувати дієслова, якщо це RPC або складна дія» (у REST дія завжди задається HTTP-методом або моделюється через ресурс).
  • ❌ «Якщо метод не підтримується, контролер повинен повертати 404 Not Found» (повинен повертатися 405 Method Not Allowed із заголовком Allow).
  • ❌ «Параметри фільтрації краще робити частиною шляху URI, наприклад /users/active/admins» (фільтри мають бути Query Parameters).
  • ❌ «Для URL можна вільно змішувати camelCase і snake_case» (стандартом для URI є kebab-case у нижньому регістрі).

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