REST API
Headless-слой: внешнее приложение — Nuxt, Next.js, мобильное, интеграция — ходит на сайт по HTTP и получает JSON. Сессии у такого клиента нет, csrf-токена вернуть эхом неоткуда — вместо этого он предъявляет bearer-токен.
По умолчанию наружу не отдаётся ничего. Пока не выписан токен и не опубликован ни один объект, все эндпоинты отвечают 401, а CORS молчит. Обычный серверный сайт на PageBlocks живёт так, будто этого слоя нет.
Адреса
GET /api/pb/v1/health жив ли API; без токена
GET /api/pb/v1/me что за токен и какие у него права
GET /api/pb/v1/objects какие объекты доступны этому токену
GET /api/pb/v1/objects/{имя} список
GET /api/pb/v1/objects/{имя}/{id} одна запись
POST /api/pb/v1/objects/{имя} создать ┐ только если объект это
PATCH /api/pb/v1/objects/{имя}/{id} изменить │ разрешил и у токена есть
DELETE /api/pb/v1/objects/{имя}/{id} удалить ┘ право на записьПрефикс api/pb/, а не api/, — чтобы не столкнуться с собственными маршрутами сайта.
Токены
Заводятся в менеджере: PageBlocks → API-токены. Полный токен показывается один раз, при создании; на сервере лежит только его sha256. Потерялся — «Перевыпустить» в меню строки, старый умирает сразу.
curl -H 'Authorization: Bearer pb_…' https://site/api/pb/v1/meПринимается и X-API-Key: pb_… — для клиентов за прокси, которые вырезают Authorization.
Права — строки вида cities.read. Маршрут требует конкретное право, токен его либо несёт, либо нет:
| В токене | Покрывает |
|---|---|
cities.read | ровно это право |
cities.* | все права группы cities |
* | всё |
У generic-эндпоинтов имя права выводится из имени объекта: cities.read для чтения, cities.write для записи. В своих маршрутах право называется явно:
Route::get('api/v1/orders', 'OrderApi@index')->middleware('ApiAuth:orders.read');
Route::get('api/v1/ping', 'PingApi@index')->middleware('ApiAuth'); // любой живой токенТокену можно задать срок действия и пользователя MODX, от имени которого работают запросы. Снятая галочка «Включено» отзывает токен, не удаляя строку.
Что отдавать: два пути
Галочка в конструкторе
Окно таблицы → вкладка API: включить, отметить поля, при необходимости добавить операции записи. Годится для контентных таблиц конструктора.
Остальное выводится само: поиск идёт по полям, уже отмеченным искомыми, обязательные поля становятся правилами валидации при записи, имя в адресе — транслит названия таблицы (переопределяется).
Два предохранителя, которые выглядят как «не работает», но так и задумано:
- не отмечено ни одного поля — объект не публикуется, даже с галочкой: случайно поставленная галочка не должна открыть таблицу целиком;
- операции по умолчанию только на чтение — публикация объекта и разрешение писать в него снаружи это разные решения.
Отдаются только опубликованные строки.
Декларация в core/App/api.php
Для всего, чего нет в конструкторе: свои модели, заявки, пользователи, ресурсы. Файл site-owned, обновление компонента его не трогает.
return [
'cities' => [
'model' => City::class,
'fields' => ['id', 'city_name', 'alias'],
'filterable' => ['id', 'alias', 'city_name'],
'searchable' => ['city_name', 'alias'],
'sortable' => ['id', 'city_name'],
'with' => ['country'],
'per_page' => 20,
'max_per_page' => 100,
'default_sort' => 'id',
'query' => fn(Builder $q) => $q->whereNotNull('published_at'),
// Запись: без этого доступно только чтение.
'operations' => ['list', 'show', 'create', 'update'],
'writable' => ['city_name', 'alias'],
'rules' => ['city_name' => 'required|string'],
'scopes' => ['read' => 'cities.read', 'write' => 'cities.write'],
],
];Имя из файла перекрывает одноимённую таблицу конструктора — файл считается более точным выражением намерения.
Компоненты-сателлиты регистрируются кодом, по событию OnPageBlocksApiRegister:
ApiRegistry::register('orders', [...]);Когда ни то, ни другое
Реестр закрывает однотипный CRUD. Агрегация, склейка нескольких моделей, бизнес-правила — это свой маршрут и свой контроллер, ApiAuth навешивается руками. Одно другому не мешает.
Параметры запроса
?filter[model_id]=7&filter[city_name][like]=Киш&sort=-id&fields=id,city_name&limit=20&offset=40| Параметр | Что делает |
|---|---|
filter[поле]=значение | равно |
filter[поле][оператор]=значение | eq ne gt gte lt lte like in nin between null |
search=слово | по полям из searchable |
sort=-id,name | минус — по убыванию |
fields=id,name | сузить ответ |
with=country | подтянуть связь из with |
limit, offset, page | постранично; limit режется по max_per_page |
Поле вне белого списка игнорируется молча
Так же ведёт себя неизвестный оператор. Это главный источник недоумения: «фильтр не работает» почти всегда значит, что поле не объявлено в filterable.
Фильтровать можно только по опубликованным полям — иначе содержимое скрытого поля вычисляется перебором по ответам «да/нет».
В like и search символы % и _ экранируются: клиент передаёт слово, а не шаблон.
Формат ответа
{
"data": [ { "id": 1, "city_name": "Москва" } ],
"meta": { "total": 48748, "count": 20, "limit": 20, "offset": 0, "has_more": true },
"links": { "self": "…", "next": "…", "prev": "…" }
}Одна запись — {"data": {…}}. Ошибка — {"error": "not_found", "message": "…"}, у 422 добавляется details с разбором по полям.
Это не менеджерский {success, total, results}. Тот принадлежит гридам ExtJS и меняется вместе с ними; этот — контракт с чужим приложением.
Коды: 401 нет или плох токен, 403 не хватает права, 404 нет объекта или записи, 405 операция не разрешена, 422 не прошла валидация, 429 превышен лимит частоты.
CORS
Выключен, пока в pageblocks_cors_origins пусто.
| Настройка | По умолчанию |
|---|---|
pageblocks_cors_origins | пусто (выключено); * или https://*.example.com |
pageblocks_cors_paths | api/* |
pageblocks_cors_methods | GET,POST,PUT,PATCH,DELETE,OPTIONS |
pageblocks_cors_headers | Authorization,Content-Type,X-Requested-With,X-API-Key |
pageblocks_cors_credentials | нет |
pageblocks_cors_max_age | 86400 |
Настройки не появляются сами
Они объявлены в сборке пакета, а она отрабатывает только при сборке — на площадке их заводит установка. Отсутствие настройки читается как «выключено», поэтому забытый шаг выглядит не ошибкой: заголовков Access-Control-* просто нет, браузер блокирует запрос, а в логе сервера чистый 200.
Preflight (OPTIONS) обрабатывается до маршрутизации — иначе он умирал бы на 405, потому что OPTIONS не зарегистрирован там, где есть GET.
Ограничение частоты
120 запросов в минуту на служебные эндпоинты и на объекты, по IP. Сверх лимита — 429, заголовки X-RateLimit-* есть у всех ответов.
OpenAPI и Swagger
PageBlocks → Документация API — Swagger UI со списком адресов, полями параметров и кнопкой «выполнить». Чтобы запрос ушёл, нажмите Authorize и вставьте токен.
Спецификация собирается из того же реестра, который обслуживает запросы, — поэтому она не может устареть: объект появляется в ней ровно тогда, когда поставлена галочка или добавлена строка в App/api.php.
| Адрес | Кто пускает | Что отдаёт |
|---|---|---|
/mgr/pb/docs?ctx=mgr | сессия менеджера | страницу Swagger UI |
/mgr/pb/openapi.json?ctx=mgr | сессия менеджера | полную спецификацию |
/api/pb/v1/openapi.json | API-токен | спецификацию, урезанную по правам токена |
ctx=mgr обязателен: контекст маршрута берётся из запроса, без параметра менеджерский адрес просто не найдётся.
Внешнему разработчику отдают не ссылку на страницу, а сам JSON — он открывается в Postman и Insomnia и скармливается генераторам клиентов:
npx openapi-typescript https://site/api/pb/v1/openapi.json -o api.d.tsФайлы Swagger UI грузятся с CDN. Нет сети или CDN заблокирован — вместо пустой страницы показывается объяснение и адрес спецификации; сам API при этом работает, не открывается только витрина.
Адрес в поле servers берёт схему у текущего запроса, а не из site_url: после переезда на https настройка сплошь и рядом остаётся с http://, и кнопка «выполнить» упиралась бы в редирект или блокировку смешанного содержимого.
Чего пока нет
- связей глубже одного уровня;
- webhooks и
Idempotency-Key; - generic-эндпоинтов для ресурсов MODX — они публикуются через
App/api.phpкак обычная модель.
Отладка
| Симптом | Что проверить |
|---|---|
| Это я или сервер? | GET /api/pb/v1/health — токен не нужен |
| Токен рабочий? | GET /api/pb/v1/me — показывает права |
| Объект опубликован? | GET /api/pb/v1/objects — показывает только доступное |
404 unknown_object на объект, который «точно есть» | почти всегда снятая галочка или пустой список полей |
| 500 с пустым телом | core/cache/logs/error.log, там полные трейсы |