Skip to content
mxApi
Единая точка входа публичного API для MODX Revolution 2 и 3 — маршруты под своим префиксом, bearer-токены, scope поверх прав MODX, каталог эндпоинтов и OpenAPI из живого реестра.
  1. Компоненты
  2. mxApi
  3. Настройка и расширение
  4. Справочник эндпоинта

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

Полный перечень того, что можно объявить в describe() и чем пользоваться внутри handle(). Как это собирается в рабочий эндпоинт — пошаговое руководство.

Ключи описания

Метод describe() возвращает массив. Ниже — все ключи и значения по умолчанию.

Идентификация и маршрут

КлючПо умолчаниюСмысл
id''Идентификатор вида reviews.list — он же ключ реестра и цель для route_aliases. Обязателен и должен быть уникален на сайте
title''Название для каталога; если пусто, показывается id
description''Описание для каталога и OpenAPI
path'/'Путь относительно mxapi.route_prefix. Сам префикс сюда не пишется
methods['GET']HTTP-методы; приводятся к верхнему регистру
provider'mxapi.core'Источник эндпоинта, виден в каталоге
deprecatedfalseПометка «устаревший» для каталога и OpenAPI

Путь разбирает FastRoute, поэтому доступны его шаблоны: /reviews/{id:\d+}, необязательные части /reviews[/{id}]. Наружу — в каталог и OpenAPI — путь отдаётся без шаблонов: /reviews/{id}.

Параметры пути попадают во входные данные наравне с query и body, объявлять их нужно с 'in' => 'path'.

Доступ

КлючПо умолчаниюСмысл
scope''Scope, который клиент обязан иметь в токене. Пусто — scope не проверяется
permission''Право MODX в namespace mxapi. Пусто — право не проверяется
auth'bearer'bearer — нужен токен; none — эндпоинт публичный (так объявлен только выпуск токена)
modx_context''Контекст MODX: конкретный ключ (mgr, web), 'request' — из заголовка X-MxApi-Context, пусто — безразличен
context'public'public — часть контракта; internal — служебный, в каталог и OpenAPI не попадает никогда
writefalseИзменяющий эндпоинт: пишется в журнал всегда и участвует в идемпотентности

Константы вместо строк: EndpointMetadata::AUTH_NONE, AUTH_BEARER, CONTEXT_PUBLIC, CONTEXT_INTERNAL, MODX_CONTEXT_FROM_REQUEST.

Кэширование ответа

КлючПо умолчаниюСмысл
cache'no-store'no-store — ответ не проверяется на изменения; etag — ядро отдаёт метку версии и отвечает 304 на If-None-Match. Константы EndpointMetadata::CACHE_NO_STORE и CACHE_ETAG

Подробнее — ETag и If-None-Match ниже.

write — не косметика

От него зависят три вещи: попадёт ли вызов в журнал при выключенном mxapi.log_reads, будет ли работать Idempotency-Key (только для write) и сохранится ли тело ответа для повтора. Изменяющий эндпоинт без write => true теряет и аудит, и защиту от двойного выполнения.

Документация

КлючПо умолчаниюСмысл
parameters[]Декларация входа — см. ниже
request_examplenullПример запроса для каталога
response_examplenullПример ответа для каталога
response_description''Что возвращается — текстом

Ключи реализации (наружу не отдаются)

Используются ProcessorEndpoint; видны в админке, но вырезаны из /meta/endpoints и OpenAPI.

КлючСмысл
processorMODX 2 — путь процессора, например mgr/review/getlist; MODX 3 — полное имя класса, MyPackage\Processors\Mgr\Review\GetList::class
processors_pathКаталог процессоров пакета. Только MODX 2: в тройке класс находит автозагрузчик
field_mapПереименование параметров: ['product' => 'product_id'] — наружу первое, в процессор второе
propertiesФиксированные свойства процессора; добавляются последними, клиент их не перебьёт

Свой ключ тоже можно положить в описание и прочитать через $this->getMetadata()->getExtra('ключ', $default) — но он попадёт в публичный каталог, если его нет в списке выше.

Параметры

Одна декларация обслуживает три задачи: валидацию входа, каталог в админке и OpenAPI.

ПолеПо умолчаниюСмысл
name''Имя параметра
in'query'query, path или body
type'string'См. таблицу приведения
requiredfalseНет значения → missing_parameter
defaultnullПодставляется, если параметр не передан
enum[]Белый список значений; иначе invalid_parameter
min / maxnullГраницы для числовых значений
description''Текст для каталога и OpenAPI
examplenullПример значения

Константы: ParameterMetadata::IN_QUERY, IN_PATH, IN_BODY, TYPE_STRING, TYPE_INTEGER, TYPE_NUMBER, TYPE_BOOLEAN, TYPE_ARRAY, TYPE_OBJECT, TYPE_DATE.

Приведение типов

ТипЧто принимаетсяЧто вернётся
stringскалярстрока
integerчисло или числовая строка, иначе invalid_parameterint
numberто жеfloat
boolean1, true, yes, on (регистр не важен) — истина; всё остальное — ложь, ошибки нетbool
arrayмассив, JSON-массив или строка через запятуюмассив
objectмассив или JSON-объект, иначе invalid_parameterмассив
dateвсё, что понимает strtotime(), иначе invalid_parameterисходная строка

Пустая строка = «параметр не передан»

?status= равнозначно отсутствию параметра: подставится default, а для обязательного будет missing_parameter. Если пустая строка для вас осмысленное значение — не полагайтесь на неё, заведите отдельное значение в enum.

Базовые классы

AbstractEndpoint

Реализует getMetadata() из describe() и даёт два помощника.

МетодЧто делает
readParams(Request $request)Возвращает только объявленные параметры, приведённые к типам. Всё лишнее отбрасывается, ошибки валидации бросаются сами
readPaging(array $params, Config $config)Возвращает [limit, offset] с учётом mxapi.default_limit и mxapi.max_limit
readCursor(array $params, Config $config)Разбирает и проверяет курсор из параметра cursor: возвращает состояние обхода или [], если курсора нет
nextCursor(array $state, array $params, Config $config)Собирает и подписывает курсор следующей страницы
cursorScope(array $params)Отпечаток выборки, к которому привязан курсор. Переопределяется, если состав «своих» параметров нестандартный

Наследник обязан реализовать describe() и handle(Request $request, EndpointContext $context): Response.

ProcessorEndpoint

handle() уже реализован: собирает свойства, запускает процессор, разворачивает ответ. Точки расширения:

ХукКогда вызывается
beforeRun(array &$properties, EndpointContext $context)после сборки свойств, до запуска процессора: лексиконы, рантайм-настройки, доп. свойства
transformPayload(array $payload, EndpointContext $context)после процессора, до сборки конверта: доменная нормализация ответа
extraMeta(array $payload)для списочного ответа: агрегаты по всей выборке уходят в meta, не смешиваясь с data

Поведение, которое стоит помнить:

  • пагинация включается объявлением параметра limit. Есть limitoffset переименуется в start, а в meta попадут total, limit, offset;
  • фиксированные properties применяются после пользовательских — клиент их не перебьёт;
  • ошибка процессора превращается в processor_error (HTTP 400) с полевыми ошибками в details.errors.

Контекст выполнения

EndpointContext, приходящий в handle():

МетодЧто даёт
getPlatform()Платформа: runProcessor(), getOption(), log(), now(), cacheGet/cacheSet(), findUserById(), checkPermission(), invokeEvent(), репозитории токенов/клиентов/журнала
getConfig()Конфигурация mxApi: get(), getInt(), getBool(), getList()
getAuth()AuthContext или null для эндпоинта с auth => none
getMetadata()Собственный паспорт эндпоинта

AuthContext: getUser(), getToken(), getClient(), getClientId(), getActor() (значение заголовка X-MxApi-Actor).

Доступ к самому modX — через платформенный адаптер: $context->getPlatform()->getModx(). Метода нет в PlatformInterface намеренно: ядро о MODX не знает, а код, который им пользуется, при переносе на MODX 3 придётся править.

Ответы и ошибки

ВызовЧто делает
Response::success($data, array $meta = [], $status = 200)конверт success / data / meta
Response::error($code, $message, $status = 400, array $details = [])обычно не нужен — бросайте ApiException
Response::stream(callable $streamer, $status = 200)ответ без конверта: колбэк сам печатает тело (так отдаётся OpenAPI)
$response->withHeader($name, $value)иммутабельно: возвращает копию с заголовком

Ошибку правильнее бросать исключением — ядро само превратит её в ответ и запишет код в журнал:

php
throw ApiException::missingParameter('product');
throw ApiException::invalidParameter('status', 'ожидается new|approved');
throw ApiException::notFound('review');
throw ApiException::insufficientPermission('mxapi_reviews_write');

Именованные конструкторы покрывают весь публичный контракт кодов — таблица кодов. Свободную строку кода придумывать не нужно: клиенты завязаны именно на этот словарь. Если своего кода действительно не хватает, создавайте исключение напрямую — new ApiException('my_code', 'Сообщение', 409).

Необработанное исключение любого другого типа превращается в internal_error (HTTP 500): подробности уходят в лог MODX, наружу — нейтральное сообщение, и только при mxapi.debug текст ошибки попадает в ответ.

Инкрементальные выгрузки

Два независимых механизма для интеграций, которые регулярно выкачивают одно и то же: ETag отвечает на вопрос «изменилось ли вообще», курсор — на вопрос «как пройти большую выборку по страницам, не сбиваясь».

ETag и If-None-Match

Объявите в паспорте 'cache' => EndpointMetadata::CACHE_ETAG — больше ничего не нужно:

php
'cache' => EndpointMetadata::CACHE_ETAG,

Что делает ядро: считает метку от тела успешного ответа, отдаёт её заголовком ETag вместе с Cache-Control: private, no-cache и, если следующий запрос пришёл с тем же значением в If-None-Match, отвечает 304 без тела.

Правила, о которых стоит помнить:

  • метка непрозрачна — клиент сравнивает её только с самой собой, разбирать её нельзя;
  • 304 расходует лимит частоты наравне с обычным ответом: версия считается внутри цепочки обработчиков, поэтому обойти ограничение заголовком нельзя;
  • метка ставится только на GET и только на успешный ответ с обычным конвертом; потоковые ответы (Response::stream()) не проверяются;
  • в OpenAPI у такого эндпоинта появляются заголовок ETag в ответе 200 и сам ответ 304.

Не доводить дело до сборки ответа

По умолчанию тело всё-таки собирается — экономится трафик, но не работа сервера. Если версию выборки видно дёшево (максимальный editedon, счётчик ревизий), реализуйте EtagAwareInterface: при совпадении меток handle() не вызывается вовсе.

php
use MxApi\Core\Endpoint\EtagAwareInterface;

class ReviewsListEndpoint extends ProcessorEndpoint implements EtagAwareInterface
{
    public function computeEtag(Request $request, EndpointContext $context)
    {
        // Обязана меняться при любом изменении данных ответа
        // и различаться для разных параметров запроса.
        return $lastChange . ':' . md5(json_encode($this->readParams($request)));
    }
}

Верните null, если версию сейчас не определить — ответ соберётся обычным путём. Исключение внутри computeEtag() не роняет запрос: ядро пишет предупреждение в лог и отвечает как обычно.

Курсорная пагинация

limit + offset для больших выгрузок плохи вдвойне: база пролистывает пропущенные строки, а вставки и удаления между страницами сдвигают выборку — записи задваиваются и теряются. Курсор (keyset) указывает не «сколько пропустить», а «после какой записи продолжать».

Объявите параметр cursor и работайте с двумя помощниками:

php
$params = $this->readParams($request);
$config = $context->getConfig();

list($limit) = $this->readPaging($params, $config);
$state = $this->readCursor($params, $config);       // [] на первой странице
$after = isset($state['id']) ? (int)$state['id'] : 0;

// Читаем на одну запись больше запрошенного: так видно, есть ли что дальше,
// без подсчёта всей выборки.
$rows = $this->fetchRowsAfter($after, $limit + 1);
$hasMore = count($rows) > $limit;
$rows = array_slice($rows, 0, $limit);

$meta = ['has_more' => $hasMore];
if ($hasMore) {
    $meta['next_cursor'] = $this->nextCursor(['id' => end($rows)['id']], $params, $config);
}

return Response::success($rows, $meta);

Что получает клиент: meta.has_more и meta.next_cursor, который передаётся как есть в параметре cursor следующего запроса. Курсор безопасен для URL — дополнительное кодирование не нужно.

СвойствоКак устроено
НепрозрачностьВнутри — позиция обхода, то есть кусок запроса к базе. Формат — деталь реализации, разбирать его клиенту нечего
ПодписьHMAC-SHA256 ключом сайта (mxapi.cursor_secret). Подделанный курсор и курсор с другого сайта отбрасываются
Привязка к выборкеВ подпись входит отпечаток параметров, кроме cursor, limit и offset. Сменили фильтр — старая позиция бессмысленна и отвергается; размер страницы менять между запросами можно
Отказinvalid_parameter с details.parameter = "cursor". Молча начать сначала нельзя: инкрементальная выгрузка пошла бы по кругу, и заметили бы это нескоро
Без ключа подписиinternal_error. Пустой mxapi.cursor_secret выключает курсоры, а не разрешает неподписанные

meta.total в курсорном режиме не отдаётся

И не должен: полный подсчёт выборки на каждой странице стоит дороже самой выгрузки — ровно того, ради чего курсор и заводят. Клиенту нужен признак «есть ли ещё», и это has_more, получаемый чтением одной лишней записи.

Сортировка обязана быть устойчивой: ключ, по которому вы продолжаете обход, должен быть уникальным (обычно id) или дополняться уникальным. Иначе записи с одинаковым значением ключа будут пропускаться или повторяться.

Системные события

СобытиеКогдаЧто можно
mxApiOnRegisterEndpointsсборка реестравернуть провайдера — имя класса строкой
mxApiOnRegisterMiddlewareсборка цепочки обработчиковвернуть промежуточный обработчик — имя класса строкой
mxApiOnBeforeRequestзапрос принят, до маршрутизациилогирование, метрики
mxApiOnBeforeEndpointRunэндпоинт найден, токен проверен, контекст переключёнаудит, подготовка окружения
mxApiOnAfterEndpointRunсразу после вызовапост-обработка, метрики
mxApiOnResponseперед отправкой ответанаблюдение за статусом

Промежуточные обработчики

php
use MxApi\Core\Endpoint\EndpointContext;
use MxApi\Core\Http\Request;
use MxApi\Core\Middleware\MiddlewareInterface;

class SignatureCheck implements MiddlewareInterface
{
    public function process(Request $request, EndpointContext $context, callable $next)
    {
        // до эндпоинта
        $response = $next($request);
        // после эндпоинта
        return $response->withHeader('X-Checked', '1');
    }
}

Подключаются двумя способами:

  • пакет — плагином на событие mxApiOnRegisterMiddleware, ровно как провайдер эндпоинтов: обработчик возвращает имя класса строкой (можно список). Так пакет привозит свою проверку вместе с собой, не заставляя администратора править конфиг на каждом сайте;
  • код сайта — ключом middleware в core/config/mxapi.php.

Порядок цепочки: встроенные обработчики → обработчики пакетов → обработчики сайта → эндпоинт. Встроенные — лимит частоты и идемпотентность — подключены всегда и идут первыми: лимит отсекает лавину до любой работы с базой, и только потом проверяется повтор по ключу идемпотентности. Владелец сайта оказывается последним рубежом перед эндпоинтом и может перекрыть решение пакета.

php
<?php
/** @var modX $modx */

if ($modx->event->name !== 'mxApiOnRegisterMiddleware') {
    return;
}

require_once $modx->getOption('core_path') . 'components/myreviews/src/Middleware/SignatureCheck.php';

$modx->event->output('MyReviews\\Api\\Middleware\\SignatureCheck');

Ненайденный класс, класс без MiddlewareInterface и обработчик, упавший в конструкторе, отбрасываются с записью в лог MODX: сломанный сторонний обработчик встраивается в каждый запрос, поэтому уронить им весь API было бы слишком дёшево. Остальная цепочка при этом работает.