[Перевод] Joomla 4 Rest API: создаем свои JSON-эндпоинты с нуля

По умолчанию Joomla отправляет ответы в формате JSON API, если запрос содержит Accept: application/json или специальный заголовок JSON API. Хотя ядро Joomla не поддерживает другие типы контента, система позволяет разработчикам добавлять дополнительные форматы для ответов.

Цели материала:

  • Получить JSON ответ от API Joomla;

  • Создать необходимый плагин группы webservices и API-часть компонента;

  • Использовать параметры модуля для моделирования данных, которые мы отправим в ответе API.

Что не является целью? ​

  • Обучение созданию расширений. Данное руководство предполагает, что вы уже умеете создавать расширения для Joomla 4. Для работы API потребуются плагин и компонент, но компонент может быть минимальным — без модели (Model), с простой административной частью.

Basic Component Dashboard View
Представление панели управления компонента

Административная панель компонента

Базовая админка компонента необходима, так как 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 раз