Appearance
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 | Бизнес-логика ошибка |
| 429 | Rate 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 changesHeader-based версионирование (альтернатива)
Accept: application/vnd.autoparts.v1+jsonRate 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 req | 1 min |
/store/* (POST) | 20 req | 1 min |
/vendor/* | 60 req | 1 min |
/store/geo/detect | 10 req | 1 min |
Последствия
Положительные
- Простота: REST понятен, отлично документируется
- Кэширование: HTTP-кэширование работает из коробки
- Совместимость: Nuxt useFetch работает нативно
- Medusa: использование встроенного роутинга
Отрицательные
- Overfetching: клиент получает все поля, даже ненужные
- N+1: требуется внимательность при загрузке связей
- Множественные запросы: для сложных страниц нужно несколько запросов
Митигация
| Проблема | Решение |
|---|---|
| Overfetching | Параметр fields для выбора полей |
| N+1 | Eager loading в ORM, Redis кэш |
| Множественные запросы | Композитные эндпоинты для критичных страниц |