Skip to content

ADR-002: Структура REST API

Дата: 2026-01-04
Статус: Принято
Авторы: Backend Team


Контекст

Проект требует API для взаимодействия между Nuxt 3 фронтендом и Medusa.js бэкендом. Необходимо определить:

  • Формат и структуру эндпоинтов
  • Версионирование API
  • Формат ответов и ошибок
  • Пагинацию и фильтрацию

Требования из ТЗ

Согласно общее.md, API должен поддерживать:

  • Year/Make/Model фильтры (критическая фича)
  • Поиск по OEM номерам
  • Геолокация и фильтрация по радиусу
  • Multi-vendor функционал
  • Публичные и приватные эндпоинты

Рассмотренные альтернативы

ПодходЗаПротив
REST (Medusa native)Встроен в Medusa, простотаOverfetching для сложных запросов
GraphQLГибкость запросов, типизацияСложность, отход от Medusa
REST + GraphQLГибкостьДублирование, сложность поддержки

Решение

Используем REST API на базе встроенного в Medusa.js роутинга с расширением для кастомных модулей.

Структура эндпоинтов

API Base URL: https://api.partizap.ru

├── /store/*          # Публичные эндпоинты (покупатели)
├── /admin/*          # Админские эндпоинты (модераторы)
└── /vendor/*         # Эндпоинты продавцов (новый scope)

Эндпоинты по модулям

Справочники (публичные)

GET  /store/cars/makes                    # Список марок
GET  /store/cars/makes/:slug              # Марка по slug
GET  /store/cars/makes/:makeId/models     # Модели марки
GET  /store/cars/models/:modelId/generations  # Поколения модели

GET  /store/geo/regions                   # Регионы
GET  /store/geo/cities?region_id=1        # Города региона
GET  /store/geo/cities/:cityId/districts  # Районы города
GET  /store/geo/cities/:cityId/metro      # Метро города
GET  /store/geo/detect                    # Определение города по IP

GET  /store/categories                    # Дерево категорий
GET  /store/categories/:slug              # Категория по slug

Товары

GET  /store/products                      # Список с фильтрами
GET  /store/products/:id                  # Карточка товара
GET  /store/products/search               # Полнотекстовый поиск
GET  /store/products/by-oem/:oem          # Поиск по OEM

POST /vendor/products                     # Создание товара
PUT  /vendor/products/:id                 # Обновление товара
DELETE /vendor/products/:id               # Удаление товара
POST /vendor/products/:id/images          # Загрузка фото

Пользователи и профили

GET  /store/sellers/:id                   # Публичный профиль продавца
GET  /store/sellers/:id/products          # Товары продавца
GET  /store/sellers/:id/reviews           # Отзывы о продавце

GET  /vendor/me                           # Профиль текущего продавца
PUT  /vendor/me                           # Обновление профиля
GET  /vendor/me/products                  # Мои товары
GET  /vendor/me/stats                     # Статистика

Коммуникации

GET  /store/conversations                 # Мои диалоги
POST /store/conversations                 # Начать диалог
GET  /store/conversations/:id/messages    # Сообщения диалога
POST /store/conversations/:id/messages    # Отправить сообщение

POST /store/products/:id/report           # Жалоба на товар
POST /store/reviews                       # Оставить отзыв

Формат ответов

Успешный ответ (единичный объект)

json
{
  "data": {
    "id": "prod_01H...",
    "title": "Фара BMW E46 левая",
    "price": 15000,
    "steering": "left",
    "categories": [
      { "id": "cat_01", "name": "Кузов", "type": "part" }
    ],
    "compatibility": [
      { "make": "BMW", "model": "3-series", "generation": "E46" }
    ]
  }
}

Успешный ответ (коллекция с пагинацией)

json
{
  "data": [
    { "id": "prod_01", "title": "..." },
    { "id": "prod_02", "title": "..." }
  ],
  "meta": {
    "total": 1250,
    "page": 1,
    "per_page": 20,
    "last_page": 63
  },
  "links": {
    "self": "/store/products?page=1",
    "next": "/store/products?page=2",
    "last": "/store/products?page=63"
  }
}

Ответ с ошибкой

json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Ошибка валидации данных",
    "details": [
      { "field": "price", "message": "Цена должна быть положительной" },
      { "field": "title", "message": "Название обязательно" }
    ]
  }
}

HTTP коды ответов

КодИспользование
200Успешный GET, PUT, PATCH
201Успешный POST (создание)
204Успешный DELETE
400Ошибка валидации
401Не авторизован
403Доступ запрещён
404Ресурс не найден
422Бизнес-логика ошибка
429Rate limit exceeded
500Внутренняя ошибка

Фильтрация и поиск

Query-параметры для /store/products

GET /store/products?
  # Совместимость с авто
  make_id=1&
  model_id=5&
  generation_id=12&
  
  # Категории (множественный выбор)
  category_id[]=3&category_id[]=7&
  
  # Состояние и тип
  condition=used&           # new, used, refurbished
  steering=left&            # left, right, universal
  
  # Цена
  price_min=1000&
  price_max=50000&
  
  # Геолокация
  city_id=1&
  district_id[]=5&district_id[]=8&
  radius_km=50&             # требует lat, lon
  lat=59.9343&
  lon=30.3351&
  
  # OEM
  oem=51117030619&
  
  # Продавец
  seller_id=user_01&
  seller_rating_min=4&
  
  # Сортировка
  sort=price_asc&           # price_asc, price_desc, date_desc, relevance
  
  # Пагинация
  page=1&
  per_page=20

Пример сложного запроса

GET /store/products?make_id=1&model_id=5&category_id[]=3&condition=used&steering=left&city_id=1&price_max=30000&sort=date_desc&per_page=20

Реализация в Medusa.js

Структура файлов

src/api/
├── store/
│   ├── cars/
│   │   ├── makes/
│   │   │   ├── route.ts              # GET /store/cars/makes
│   │   │   └── [slug]/route.ts       # GET /store/cars/makes/:slug
│   │   └── models/
│   │       └── [modelId]/
│   │           └── generations/route.ts
│   ├── products/
│   │   ├── route.ts                  # GET, POST
│   │   ├── [id]/route.ts             # GET, PUT, DELETE
│   │   └── search/route.ts           # GET
│   └── geo/
│       └── detect/route.ts           # GET
├── vendor/
│   ├── products/
│   │   └── route.ts
│   └── me/
│       └── route.ts
└── middlewares.ts                    # Auth, validation

Пример роута

typescript
// src/api/store/products/route.ts
import { MedusaRequest, MedusaResponse } from "@medusajs/framework";
import { ProductService } from "../../../modules/products/service";

export async function GET(
  req: MedusaRequest,
  res: MedusaResponse
): Promise<void> {
  const productService = req.scope.resolve<ProductService>("productService");
  
  const filters = {
    make_id: req.query.make_id as string,
    model_id: req.query.model_id as string,
    category_ids: req.query.category_id as string[],
    price_min: Number(req.query.price_min) || undefined,
    price_max: Number(req.query.price_max) || undefined,
    condition: req.query.condition as string,
    steering: req.query.steering as string,
    city_id: req.query.city_id as string,
  };
  
  const pagination = {
    page: Number(req.query.page) || 1,
    per_page: Math.min(Number(req.query.per_page) || 20, 100),
  };
  
  const { data, meta } = await productService.list(filters, pagination);
  
  res.json({ data, meta });
}

Версионирование

Стратегия: URL-based versioning (отложено)

Для MVP версионирование не требуется. При необходимости:

/v1/store/products    # Текущая версия
/v2/store/products    # Новая версия с breaking changes

Header-based версионирование (альтернатива)

Accept: application/vnd.autoparts.v1+json

Rate Limiting

Согласно общее.md, Nginx обеспечивает rate limiting:

nginx
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=20r/s;
limit_req zone=api_limit burst=40 nodelay;

Дополнительно в приложении:

ЭндпоинтЛимитОкно
/store/* (GET)100 req1 min
/store/* (POST)20 req1 min
/vendor/*60 req1 min
/store/geo/detect10 req1 min

Последствия

Положительные

  • Простота: REST понятен, отлично документируется
  • Кэширование: HTTP-кэширование работает из коробки
  • Совместимость: Nuxt useFetch работает нативно
  • Medusa: использование встроенного роутинга

Отрицательные

  • Overfetching: клиент получает все поля, даже ненужные
  • N+1: требуется внимательность при загрузке связей
  • Множественные запросы: для сложных страниц нужно несколько запросов

Митигация

ПроблемаРешение
OverfetchingПараметр fields для выбора полей
N+1Eager loading в ORM, Redis кэш
Множественные запросыКомпозитные эндпоинты для критичных страниц

Связанные решения