REST M2M - Архитектура машинного API в Wepps
Вам нужно создать REST API для работы с данными таблиц? Может быть, это интеграция с 1С, синхронизация с внешней CRM, или обмен данными между системами. Обычно это приводит к дублированию кода: одинаковые методы, одинаковые ошибки, одинаковые траты времени.
RestV1M2M решает эту проблему простым способом: два файла, явные методы на каждую таблицу и один универсальный utils для работы с любой таблицей.
Три проблемы, которые решает RestV1M2M
Проблема 1: Дублирование кода CRUD операций
Решение: Один RestV1M2MUtils (экземпляр на таблицу) с методами add(), fetch(), set(), remove() (single) и addBatch(), setBatch(), remove() (batch). Все операции используют WeppsCore\Data.
Проблема 2: Сложность понимания кода
Чем больше абстракций (Manager → Entity → Router), тем это сложнее отлаживать и расширять.
Решение: Явные HTTP методы в RestV1M2M — видно сразу что где. Никаких магических вызовов, никаких фабрик.
Проблема 3: Трудно добавлять новые таблицы
Каждая новая таблица требовала создания целого класса + регистрации + маппинга.
Решение: RestConfig.php содержит все роуты. Добавьте запись в конфиг — готово. Новые таблицы через конфигурацию, специальная логика через before/after-колбэки.
Архитектура: Диспетчер + Utils
Общая схема
┌──────────────────────────────────────────────────┐
│ Rest.php (фреймворк) │
│ Парсит URL, маршрутизирует на классы │
└──────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ RestV1M2M (диспетчер) │
│ │
│ getUsers() → getUtils('s_Users')->fetch() │
│ postUsers() → create('s_Users', records) │
│ putUsers() → update('s_Users', records) │
│ deleteUsers() → getUtils('s_Users')->remove() │
└──────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ RestV1M2MUtils (CRUD логика) │
│ (экземпляр на таблицу, кэш по tableName) │
│ │
│ fetch($query, $conditions) → Data::fetch() │
│ item($id) → Data::fetch() │
│ add($record) → Data::add() │
│ addBatch($records) → Data::add() batch │
│ set($id, $data) → Data::set() │
│ setBatch($items) → Data::set() batch │
│ remove($ids) → Data::remove() │
└──────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────┐
│ WeppsCore\Data (ядро системы) │
│ │
│ fetch() - получить записи с пагинацией │
│ add() - создать запись │
│ set() - обновить запись │
│ remove() - удалить запись │
└──────────────────────────────────────────────────┘
Компоненты
1. RestV1M2M.php — Диспетчер HTTP методов
Назначение: Встречает HTTP запросы и делегирует их utils'у.
Структура: явные методы для каждой таблицы. Для каждой таблицы есть 4 метода (GET list, GET item, POST, PUT, DELETE):
class RestV1M2M extends RestV1
{
/**
* Кэш экземпляров RestV1M2MUtils по имени таблицы.
*/
private array $utils = [];
/**
* Получить (и закэшировать) экземпляр RestV1M2MUtils для таблицы.
*/
protected function getUtils(string $tableName): RestV1M2MUtils
{
if (!isset($this->utils[$tableName])) {
$this->utils[$tableName] = new RestV1M2MUtils($tableName);
}
return $this->utils[$tableName];
}
// USERS
public function getUsers(): array
{
$utils = $this->getUtils('s_Users');
$utils->setFields('Id,Guid,Name,NameFirst,NameSurname,NamePatronymic,IsHidden,UserPermissions,CreateDate,Login,Email,Phone,Comment,Country,Region,City,Address,PostalCode');
return $utils->fetch($this->get);
}
public function getUsersItem(): array
{
$utils = $this->getUtils('s_Users');
$utils->setFields('Id,Guid,Name,...');
return $utils->item((int) ($this->get['id'] ?? 0));
}
public function postUsers(): array
{
$records = $this->normalizeInput();
return $this->create('s_Users', $records);
}
public function putUsers(): array
{
$records = $this->normalizeInput();
return $this->update('s_Users', $records);
}
public function deleteUsers(): array
{
$ids = $this->normalizeIds($this->normalizeInput());
return $this->getUtils('s_Users')->remove($ids);
}
// ORDERS, GOODS, GOODS.NAVIGATOR, GOODS.STATUSES,
// GOODS.ATTRIBUTES, GOODS.ATTRIBUTESVALUES, GOODS.VARIATIONS,
// GOODS.STOCKS, FILES, TASKS.RESULT — аналогично
}
Преимущества явных методов:
- ✅ Легко отладить - видно какой метод вызывается
- ✅ IDE показывает все доступные методы
- ✅ Нет магических вызовов через
__call() - ✅ Одна ответственность - только диспетчинг
2. RestV1M2MUtils.php — CRUD Utils (экземпляр на таблицу)
Назначение: Вся бизнес-логика работы с БД. Конструктор принимает имя таблицы, поэтому один экземпляр работает только с одной таблицей:
class RestV1M2MUtils
{
private string $tableName;
public function __construct(string $tableName)
{
$this->tableName = $tableName;
}
/**
* Получить список записей с пагинацией
* @param array $query - параметры: page, limit, search
* @param string|null $conditions - дополнительные условия WHERE
* @return array - {status, message, data, pagination}
*/
public function fetch(array $query, ?string $conditions = null): array { ... }
/**
* Получить одну запись по ID
*/
public function item($id): array { ... }
/**
* Добавить одну запись (201 Created)
*/
public function add(array $record): array { ... }
/**
* Пакетная вставка записей (207 Multi-Status)
*/
public function addBatch(array $records): array { ... }
/**
* Обновить одну запись (200 OK)
*/
public function set(int $id, array $data): array { ... }
/**
* Пакетное обновление записей (207 Multi-Status)
*/
public function setBatch(array $items): array { ... }
/**
* Удалить записи (всегда 207 Multi-Status)
*/
public function remove(array $ids): array { ... }
}
Ключевые моменты:
- Все методы используют WeppsCore\Data (нет собственной SQL логики)
- Все возвращают единый формат:
{status, message, data} - Стандартные HTTP коды: 200 (success), 201 (created), 207 (multi-status), 400 (bad request), 404 (not found), 409 (duplicate), 500 (error)
- Single-операции возвращают 201/200, batch-операции — 207 с per-item статусами
remove()работает ТОЛЬКО с batch-форматом (массив id)
3. RestConfig.php — Конфигурация маршрутов
Где регистрируются все REST методы (версия m2m):
'm2m' => [
'get' => [
'users' => [
'class' => RestV1M2M::class,
'method' => 'getUsers',
'role_required' => [1],
'auth_required' => true,
'note' => 'M2M: список пользователей',
],
'users.item' => [
'class' => RestV1M2M::class,
'method' => 'getUsersItem',
'role_required' => [1],
'auth_required' => true,
'query_validation' => [
'id' => ['type' => 'int2', 'required' => true],
],
],
// ... orders, goods, goods.navigator, goods.statuses, ...
],
'post' => [
'goods' => [
'class' => RestV1M2M::class,
'method' => 'postGoods',
'role_required' => [1],
'auth_required' => true,
'async' => true, // ← поставить в очередь s_Tasks (202 Accepted)
'validation' => [
'guid' => ['type' => 'guid', 'required' => true],
'name' => ['type' => 'string', 'required' => true],
'alias' => ['type' => 'string', 'required' => true],
'navigatorId' => ['type' => 'int2', 'required' => true],
'price' => ['type' => 'float2', 'required' => true],
],
],
// ... users, orders, goods.navigator, files, ...
],
'put' => [
'users' => [
'class' => RestV1M2M::class,
'method' => 'putUsers',
'role_required' => [1],
'auth_required' => true,
'note' => 'M2M: обновление пользователя по id. ID через ?id= или {"id": 123}',
],
// ...
],
'delete' => [
'users' => [
'class' => RestV1M2M::class,
'method' => 'deleteUsers',
'role_required' => [1],
'auth_required' => true,
'note' => 'M2M: удаление по id. Формат тела: {"data": [123, 456, ...]}',
],
// ...
],
],
Авто-дополнение валидации — inheritEndpointConfig() достраивает правила по умолчанию:
- GET:
query_validation = {page, limit}(если не задано явно) - PUT:
validation = {id: int, required} + поля из POST как необязательные(частичное обновление) - DELETE:
validation = {ARRAY: int[], required}— batch-удаление по массиву id
💡 Если для эндпоинта задан явный
validation— он имеет приоритет и переопределяет авто-дополнение. Это позволяет, например, сделать PUT «строгим» с нужным набором обязательных полей (см. PUT — обновление).
Доступные таблицы (эндпоинты)
На данный момент в RestConfig.php зарегистрированы 12 эндпоинтов (версия m2m):
| Эндпоинт | Таблица в БД | Описание |
|---|---|---|
users, users.item |
s_Users |
Пользователи системы |
orders, orders.item |
Orders |
Заказы (с вложенными data.items[] — позиции) |
goods, goods.item |
Products |
Товары |
goods.variations |
ProductsVariations |
Вариации товаров (цвет/размер/sku/остатки) |
goods.stocks |
ProductsVariations (Field4) |
Остатки товаров (sku, количества) |
goods.navigator |
s_Navigator |
Категории товаров (разделы навигатора) |
goods.statuses |
s_Vars (группа ПродукцияСтатусы) |
Статусы товаров |
goods.attributes |
s_Properties |
Свойства (атрибуты) товаров |
goods.attributesGroups |
s_PropertiesGroups |
Группы свойств товаров |
goods.attributesValues |
s_PropertiesValues |
Значения свойств товаров |
files |
s_Files |
Файлы (картинки, документы) |
tasks.result |
s_Tasks |
Результат async-задачи по id (только GET) |
Для каждого эндпоинта кроме tasks.result доступны методы GET (list), GET (item), POST, PUT, DELETE — единый набор CRUD-операций.
Batch-семантика операций
M2M API поддерживает два режима работы: одиночный объект и batch (массив).
POST — создание
| Формат | Ответ |
|---|---|
Одиночный объект {"data": {...}} |
201 Created + {"data": {"id": N}} |
Массив (макс. 100 записей) {"data": [{...}, {...}]} |
207 Multi-Status + массив результатов по индексам |
📌 Откуда взялось «макс. 100»: лимит устанавливается константой
MAX_BATCH_SIZE = 100вRestV1M2M.phpи применяется вnormalizeInput(int $maxBatchSize = self::MAX_BATCH_SIZE)— единой точке входа для всех batch-запросов (POST/PUT/DELETE). Если в{"data": [...]}передано больше 100 записей, массив аккуратно обрезается до первых 100 (array_slice(0, $maxBatchSize)); лишние записи молча отбрасываются без ошибки. Лимит можно переопределить аргументом вызова ($this->normalizeInput(500)), но по умолчанию он берётся из константы. Также «макс. 100» упоминается вnote-описаниях эндпоинтов вRestConfig.php. Дальше по цепочкеnormalizeInput()→create()/update()→addBatch()/setBatch()обрабатывают уже обрезанный массив одной транзакцией.
PUT — обновление
ID записи передаётся только в теле JSON:
{"data": {"id": 123, "name": "..."}}
Передача ID через query-строку (?id=123) для PUT не поддерживается — ID берётся из каждой записи в data ($record['id']), несуществующие ID отсекаются проверкой SELECT Id FROM ... WHERE Id IN (...) до batch-обновления.
Несуществующий ID → 404 Record not found. PUT — это частичное обновление (работает как PATCH): обязательные поля не требуются, обновляются только переданные. Так сделано намеренно — для упрощения кодовой базы не вводятся отдельные PATCH-методы, единый PUT покрывает и полное, и частичное обновление.
💡 Настройка под свои требования: несмотря на частичную семантику по умолчанию, на уровне конфига можно задать необходимое и достаточное количество обязательных полей для PUT. Для этого в
RestConfig.phpу PUT-эндпоинта указывается явныйvalidation— он переопределяет авто-дополнениеinheritEndpointConfig():'put' => [ 'users' => [ 'class' => RestV1M2M::class, 'method' => 'putUsers', // Явный validation переопределяет inheritEndpointConfig(): // теперь PUT /rest/m2m/users требует id + name (иначе 400) 'validation' => [ 'id' => ['type' => 'int2', 'required' => true], 'name' => ['type' => 'string', 'required' => true], ], ], ],Так можно сделать PUT «строгим» (полная замена, все поля обязательны), либо гибридным — комбинация обязательных и необязательных полей под конкретную бизнес-логику.
DELETE — удаление
Тело запроса — массив ID:
{
"data": [123, 456, 789]
}
Ответ всегда 207 Multi-Status с per-item статусами (успех/404 Already deleted or not found).
Пример 207 Multi-Status ответа
{
"status": 207,
"message": "Multi-Status",
"data": [
{"status": 201, "message": "Created", "data": {"id": 501}},
{"status": 409, "message": "Duplicate key constraint: guid = 550e8400-..."},
{"status": 400, "message": "Field 'name' is required"}
]
}
Ограничения
- Максимум 100 записей за один batch-запрос: константа
MAX_BATCH_SIZE = 100вRestV1M2M.php, обрезка вnormalizeInput(int $maxBatchSize = self::MAX_BATCH_SIZE)(array_slice(0, $maxBatchSize)). Лишние записи отбрасываются без ошибки — клиенту нужно разбивать большие массивы на порции по 100 - Пагинация:
pagemax 1...N,limitmax 100 (лимит задаётся вRestV1M2M::calculatePagination(int $maxLimit = 100)) - Проверка уникальности полей (
IsUnique = 1вs_ConfigFields) →409 Duplicate key constraint
Хелперы диспетчера (create/update)
RestV1M2M содержит protected-хелперы для общих операций:
/**
* Создать записи (одиночную или batch)
* Одиночная → 201 Created, batch → 207 Multi-Status
*/
protected function create(string $tableName, array $records): array
{
$results = [];
foreach ($records as $record) {
$this->validate($tableName, $record, true); // полная валидация
}
$utils = $this->getUtils($tableName);
$results = $utils->addBatch($records); // batch-вставка
$this->updateSearchIndex($tableName, $records); // обновление поискового индекса
// одиночная → прямой результат
// batch → ['status' => 207, 'message' => 'Multi-Status', 'data' => $results]
}
/**
* Обновить записи (PUT, частичное обновление)
*/
protected function update(string $tableName, array $records): array
{
foreach ($records as $record) {
if (empty($record['id'])) {
return ['status' => 400, 'message' => 'id required', 'data' => null];
}
$this->validate($tableName, $record, false); // required=false
}
// ... setBatch, 404 для несуществующих id
}
Вспомогательные методы диспетчера:
normalizeInput(int $maxBatchSize = self::MAX_BATCH_SIZE)— разворачивает{"data": ...}; одиночный объект оборачивает в массив[объект]; batch определяется поisset($raw[0]) && is_array(...); batch обрезается до$maxBatchSize(по умолчанию 100)normalizeIds(array $records)— принимает скалярные ID или{"id": N}объекты, возвращает массив int > 0calculatePagination(int $maxLimit = 100)— ограничивает page/limitvalidate(string $tableName, array $record, bool $requireAll)— вызывает$this->rest->validateData()с правилами изgetFieldRules(), JSON-поля исключаются
Расширение: Custom логика через before/after колбэки
Специальная логика задаётся через колбэки прямо в методах диспетчера — без подклассов и наследования. Колбэки выполняются ВНУТРИ транзакции — исключение откатывает всю операцию.
Колбэки не ограничены валидацией — они подгоняют данные под бизнес-требования и могут запускать дополнительные бизнес-операции: создавать аккаунты пользователей, выполнять массовые операции (например, пересборку вариаций или перезагрузку остатков) и ставить задачи в очередь s_Tasks. Вся логика живёт рядом с эндпоинтом в одном месте — понятно, что происходит при каждом запросе.
Транзакционность: всё или ничего
Каждая batch-операция (и single тоже) выполняется в одной транзакции через Connect::transaction() (beginTransaction → работа → commit):
BEGIN TRANSACTION
├─ before-колбэки ← модифицируют записи
├─ INSERT/UPDATE ← запись в БД
├─ after-колбэки ← доп. операции (пересборка, привязки, файлы)
COMMIT ← все изменения фиксируются вместе
Откат при неуспехе: если любой колбэк или SQL-запрос бросит исключение — происходит rollBack() и вся операция откатывается целиком: ни одна запись из batch не сохранится. Это даёт гарантию консистентности — не бывает «половины» batch: либо применены все записи с сопутствующей логикой, либо ни одной.
// Пример: колбэк бросает исключение → транзакция откатывается
$utils->setAfter(function (array $results, string $tableName, RestV1M2MUtils $utils): array {
foreach ($results as $result) {
if (($result['status'] ?? 0) === 201) {
$sync = someExternalService($result['data']['id']);
if (!$sync) {
throw new \Exception('External sync failed'); // → rollBack всего batch
}
}
}
return $results;
});
⚠️ Исключение в колбэке откатывает и вставку, и сопутствующие операции. Поэтому внешние вызовы (HTTP, файлы) лучше выполнять после успешного COMMIT или так, чтобы их можно было безопасно повторить.
public function postGoodsNavigator(): array
{
$records = $this->normalizeInput();
$utils = $this->getUtils('s_Navigator');
// AFTER: после создания автоматически привязать раздел к расширению каталога
$utils->setAfter(function (array $results, string $tableName, RestV1M2MUtils $utils): array {
foreach ($results as $i => $result) {
if (($result['status'] ?? 0) === 201) {
$id = $result['data']['id'] ?? null;
if ($id) {
$utils->set($id, ['ParentId' => ..., 'Extension' => ...]);
}
}
}
return $results;
});
return $this->create('s_Navigator', $records);
}
Список колбэков в текущем коде:
| Метод | Колбэк | Действие |
|---|---|---|
postGoodsNavigator |
after |
Устанавливает ParentId и Extension (каталог) |
put/deleteGoodsNavigator |
before |
Валидация разделов каталога (buildInvalidIdValidationErrors) |
postGoodsStatuses |
after |
Приоритет Priority += 5 |
postGoodsAttributes |
before |
Тип text-multi, группа = 1 |
postGoodsAttributes |
after |
Приоритет Priority += 5 |
put/deleteGoodsStatuses |
before |
Запрет обновления/удаления не-групповых ID |
postGoodsAttributesValues |
before |
Генерация w_guid через Lists::getPropertiesValuesGuid() |
postGoodsVariations |
before |
name = sku, alias через ProcessingProducts::getProductsVariationsAlias(), Priority = MAX+1 |
post/put/deleteGoodsVariations |
after/before |
Пересборка вариаций rebuildProductsVariations() |
putGoodsStocks |
before |
При page=1 — полная перезагрузка остатков (Field4 = 0) |
postFiles |
before |
Загрузка файла (base64/url) через Lists::getUploadFileName() |
postFiles |
after |
Очистка временных файлов |
deleteFiles |
before/after |
Сбор FileUrl, физическое удаление файлов (unlink) |
Сигнатуры колбэков
// BEFORE — может модифицировать записи до вставки
// Возвращает массив записей (или null, чтобы оставить как есть)
'before' => fn(array $items, string $tableName, RestV1M2MUtils $utils): ?array
// AFTER — может заменить результаты после вставки
// Возвращает массив результатов (или null, чтобы оставить как есть)
'after' => fn(array $results, string $tableName, RestV1M2MUtils $utils): ?array
Колбэки — это обычные PHP-замыкания, поэтому можно захватывать переменные из области видимости диспетчера через use($var) и обмениваться данными между before и after:
public function postGoods(): array
{
$records = $this->normalizeInput();
$utils = $this->getUtils('Products');
$generatedAliases = []; // переменная для обмена между колбэками
// BEFORE: генерируем alias, сохраняем результат в $generatedAliases
$utils->setBefore(function (array $items, string $tableName, RestV1M2MUtils $utils) use (&$generatedAliases): array {
foreach ($items as $i => $item) {
$generatedAliases[$i] = transliterate($item['name'] ?? '');
$items[$i]['alias'] = $generatedAliases[$i];
}
return $items;
});
// AFTER: используем $generatedAliases из before-колбэка
$utils->setAfter(function (array $results, string $tableName, RestV1M2MUtils $utils) use ($generatedAliases): array {
foreach ($results as $i => $result) {
if (($result['status'] ?? 0) === 201) {
// пишем alias во внешний сервис / лог / связанную таблицу
}
}
return $results;
});
return $this->create('Products', $records);
}
Для передачи по ссылке (чтобы after видел изменения, сделанные в before) используйте use (&$var) — как в примере выше.
// Установка ошибок валидации из колбэка (per-item)
$utils->setValidationErrors([
0 => ['status' => 400, 'message' => 'Invalid id values provided', 'data' => ['id' => 999]],
]);
Пагинация со скрытием (handlePagination)
Некоторые POST/PUT методы поддерживают массовое скрытие через блок pagination в теле запроса. Это паттерн синхронизации: клиент отправляет все записи постранично, а сервер скрывает те, которые не были переданы.
Где поддерживается (методы с handlePagination в RestV1M2M):
| Метод | HTTP | Логика |
|---|---|---|
postUsers |
POST /rest/m2m/users |
Массовое скрытие не переданных пользователей |
putUsers |
PUT /rest/m2m/users |
Массовое скрытие не переданных пользователей |
putGoods |
PUT /rest/m2m/goods |
Массовое скрытие не переданных товаров |
putGoodsAttributes |
PUT /rest/m2m/goods.attributes |
Массовое скрытие не переданных свойств |
putGoodsAttributesValues |
PUT /rest/m2m/goods.attributesValues |
Массовое скрытие не переданных значений свойств |
putGoodsVariations |
PUT /rest/m2m/goods.variations |
Массовое скрытие не переданных вариаций |
putGoodsStocks |
PUT /rest/m2m/goods.stocks |
При page=1 — обнуление всех остатков (Field4 = 0), затем запись новых |
Формат блока в теле запроса:
{
"data": [ { "id": 1, ... }, { "id": 2, ... } ],
"pagination": {
"page": 1, // номер текущей страницы (с 1)
"count": 3 // всего страниц
}
}
Как работает паттерн IsHiddenCandidate (в RestV1M2MUtils::handlePagination()):
// page=1 → все записи помечаются IsHiddenCandidate=1
// каждая страница сбрасывает флаг для обновлённых/созданных id
// последняя страница (page === count) финализирует: IsHidden = IsHiddenCandidate
$pagination = $utils->handlePagination($this->data['pagination'] ?? null);
Пошагово:
- Первая страница (
page: 1) — все записи таблицы помечаютсяIsHiddenCandidate = 1. - Каждая страница — переданные
idсбрасывают флагIsHiddenCandidate = 0(запись будет оставлена видимой). - Последняя страница (
page === count) — финализация:IsHidden = IsHiddenCandidate, флаги очищаются.
Важно: пагинация передаётся в теле запроса (
$this->data['pagination']), а не в query-строке. Без блокаpaginationколбэки не создаются — выполняется обычное точечное обновление переданных записей.
Исключение — putGoodsStocks: использует свой before-колбэк (не handlePagination), который при page: 1 обнуляет ВСЕ остатки (Field4 = 0) перед записью новых значений. Это полная перезагрузка остатков при синхронизации.
// PUT /rest/m2m/goods.stocks — полная перезагрузка остатков
$utils->setBefore(function (array $records, string $tableName, RestV1M2MUtils $utils) {
$pagination = $this->data['pagination'] ?? null;
if (isset($pagination['count']) && isset($pagination['page'])) {
if ((int) $pagination['page'] == 1) {
$sql = "UPDATE {$tableName} SET Field4 = 0 WHERE 1";
Connect::$instance->query($sql);
}
}
return $records;
});
Пример запроса с массовым скрытием (пользователи, 3 страницы):
# Страница 1 — все пользователи помечаются кандидатами на скрытие
PUT /rest/m2m/users HTTP/1.1
Content-Type: application/json
Authorization: Bearer ...
{
"data": [ { "id": 1, "name": "John" }, { "id": 2, "name": "Jane" } ],
"pagination": { "page": 1, "count": 3 }
}
# Страница 2
PUT /rest/m2m/users HTTP/1.1
{ "data": [ { "id": 3, "name": "Bob" } ], "pagination": { "page": 2, "count": 3 } }
# Страница 3 — последняя, финализирует скрытие непереданных пользователей
PUT /rest/m2m/users HTTP/1.1
{ "data": [ { "id": 4, "name": "Alice" } ], "pagination": { "page": 3, "count": 3 } }
После этого пользователи с id 5, 6, ... (не переданные ни на одной странице) будут скрыты (IsHidden = 1).
## Примеры использования REST API
### 1. Получить список пользователей
```bash
GET /rest/m2m/users?page=1&limit=20 HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Response:
{
"status": 200,
"message": "OK",
"data": [
{
"id": 1,
"guid": "550e8400-e29b-41d4-a716-446655440000",
"login": "user@example.com",
"name": "John Doe",
"email": "john@example.com",
"phone": "+1234567890"
}
],
"pagination": {
"page": 1,
"limit": 20,
"count": 145
}
}
2. Создать пользователя
POST /rest/m2m/users HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"data": {
"guid": "550e8400-e29b-41d4-a716-446655440000",
"name": "John Doe",
"nameFirst": "John",
"nameSurname": "Doe",
"login": "john@example.com",
"email": "john@example.com",
"phone": "9123456789"
}
}
Response: 201 Created
{
"status": 201,
"message": "Created",
"data": {
"id": 42
}
}
3. Создать товары (batch)
POST /rest/m2m/goods HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"data": [
{
"guid": "550e8400-e29b-41d4-a716-446655440001",
"name": "iPhone 15",
"alias": "iphone-15",
"navigatorId": 8,
"price": 999.99
},
{
"guid": "550e8400-e29b-41d4-a716-446655440002",
"name": "iPhone 15 Pro",
"alias": "iphone-15-pro",
"navigatorId": 8,
"price": 1199.99
}
]
}
Response: 207 Multi-Status (после обработки async-очереди)
Примечание: POST
goodsнастроен как'async' => true— запрос ставится в очередьs_Tasksи сразу возвращает202 Acceptedсtask_id. Результат получается черезGET /rest/m2m/tasks.result?id={task_id}.
4. Обновить остатки товаров
# Точечное обновление одного остатка
PUT /rest/m2m/goods.stocks HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"data": {
"id": 501,
"stocks": 15
}
}
Response: 200 OK
{
"status": 200,
"message": "Updated",
"data": {
"id": 501
}
}
Полная перезагрузка остатков (пакетная синхронизация) — добавьте pagination с page: 1:
PUT /rest/m2m/goods.stocks HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"data": [
{ "id": 501, "stocks": 15 },
{ "id": 502, "stocks": 0 }
],
"pagination": { "page": 1, "count": 1 }
}
При page: 1 все остатки сначала обнуляются (Field4 = 0), затем переданным id записываются новые значения. Так остатки, отсутствующие в синхронизации, гарантированно становятся 0.
5. Удалить заказы (batch)
DELETE /rest/m2m/orders HTTP/1.1
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
{
"data": [5001, 5002]
}
Response: 207 Multi-Status
{
"status": 207,
"message": "Multi-Status",
"data": [
{"status": 200, "message": "Deleted", "data": {"id": 5001}},
{"status": 404, "message": "Already deleted or not found", "data": {"id": 5002}}
]
}
Аутентификация
Все M2M-эндпоинты защищены:
auth_required => true— обязателен JWT-токенrole_required => [1]— только пользователи с правамиUserPermissions = 1
Авторизация общая с APP — токен получается через POST /rest/v1/auth.login (в Bruno-коллекциях это auth/auth.login.bru, сохраняет access_token/refresh_token в переменные, а папка clientM2M использует auth: bearer с {{access_token}}):
POST /rest/v1/auth.login HTTP/1.1
Content-Type: application/json
{
"data": {
"login": "m2m-admin@example.com",
"password": "secret"
}
}
Response:
{
"status": 200,
"message": "Login successful",
"data": {
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"access_exp": 1774567890,
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
"refresh_exp": 1777159890
}
}
Далее access_token передаётся заголовком в каждом M2M-запросе:
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Валидация данных
Правила валидации для M2M-запросов берутся из таблицы s_ConfigFields (не зашиты в код):
Как это работает
Для каждого POST/PUT запроса диспетчер вызывает validate():
private function validate(string $tableName, array $record, bool $requireAll): void
{
$rules = $this->getUtils($tableName)->getFieldRules();
if (empty($rules)) {
return; // правил нет в БД — пропустить
}
$this->rest->validateData($record, $rules); // вызов из Rest.php
}
Источник правил — getFieldRules()
public function getFieldRules(): array
{
// Кэш в Memcached на 1 час (ключ api_validation_rules_{table})
return Connect::$instance->memcached->get('auto', function () {
return Connect::$instance->db->query(
"SELECT `TableField`, `ApiMapping`, `ApiFieldType`, `IsRequired`
FROM s_ConfigFields
WHERE `TableName` = ?
ORDER BY `TableField` ASC",
[$this->tableName]
)->fetchAll();
});
}
Поля из s_ConfigFields:
ApiMapping— маппинг camelCase → PascalCase (напримерnameFirst→NameFirst)ApiFieldType— тип валидации (string,int,float2,date,guid...)IsRequired— обязательность поляTableField— имя поля в таблице
Результат — массив правил:
[
'nameFirst' => ['type' => 'string', 'required' => true],
'nameSurname' => ['type' => 'string', 'required' => true],
'phone' => ['type' => 'string', 'required' => true],
]
Особенности
- JSON-поля исключаются из валидации (обрабатываются отдельно, автоматически декодируются)
- PUT — валидация с
required = false(частичное обновление) - Непереданные поля не проверяются; лишние поля →
400 Unexpected field '...' - Проверка уникальности полей
IsUnique = 1→409 Duplicate key constraint: {поле} = {значение} - Правила кэшируются в Memcached на 1 час; чтобы обновить — очистить кэш или подождать
⚠️ Типы в конфиге и в
s_ConfigFieldsдолжны совпадать. Валидация выполняется дважды: сначалаRest::executeHandler()проверяет тип изvalidationвRestConfig.php, затемRestV1M2M::validate()проверяетApiFieldTypeизs_ConfigFields(черезgetFieldRules()). Если типы расходятся (например, в конфигеstring, а вs_ConfigFields—guid), валидация поs_ConfigFieldsдаст ошибкуField '...' must be guidдля значения, которое прошло проверку по конфигу. Держите типы синхронизированными в обоих местах — конфиг отвечает за тип запроса,s_ConfigFields— за тип поля в БД.
Ошибки валидации
{
"status": 400,
"message": "Field 'email' is required",
"data": null
}
{
"status": 400,
"message": "Field 'id' must be int",
"data": null
}
Async-режим
Некоторые эндпоинты обрабатываются асинхронно через очередь s_Tasks:
POST goods→'async' => true— запрос ставится в очередь, ответ202 Acceptedсtask_idPOST goods.navigator→'async' => false— выполняется немедленноPOST goods.variations→'async' => false— выполняется немедленно (планируется async)PUT goods.variations,PUT goods.stocks→'async' => false
Поток async-запроса
POST /rest/m2m/goods ({"data": [...]})
│
▼
Rest::queueTask() → INSERT в s_Tasks
│
▼
202 Accepted: {"status": 202, "data": {"task_id": 123}}
│
▼ (отдельно, по cron/CLI)
php Request.php tasks.process ← обработка очереди s_Tasks
│
▼
GET /rest/m2m/tasks.result?id=123
│
▼
200 OK: {"status": 200, "data": {"id": 123, "is_processed": 1, "http_status": 207, "response": {...}}}
Состояния ответа tasks.result (polling):
- Задача ещё в очереди:
is_processed: false,http_status: null,response: null - Задача выполнена:
is_processed: true,http_status: <статус операции>(например201или207),response: { "status": ..., "data": ... } - Задачи с таким id нет:
404
Опрашивайте эндпоинт с интервалом (например, раз в 5–10 секунд), пока is_processed не станет true.
CLI-обработка очереди: php packages/WeppsExtensions/Addons/Rest/Request.php tasks.process
Итоговая диаграмма потока запроса
HTTP Request: GET /rest/m2m/users?page=1&limit=20
│
▼
┌──────────────────────────────┐
│ Rest.php (фреймворк) │
│ routeRequest() │
│ version='m2m', method='users'│
│ authenticateBearerToken() │ ← auth_required + role_required [1]
└──────────┬───────────────────┘
│
▼
┌──────────────────────────────────┐
│ RestV1M2M.getUsers() │
│ ↓ │
│ getUtils('s_Users')->fetch( │
│ ['page'=>1, 'limit'=>20] │
│ ) │
└──────────┬───────────────────────┘
│
▼
┌──────────────────────────────────┐
│ RestV1M2MUtils.fetch() │
│ ↓ │
│ Data('s_Users')->fetch(...) │
│ ↓ │
│ keysToCamelCase() (ApiMapping) │
│ ↓ │
│ return ['status' => 200, ...] │
└──────────┬───────────────────────┘
│
▼
HTTP 200: { status, message, data, pagination }
Возможности RestV1M2M
| Аспект | Реализация |
|---|---|
| URL | /rest/m2m/... |
| Utils | Экземпляр на таблицу (getUtils('s_Users'), кэш) |
| Методы | Явные getUsers()/postUsers()/... + хелперы create()/update() |
| Входные данные | normalizeInput() — обёртка {"data": ...}, batch-детект |
| Batch | POST batch (макс. 100) → 207, DELETE batch → 207 |
| Кастомная логика | Before/after колбэки (setBefore()/setAfter()) |
| Валидация | validate() + getFieldRules() из TableField/ApiMapping/ApiFieldType/IsRequired |
| Аутентификация | Всегда auth_required + role_required [1] |
| Async | async => true → 202 + s_Tasks |
| Эндпоинты | 12 эндпоинтов (users, orders, goods.*, files, tasks.result) |
Выводы
RestV1M2M — это просто и эффективно потому что:
- Явные методы — каждый HTTP метод это отдельная функция, видно одновременно
- Utils на таблицу — один экземпляр на таблицу с кэшем, вся CRUD-логика в одном классе
- Без магии — нет
__call(), нет магических фабрик, нет сложных наследований - Опирается на Data — используем проверенный класс WeppsCore\Data
- Легко добавлять таблицы — методы + запись в RestConfig (валидация достраивается автоматически через
inheritEndpointConfig()) - Batch-операции — создание/обновление/удаление до 100 записей за запрос (207 Multi-Status)
- Кастомизация через колбэки — before/after внутри транзакции, без создания подклассов
- Транзакционность — каждая операция выполняется в транзакции (
beginTransaction→commit); исключение в колбэке или SQL откатывает весь batch (rollBack) — не бывает частично применённых изменений - Валидация из БД — правила автоматически из
s_ConfigFields(TableField/ApiMapping/ApiFieldType/IsRequired), кэш 1 час - Асинхронность — тяжёлые операции через
s_Tasks(202 +tasks.result) - Единая валидация —
validateData()/validateType()изRest.phpчерез$this->rest, нет дублирования логики
Примеры в Bruno
Готовые коллекции для тестирования лежат в репозитории:
.tools/bruno/WeppsPlatformV1/clientM2M/ — папки users/, orders/, goods/ (с attributes/, attributesValues/, navigator/, statuses/, stocks/, variations/), files/, tasks.result.get.bru.
Переменная окружения: {{base_url}} = https://ваш-домен