[Перевод] Joomla 4 Rest API: создаем свои JSON-эндпоинты с нуля
По умолчанию Joomla отправляет ответы в формате JSON API, если запрос содержит Accept: application/json
или специальный заголовок JSON API. Хотя ядро Joomla не поддерживает другие типы контента, система позволяет разработчикам добавлять дополнительные форматы для ответов.
Цели материала:
Получить JSON ответ от API Joomla;
Создать необходимый плагин группы
webservices
и API-часть компонента;Использовать параметры модуля для моделирования данных, которые мы отправим в ответе API.
Что не является целью?
Обучение созданию расширений. Данное руководство предполагает, что вы уже умеете создавать расширения для Joomla 4. Для работы API потребуются плагин и компонент, но компонент может быть минимальным — без модели (Model), с простой административной частью.

Административная панель компонента
Базовая админка компонента необходима, так как XML-манифест, config.xml и access.xml располагаются только в backend-директории компонента. Даже если функционал не требуется, Joomla автоматически создаёт пункт меню компонента в панели управления. Достаточно добавить минимальное представление (view) с сообщением о том, что компонент не требует настроек.
Ключевые моменты:
Аутентификация в Joomla API:
По токену (рекомендуется).
По паролю (не рекомендуется).
Для публичного контента (например, блога) можно использовать флаг
public
при создании эндпоинта в плагинеwebservices
.
Плагин группы webservices
Плагин отвечает за регистрацию маршрутов (эндпоинтов) API и указание контроллера компонента для обработки запросов. Давайте создадим его.
В рабочей области создайте папку с именем: plg_webservices_vapi. Внутри папки создайте php-файл с именем vapi.php со следующим содержимым:
defined('_JEXEC') || die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\CMS\Router\ApiRouter;
use Joomla\Router\Route;
class PlgWebservicesVapi extends CMSPlugin
{
/**
* Registers com_vapi API's routes in the application
*
* @param ApiRouter &$router The API Routing object
*
* @return void
*
*/
public function onBeforeApiRoute(&$router)
{
$route = new Route(
['GET'],
'v1/vapi/modules/:id',
'module.displayModule',
['id' => '(\d+)'],
[
'component' => 'com_vapi',
'public' => $publicGets,
'format' => [
'application/json'
]
]
),
];
$router->addRoute($route);
}
}
Давайте выделим некоторые вещи, которые вам следует знать об этом плагине:
onBeforeApiRoute:
Этот метод требуется в каждом плагине веб-сервисов. Здесь вы определите свои эндпоинты.Создание эндпоинта. Рассмотрим подробнее параметры передаваемые в конструктор класса
Joomla\Router\Route
:['GET']
— HTTP методы которые поддерживает этот эндпоинт, значения должны быть написаны заглавными буквами. Допустимые значения:'GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'TRACE', 'PATCH'
;'v1/vapi/modules/:id'
— шаблон эндпоинта:v1
означает версию вашего апи;vapi
— cледующей частью рекомендуется указать имя вашего компонента без префиксаcom_
. (Это просто соглашение для определения эндпоинта, не обязательное для исполнения.)modules/:id
. Это имя контроллера нашего компонента плюс параметр:id
. В нашем случае этот шаблон сообщает Joomla, что этот эндпоинт будет соответствовать запросу только в том случае, если послеv1/vapi/modules/
следует значение, которое может использоваться какid
. Если этого значения нет, эндпоинт не соответствует, и Joomla не будет его использовать.
'module.displayModule'
: это имя контроллера API компонента и задача для выполнения, разделенные точкой.['id' => '(\d+)']
: Если ваш шаблон эндпоинта имеет параметры, то требуются регулярные выражения, которым должны соответствовать их значения. В нашем случае параметр id (который был определен как: id в шаблоне) должен быть целым числом, состоящим из одной или нескольких цифр (включая значение 0).[
И последний, но не менее важный параметр. В нём мы определили:
'component' => 'com_vapi',
'public' => $publicGets,
'format' => [
'application/json'
]
]'component' => 'com_vapi'
— ассоциированный компонент;'public' => true
Флаг публичности эндпоинта;'format' => ['application/json']
Здесь определяется, что наше приложение будет обрабатывать ответ в формате json. Без этого Joomla будет использовать JSON-API по умолчанию.
Не забудьте создать xml-файл плагина.
API часть компонента
Для начала убедитесь, что ваш компонент имеет базовую функциональность.
Joomla 4 позволяет создавать в компонентах API-секцию с отдельными Controller-View-Model
для JSON-вывода, аналогично site/administrator разделам.
Добавьте секцию в XML-манифест, чтобы подключить API-часть компонента.
src
В корневой папке установщика вашего компонента создайте папку api
, внутри которой создайте подпапку src
— здесь будут размещаться все файлы и папки API. Начнём с контроллера: создайте папку Controller
и файл ModuleController.php со следующим содержимым:
Controller — описание класса
namespace Carlitorweb\Component\Vapi\Api\Controller;
defined('_JEXEC') || die;
use Joomla\CMS\MVC\Factory\ApiMVCFactory;
use Joomla\CMS\Application\ApiApplication;
use Joomla\Input\Input;
use Joomla\CMS\Language\Text;
use Joomla\Component\Content\Administrator\Extension\ContentComponent;
use Joomla\CMS\Component\ComponentHelper;
class ModuleController extends \Joomla\CMS\MVC\Controller\BaseController
{
/**
* @var string $default_view Will be used as default for $viewName
*/
protected $default_view = 'modules';
/**
* @var \Joomla\Registry\Registry $moduleParams The module params to set filters in the model
*/
protected $moduleParams;
/**
* Constructor.
*
* @param array $config An optional associative array of configuration settings
*
* @param ApiMVCFactory $factory The factory.
* @param ApiApplication $app The Application for the dispatcher
* @param Input $input Input
*
* @throws \Exception
*/
public function __construct($config = array(), ApiMVCFactory $factory = null, ?ApiApplication $app = null, ?Input $input = null)
{
if (\array_key_exists('moduleParams', $config)) {
$this->moduleParams = new \Joomla\Registry\Registry($config['moduleParams']);
}
parent::__construct($config, $factory, $app, $input);
}
# your methods code from here...
}
Обратите внимание на пространство имен контроллера. Вы можете изменить Vapi
(используется в этом примере) и префикс Carlitorweb
на свои. Остальную часть (по соглашению) лучше оставить как есть, но можно полностью адаптировать под свои нужды.
Обратите внимание что стандартный JSON-API Joomla использует ApiController
, а в нашем случае (для простого JSON-ответа) достаточно наследовать BaseController
.
Мы объявили два свойства: для указания файла представления и для хранения параметров модуля, формирующих данные API-ответа.
Теперь давайте посмотрим на методы нашего класса:
Controller — Методы класса
/**
* Set the models and execute the view
*
* @throws \Exception
*/
public function displayModule(): void
{
# your code here...
}
/**
* Boot the model and set the states
*
* @param \Joomla\Registry\Registry $params The module params
*
*/
protected function getMainModelForView($params): \Joomla\Component\Content\Site\Model\ArticlesModel
{
# your code here...
}
/**
* Set the module params
*
* @param \Carlitorweb\Component\Vapi\Api\Model\ModuleModel $moduleModel
*
*/
protected function setModuleParams($moduleModel): \Joomla\Registry\Registry
{
# your code here...
}
Метод displayModule()
определен как задача в плагине webservices
и будет первым вызываемым методом контроллера — для тестирования добавьте в его начало код:
var_dump(__METHOD__);
die;
Затем, используя API-клиент (например Postman) сделайте GET запрос к URL [yourLocalRootSiteURL]/api/index.php/v1/vapi/modules/[idOfYourModule]
. Вы получите следующий ответ:
string(73)"Carlitorweb\Component\Vapi\Api\Controller\ModuleController::displayModule"
getMainModelForView()
— метод предназначен для загрузки и подготовки главной модели которую будет использовать представление. Я написал «главной» потому что Joomla позволяет представлению взаимодействовать с более чем одной моделью.
setModuleParams()
— здесь мы будем получать параметры модуля для использования в getMainModelForView()
. Как вы заметили, метод использует параметр \Carlitorweb\Component\Vapi\Api\Model\ModuleModel
. Это экземпляр пользовательской модели, который будет у API и нам необхоимо его создать. Вот где мы получим модуль, основанный на id, переданном в качестве параметра в запрошенном URL. На этом этапе вам нужно знать, что этот id должен совпадать с уже созданным модулем.
Поскольку параметры так необходимы, давайте создадим нашу модель.
Model — описание класса
В той же папке src
в которой мы создавали папку Controller
создадим папку Model
c файлом ModuleFolder.php
и следующим кодом:
state->get('moduleID', 0);
if ($mid === 0) {
throw new \InvalidArgumentException(
sprintf(
'A module ID is necessary in %s',
__METHOD__
)
);
}
/** @var \Joomla\Database\DatabaseInterface $db */
$db = $this->getDatabase();
$query = $this->getModuleQuery($db, $mid);
// Set the query
$db->setQuery($query);
// Build a cache ID for the resulting data object
$cacheId = 'com_vapi.moduleId' . $mid;
try {
/** @var \Joomla\CMS\Cache\Controller\CallbackController $cache */
$cache = Factory::getContainer()->get(CacheControllerFactoryInterface::class)
->createCacheController('callback', ['defaultgroup' => 'com_modules']);
$module = $cache->get(array($db, 'loadObject'), array(), md5($cacheId), false);
} catch (\RuntimeException $e) {
$app->getLogger()->warning(
Text::sprintf('JLIB_APPLICATION_ERROR_MODULE_LOAD', $e->getMessage()),
array('category' => 'jerror')
);
return new \stdClass();
}
return $module;
}
/**
* Get the module query
*
* @param int $mid The ID of the module
* @param \Joomla\Database\DatabaseInterface $db
*
*/
protected function getModuleQuery($db, $mid): \Joomla\Database\QueryInterface
{
$query = $db->getQuery(true);
$query->select('*')
->from($db->quoteName('#__modules'))
->where(
$db->quoteName('id') . ' = :moduleId'
)
->bind(':moduleId', $mid, ParameterType::INTEGER);
return $query;
}
}
Довольно простая модель. Мы получаем id модуля для поиска в строке $this->state->get('moduleID', 0)
. Мы должны установить этот id в нашем контроллере (мы вскоре это сделаем). Затем с помощью метода $this->getModuleQuery()
строится запрос к базе данных, который мы выполним после этого. Наконец, мы используем блок try/catch
, чтобы получить объект модуля и кэшировать его.
Когда модель готова, вернемся к нашему контроллеру, и оттуда мы сможем проверить, работает ли наша модель так, как должна.
Controller — методы класса
Теперь, когда мы можем получить определенный модуль, давайте завершим метод setModuleParams()
.
/**
* Set the module params
*
* @param \Carlitorweb\Component\Vapi\Api\Model\ModuleModel $moduleModel
*
*/
protected function setModuleParams($moduleModel): \Joomla\Registry\Registry
{
// Get the module params
$module = $moduleModel->getModule();
if (is_null($module)) {
throw new \UnexpectedValueException(
sprintf(
'$module need be of type object, %s was returned in %s()',
gettype($module), __FUNCTION__
)
);
}
return $this->moduleParams = new \Joomla\Registry\Registry($module->params);
}
Давайте посмотрим, получает ли свойство контроллера $moduleParams
ожидаемый результат. Для этого отредактируем основной метод displayModule()
:
/**
* Set the models and execute the view
*
* @throws \Exception
*/
public function displayModule(): void
{
$moduleID = $this->input->get('id', 0, 'int');
$moduleState = new \Joomla\Registry\Registry(['moduleID' => $moduleID]);
/** @var \Carlitorweb\Component\Vapi\Api\Model\ModuleModel $moduleModel */
$moduleModel = $this->factory->createModel('Module', 'Api', ['ignore_request' => true, 'state' => $moduleState]);
// Set the params who will be used by the model
if (empty($this->moduleParams)) {
$this->setModuleParams($moduleModel);
}
var_dump($this->moduleParams);
die;
}
$this->input->get('id', 0, 'int')
: Значение параметра:id
в URL (который мы определили в webservoces-плагине).'state' => $moduleState
: Обратите внимание, что мы передали ID при загрузке модели. Мы уже видели, где использовали этот ID внутри модели.
Снова используем ваш любимый API client, делаем запрос к [yourLocalRootSiteURL]/api/index.php/v1/vapi/modules/[idOfYourModule]
. Вы увидите что-то вроде:
object(Joomla\Registry\Registry)#938 (3) {
["data":protected]=>
object(stdClass)#1090 (44) {
["mode"]=>
string(6) "normal"
["show_on_article_page"]=>
int(1)
["count"]=>
int(0)
["show_front"]=>
string(4) "only"
["category_filtering_type"]=>
int(1)
["catid"]=>
array(5) {
[0]=>
int(2)
[1]=>
int(8)
[2]=>
int(9)
[3]=>
int(10)
.....
Теперь, работая с параметрами, доработаем getMainModelForView()
.
/**
* Boot the model and set the states
*
* @param \Joomla\Registry\Registry $params The module Articles - Category params
*
*/
protected function getMainModelForView($params): \Joomla\Component\Content\Site\Model\ArticlesModel
{
$mvcContentFactory = $this->app->bootComponent('com_content')->getMVCFactory();
// Get an instance of the generic articles model
/** @var \Joomla\Component\Content\Site\Model\ArticlesModel $articlesModel */
$articlesModel = $mvcContentFactory->createModel('Articles', 'Site', ['ignore_request' => true]);
if (!$articlesModel) {
throw new \RuntimeException(Text::_('JLIB_APPLICATION_ERROR_MODEL_CREATE'));
}
$appParams = ComponentHelper::getComponent('com_content')->getParams();
$articlesModel->setState('params', $appParams);
$articlesModel->setState('filter.published', ContentComponent::CONDITION_PUBLISHED);
/*
* Set the filters based on the module params
*/
$articlesModel->setState('list.start', 0);
$articlesModel->setState('list.limit', (int) $params->get('count', 0));
$catids = $params->get('catid');
$articlesModel->setState('filter.category_id', $catids);
// Ordering
$ordering = $params->get('article_ordering', 'a.ordering');
$articlesModel->setState('list.ordering', $ordering);
$articlesModel->setState('list.direction', $params->get('article_ordering_direction', 'ASC'));
$articlesModel->setState('filter.featured', $params->get('show_front', 'show'));
$excluded_articles = $params->get('excluded_articles', '');
if ($excluded_articles) {
$excluded_articles = explode("\r\n", $excluded_articles);
$articlesModel->setState('filter.article_id', $excluded_articles);
// Exclude
$articlesModel->setState('filter.article_id.include', false);
}
return $articlesModel;
}
Параметры, которые мы используем для моделирования наших данных, берутся из модуля Articles — Category. Это идентификатор, который мы запрашиваем с параметром : id. (Заметьте, что не все параметры были использованы)
В этом методе не так много пояснений, вы должны быть знакомы с этим кодом. Загружается ArticlesModel и определяется набор состояний модели для получения данных на основе параметров модуля.
Давайте снова отредактируем основной метод displayModule()
для проверки того, что мы получаем объект ArticlesModel
:
/**
* Set the models and execute the view
*
* @throws \Exception
*/
public function displayModule(): void
{
$moduleID = $this->input->get('id', 0, 'int');
$moduleState = new \Joomla\Registry\Registry(['moduleID' => $moduleID]);
/** @var \Carlitorweb\Component\Vapi\Api\Model\ModuleModel $moduleModel */
$moduleModel = $this->factory->createModel('Module', 'Api', ['ignore_request' => true, 'state' => $moduleState]);
// Set the params who will be used by the model
if(empty($this->moduleParams)) {
$this->setModuleParams($moduleModel);
}
$mainModel = $this->getMainModelForView($this->moduleParams);
var_dump($mainModel::class);die;
}
Снова, используя API клиент, сделаем запрос к эндпоинту [yourLocalRootSiteURL]/api/index.php/v1/vapi/modules/[idOfYourModule]
. Вы получите:
string(49) "Joomla\Component\Content\Site\Model\ArticlesModel"
Теперь, когда всё работает, настроим представление и получим JSON-ответ. Внесём последние правки в метод displayModule()
:
/**
* Set the models and execute the view
*
* @throws \Exception
*/
public function displayModule(): void
{
$moduleID = $this->input->get('id', 0, 'int');
$moduleState = new \Joomla\Registry\Registry(['moduleID' => $moduleID]);
/** @var \Carlitorweb\Component\Vapi\Api\Model\ModuleModel $moduleModel */
$moduleModel = $this->factory->createModel('Module', 'Api', ['ignore_request' => true, 'state' => $moduleState]);
// Set the params who will be used by the model
if(empty($this->moduleParams)) {
$this->setModuleParams($moduleModel);
}
$mainModel = $this->getMainModelForView($this->moduleParams);
/** @var \Joomla\CMS\Document\JsonDocument $document */
$document = $this->app->getDocument();
$viewType = $document->getType();
$viewName = $this->input->get('view', $this->default_view);
$viewLayout = $this->input->get('layout', 'default', 'string');
try {
/** @var \Carlitorweb\Component\Vapi\Api\View\Modules\JsonView $view */
$view = $this->getView(
$viewName,
$viewType,
'',
['moduleParams' => $this->moduleParams, 'base_path' => $this->basePath, 'layout' => $viewLayout]
);
} catch (\Exception $e) {
throw new \RuntimeException($e->getMessage());
}
// Push the model into the view (as default)
$view->setModel($mainModel, true);
// Push as secondary model the Module model
$view->setModel($moduleModel);
$view->document = $this->app->getDocument();
$view->display();
}
$this->getView()
: Joomla проверит папкуView/Modules
(из$viewName
) в API-части компонента (рядом с Controller и Model);'moduleParams' => $this->moduleParams
: Уведомление было отправлено для просмотра параметров модуля;$view->setModel()
: Здесь мы устанавливаем в объект представления две модели, которые мы используем в API. Модель по-умолчанию$mainModel
содержит данные которые мы хотим вывести, а модель $moduleModel — наша пользовательская модель (опциональная).$view->display()
: Выполняем и отображаем вывод.
Давайте создадим наш последний файл — представление.
View — Описание и методы класса
namespace Carlitorweb\Component\Vapi\Api\View\Modules;
defined('_JEXEC') || die;
use \Joomla\CMS\MVC\View\JsonView as BaseJsonView;
use \Joomla\CMS\MVC\View\GenericDataException;
use Joomla\CMS\HTML\HTMLHelper;
use \Carlitorweb\Component\Vapi\Api\Model\ModuleModel;
class JsonView extends BaseJsonView
{
/**
* @var array $fieldsToRenderList Array of allowed fields to render
*/
protected $fieldsToRenderList = [
'id',
'title',
'alias',
'displayDate',
'metadesc',
'metakey',
'params',
'displayHits',
'displayCategoryTitle',
'displayAuthorName',
];
/**
* @var array $display Extra params to prepare the articles
*/
protected $display = array();
/**
* Constructor.
*
* @param array $config A named configuration array for object construction.
*
*/
public function __construct($config = [])
{
if (\array_key_exists('moduleParams', $config))
{
$params = $config['moduleParams'];
// Display options
$this->display['show_date'] = $params->get('show_date', 0);
$this->display['show_date_field'] = $params->get('show_date_field', 'created');
$this->display['show_date_format'] = $params->get('show_date_format', 'Y-m-d H:i:s');
$this->display['show_category'] = $params->get('show_category', 0);
$this->display['show_author'] = $params->get('show_author', 0);
$this->display['show_hits'] = $params->get('show_hits', 0);
}
parent::__construct($config);
}
/**
* Set the data who will be load
*/
protected function setOutput(array $items = null): void
{
/** @var \Joomla\CMS\MVC\Model\ListModel $mainModel */
$mainModel = $this->getModel();
/** @var \Carlitorweb\Component\Vapi\Api\Model\ModuleModel $moduleModel */
$moduleModel = $this->getModel('module');
if ($items === null)
{
$items = [];
foreach ($mainModel->getItems() as $item)
{
$_item = $this->prepareItem($item, $moduleModel);
$items[] = $this->getAllowedPropertiesToRender($_item);
}
}
// Check for errors.
if (\count($errors = $this->get('Errors')))
{
throw new GenericDataException(implode("\n", $errors), 500);
}
$this->_output = $items;
}
/**
* @param \stdClass $item The article to prepare
*/
protected function getAllowedPropertiesToRender($item): \stdClass
{
$allowedFields = new \stdClass;
foreach($item as $key => $value)
{
if (in_array($key, $this->fieldsToRenderList, true))
{
$allowedFields->$key = $value;
}
}
return $allowedFields;
}
/**
* Prepare item before render.
*
* @param object $item The model item
* @param ModuleModel $moduleModel
*
* @return object
*
*/
protected function prepareItem($item, $moduleModel)
{
$item->slug = $item->alias . ':' . $item->id;
if ($this->display['show_date'])
{
$show_date_field = $this->display['show_date_field'];
$item->displayDate = HTMLHelper::_('date', $item->$show_date_field, $this->display['show_date_format']);
}
$item->displayCategoryTitle = $this->display['show_category'] ? $item->category_title : '';
$item->displayHits = $this->display['show_hits'] ? $item->hits : '';
$item->displayAuthorName = $this->display['show_author'] ? $item->author : '';
return $item;
}
/**
* Execute and display a template script.
*
* @param string $tpl The name of the template file to parse; automatically searches through the template paths.
*
* @return void
*
*/
public function display($tpl = null)
{
// remove any string that could create an invalid JSON
// such as PHP Notice, Warning, logs...
ob_clean();
// this will clean up any previously added headers, to start clean
header_remove();
$this->setOutput();
parent::display($tpl);
echo $this->document->render();
}
}
JsonView
: Это важно. Класс должен называться именно JsonView, так как Joomla ищет его по этому имени. Хотя ядро использует Joomla\CMS\MVC\View\JsonApiView, для нашего JSON-ответа достаточно наследовать Joomla\CMS\MVC\View\JsonView.setOutput()
: Используя метод$this->getModel()
, мы получаем доступ к моделям, которые мы установили в контроллере, одна из которых установлена по умолчанию, а другая требует имени в качестве ключа элемента в массивеJoomla\CMS\MVC\View\AbstractView::_models
.getAllowedPropertiesToRender()
: Если у вас есть публичный эндпоинт, вы ДОЛЖНЫ подумать над тем какие поля вы будете включать в объект JsonView. Не все поля являются подходящими для публичного отобажения; это может привести к уязвимости безопасности, известной как раскрытие информации.Например, ваш компонент форума может сохранять IP-адрес вместе с идентификатором пользователя и датой и временем создания сообщения на форуме. НЕ делайте IP-адрес и идентификатор пользователя общедоступными. Эта комбинация считается персонально идентифицируемой информацией и может привести к штрафам! Только идентификатор пользователя может быть привилегированной информацией в зависимости от контекста сайта (помните, что имена пользователей не являются привилегированной информацией, а внутренние идентификаторы пользователей являются).
$this->document->render():
Отправляет вывод.
Ещё раз отправьте запрос через ваш API-клиент по URL [yourLocalRootSiteURL]/api/index.php/v1/vapi/modules/[idOfYourModule]
. Вы получите:
[
{
"id": 11,
"title": "Typography",
"alias": "typography",
"metakey": "",
"metadesc": "",
"params": {...},
"displayDate": "2022-11-20 20:49:17",
"displayCategoryTitle": "Typography",
"displayHits": 0,
"displayAuthorName": "Carlos Rodriguez"
}
]
Готово! Базовый JSON-ответ успешно работает ;)
Habrahabr.ru прочитано 12270 раз