🌐 Раздел 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/de
✅ Хорошо: /api/v1/users?status=ACTIVE&country=DE

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

  • 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. Необходимость валидации всей цепочки внешних ключей в 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 в нижнем регистре).

Связанные темы