Приветствую вас, уважаемые хабравчане! Поскольку я занимаюсь разработкой на e-commerce платформе Magento с 2013 года, то набравшись храбрости и посчитав, что в этой области я могу себя назвать, как минимум, уверенным разработчиком, решил написать свою первую статью на хабре именно об этой системе. И начну я с реализации REST API в Magento 2. Здесь из коробки есть функционал для обработки запросов и я постараюсь продемонстрировать его на примере простого модуля. Данная статья больше рассчитана на тех, кто уже работал с Маджентой. И так, кто заинтересовался, прошу под кат.
С фантазией у меня совсем плохо, поэтому пример я придумал следующий: представим, что нам надо реализовать блог, писать статьи могут только юзеры из админки. Периодически к нам стучится какая-нибудь CRM и выгружает к себе эти статьи (зачем непонятно, но так мы оправдаем использование REST API). Для упрощения модуля я специально опустил реализацию отображения статей на фронтенде и в админке (можете реализовать самостоятельно, рекомендую хорошую статью по гридам). Здесь будет затронут только функционал обработки запросов.
Для начала создаем структуру модуля, назовем его AlexPoletaev_Blog (отсутствие фантазии еще никуда не делось). Модуль размещаем в директории app/code.
Эти два файла — необходимый минимум для модуля.
Если все делать по фэншую, то нам необходимо создать service контракты (что это такое в пределах мадженты и как работает почитать можно тут и тут), чем мы и займемся:
Разберем немного подробнее эти два интерфейса. Интерфейс PostInterface отображает таблицу со статьями из нашего блога. Таблицу создадим ниже. Каждая колонка из базы данных должна иметь свой геттер и сеттер в этом интерфейсе, почему это важно — выясним позже. Интерфейс PostRepositoryInterface предоставляет стандартный набор методов для взаимодействия с базой данных и хранения в кэше загруженных сущностей. Эти же методы используются и для API. Еще одно важное замечание, наличие корректных PHPDocs в этих интерфейсах обязательно, так как Маджента, обрабатывая REST запрос, использует рефлексию для определения входящих параметров и возвращаемых значений в методах.
С помощью install скрипта создаем таблицу, где будут храниться посты из блога:
В таблице будут следующие колонки (не забываем, у нас все максимально упрощено):
Теперь необходимо создать стандартный для Мадженты набор Model, ResourceModel и Collection классов. Для чего эти классы я расписывать не буду, эта тема обширна и выходит за рамки этой статьи, кому интересно, может погуглить самостоятельно. Если в двух словах, то эти классы нужны для манипуляций с сущностями (статьями) из базы данных. Советую еще почитать про паттерны Domain Model, Repository и Service Layer.
Внимательный читатель заметит, что наша модель как раз реализует созданный ранее интерфейс и все его геттеры и сеттеры.
Заодно реализуем репозиторий и его методы:
Метод
Чтобы проще было тестировать наш модуль, создадим два консольных скрипта, которые добавляют и удаляют тестовые данные из таблицы:
Основной «фишкой» Magento 2 является повсеместное использование собственной реализации Dependency Injection. Чтобы Маджента знала какому интерфейсу какая соответствует реализация, нам необходимо указать эти зависимости в файле di.xml. Заодно и зарегистрируем в этом файле только что созданные консольные скрипты:
Теперь регистрируем роуты для REST API, делается это в файле webapi.xml:
Здесь мы указываем Мадженте какой интерфейс и какой метод из этого интерфейса использовать при запросе на определенный URL и с определенным http методом (POST, GET и тд.). Также, в целях упрощения, используется
Все дальнейшие действия подразумевают, что у вас включен developer mode. Это позволяет избежать лишних манипуляций с деплоем статик контента и DI компиляцией.
Регистрируем наш новый модуль, запускаем команду:
Проверяем, что создалась новая таблица alex_poletaev_blog_post.
Далее загружаем тестовые данные, используя наш кастомный скрипт:
Параметр admin в данном скрипте это username из таблицы admin_user (у вас он может отличаться), одним словом, юзер из админки, который пропишется в колонку author_id.
Теперь можно приступать к тестированию. Для тестов я использовал Magento 2.2.4, домен
Один из способов потестить REST API — это открыть
Получить статью с id = 2
Ответ:
Получить список статей, у которых author_id = 2
Ответ:
Удалить статью с id = 3
Ответ:
Сохраняем новую статью
Ответ:
Обратите внимание, что для запроса с http методом POST обязательно надо передать ключ post, что на самом деле соответствует входному параметру ($post) для метода
Для тех, кому интересно, что происходит во время запроса и как Маджента его обрабатывает, ниже я приведу несколько ссылок на методы с моими комментариями. Если что-то не работает, то эти методы надо дебажить в первую очередь.
Контроллер, отвечающий за обработку запроса
\Magento\Webapi\Controller\Rest::dispatch()
Далее вызывается
\Magento\Webapi\Controller\Rest::processApiRequest()
Внутри
\Magento\Webapi\Controller\Rest\InputParamsResolver::resolve()
\Magento\Webapi\Controller\Rest\Router::match() — определяется конкретный роут (внутри через
\Magento\Framework\Webapi\ServiceInputProcessor::process()
— используется
\Magento\Framework\Webapi\ServiceInputProcessor::convertValue() — несколько вариантов конвертирования массива в DataObject или в массив из DataObject
\Magento\Framework\Webapi\ServiceInputProcessor::_createFromArray() — непосредственно конвертация, где через рефлексию проверяется наличие геттеров и сеттеров (помните, я говорил выше, что мы к ним вернемся?) и то, что они имеют публичную область видимости. Далее объект заполняется данными через сеттеры.
В самом конце, в методе
\Magento\Webapi\Controller\Rest::processApiRequest(), через
Репозиторий модуля на гитхабе
Установить можно двумя способами:
1) Через composer. Для этого добавьте следующий объект к массиву
Далее набрать в терминале следующую команду:
2) Скачать файлы модуля и вручную скопировать их в директорию
Независимо от того, какой способ вы выбрали, в конце надо запустить апгрейд:
Надеюсь, эта статья окажется кому-нибудь полезной. Если есть какие-либо замечания, пожелания или вопросы, то добро пожаловать в комментарии. Спасибо за внимание.
Завязка
С фантазией у меня совсем плохо, поэтому пример я придумал следующий: представим, что нам надо реализовать блог, писать статьи могут только юзеры из админки. Периодически к нам стучится какая-нибудь CRM и выгружает к себе эти статьи (зачем непонятно, но так мы оправдаем использование REST API). Для упрощения модуля я специально опустил реализацию отображения статей на фронтенде и в админке (можете реализовать самостоятельно, рекомендую хорошую статью по гридам). Здесь будет затронут только функционал обработки запросов.
Развитие действия
Для начала создаем структуру модуля, назовем его AlexPoletaev_Blog (отсутствие фантазии еще никуда не делось). Модуль размещаем в директории app/code.
AlexPoletaev/Blog/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="AlexPoletaev_Blog" setup_version="1.0.0"/>
</config>
AlexPoletaev/Blog/registration.php
<?php
\Magento\Framework\Component\ComponentRegistrar::register(
\Magento\Framework\Component\ComponentRegistrar::MODULE,
'AlexPoletaev_Blog',
__DIR__
);
Эти два файла — необходимый минимум для модуля.
Если все делать по фэншую, то нам необходимо создать service контракты (что это такое в пределах мадженты и как работает почитать можно тут и тут), чем мы и займемся:
AlexPoletaev/Blog/Api/Data/PostInterface.php
<?php
namespace AlexPoletaev\Blog\Api\Data;
/**
* Interface PostInterface
* @package AlexPoletaev\Api\Data
* @api
*/
interface PostInterface
{
/**#@+
* Constants
* @var string
*/
const ID = 'id';
const AUTHOR_ID = 'author_id';
const TITLE = 'title';
const CONTENT = 'content';
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
/**#@-*/
/**
* @return int
*/
public function getId();
/**
* @param int $id
* @return $this
*/
public function setId($id);
/**
* @return int
*/
public function getAuthorId();
/**
* @param int $authorId
* @return $this
*/
public function setAuthorId($authorId);
/**
* @return string
*/
public function getTitle();
/**
* @param string $title
* @return $this
*/
public function setTitle(string $title);
/**
* @return string
*/
public function getContent();
/**
* @param string $content
* @return $this
*/
public function setContent(string $content);
/**
* @return string
*/
public function getCreatedAt();
/**
* @param string $createdAt
* @return $this
*/
public function setCreatedAt(string $createdAt);
/**
* @return string
*/
public function getUpdatedAt();
/**
* @param string $updatedAt
* @return $this
*/
public function setUpdatedAt(string $updatedAt);
}
AlexPoletaev/Blog/Api/PostRepositoryInterface.php
<?php
namespace AlexPoletaev\Blog\Api;
use AlexPoletaev\Blog\Api\Data\PostInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
/**
* Interface PostRepositoryInterface
* @package AlexPoletaev\Api
* @api
*/
interface PostRepositoryInterface
{
/**
* @param int $id
* @return \AlexPoletaev\Blog\Api\Data\PostInterface
*/
public function get(int $id);
/**
* @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
* @return \AlexPoletaev\Blog\Api\Data\PostSearchResultInterface
*/
public function getList(SearchCriteriaInterface $searchCriteria);
/**
* @param \AlexPoletaev\Blog\Api\Data\PostInterface $post
* @return \AlexPoletaev\Blog\Api\Data\PostInterface
*/
public function save(PostInterface $post);
/**
* @param \AlexPoletaev\Blog\Api\Data\PostInterface $post
* @return bool
*/
public function delete(PostInterface $post);
/**
* @param int $id
* @return bool
*/
public function deleteById(int $id);
}
Разберем немного подробнее эти два интерфейса. Интерфейс PostInterface отображает таблицу со статьями из нашего блога. Таблицу создадим ниже. Каждая колонка из базы данных должна иметь свой геттер и сеттер в этом интерфейсе, почему это важно — выясним позже. Интерфейс PostRepositoryInterface предоставляет стандартный набор методов для взаимодействия с базой данных и хранения в кэше загруженных сущностей. Эти же методы используются и для API. Еще одно важное замечание, наличие корректных PHPDocs в этих интерфейсах обязательно, так как Маджента, обрабатывая REST запрос, использует рефлексию для определения входящих параметров и возвращаемых значений в методах.
С помощью install скрипта создаем таблицу, где будут храниться посты из блога:
AlexPoletaev/Blog/Setup/InstallSchema.php
<?php
namespace AlexPoletaev\Blog\Setup;
use AlexPoletaev\Blog\Api\Data\PostInterface;
use AlexPoletaev\Blog\Model\ResourceModel\Post as PostResource;
use Magento\Framework\DB\Adapter\AdapterInterface;
use Magento\Framework\DB\Ddl\Table;
use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;
use Magento\Security\Setup\InstallSchema as SecurityInstallSchema;
/**
* Class InstallSchema
* @package AlexPoletaev\Blog\Setup
*/
class InstallSchema implements InstallSchemaInterface
{
/**
* Installs DB schema for a module
*
* @param SchemaSetupInterface $setup
* @param ModuleContextInterface $context
* @return void
*/
public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
{
$setup->startSetup();
$table = $setup->getConnection()
->newTable(
$setup->getTable(PostResource::TABLE_NAME)
)
->addColumn(
PostInterface::ID,
Table::TYPE_INTEGER,
null,
['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true],
'Post ID'
)
->addColumn(
PostInterface::AUTHOR_ID,
Table::TYPE_INTEGER,
null,
['unsigned' => true, 'nullable' => true,],
'Author ID'
)
->addColumn(
PostInterface::TITLE,
Table::TYPE_TEXT,
255,
[],
'Title'
)
->addColumn(
PostInterface::CONTENT,
Table::TYPE_TEXT,
null,
[],
'Content'
)
->addColumn(
'created_at',
Table::TYPE_TIMESTAMP,
null,
['nullable' => false, 'default' => Table::TIMESTAMP_INIT],
'Creation Time'
)
->addColumn(
'updated_at',
Table::TYPE_TIMESTAMP,
null,
['nullable' => false, 'default' => Table::TIMESTAMP_INIT_UPDATE],
'Update Time'
)
->addForeignKey(
$setup->getFkName(
PostResource::TABLE_NAME,
PostInterface::AUTHOR_ID,
SecurityInstallSchema::ADMIN_USER_DB_TABLE_NAME,
'user_id'
),
PostInterface::AUTHOR_ID,
$setup->getTable(SecurityInstallSchema::ADMIN_USER_DB_TABLE_NAME),
'user_id',
Table::ACTION_SET_NULL
)
->addIndex(
$setup->getIdxName(
PostResource::TABLE_NAME,
[PostInterface::AUTHOR_ID],
AdapterInterface::INDEX_TYPE_INDEX
),
[PostInterface::AUTHOR_ID],
['type' => AdapterInterface::INDEX_TYPE_INDEX]
)
->setComment('Posts')
;
$setup->getConnection()->createTable($table);
$setup->endSetup();
}
}
В таблице будут следующие колонки (не забываем, у нас все максимально упрощено):
- id — автоинкремент
- author_id — идентификатор юзера админки (внешний ключ на поле user_id из таблицы admin_user)
- title — заголовок
- content — текст статьи
- created_at — дата создания
- updated_at — дата редактирования
Теперь необходимо создать стандартный для Мадженты набор Model, ResourceModel и Collection классов. Для чего эти классы я расписывать не буду, эта тема обширна и выходит за рамки этой статьи, кому интересно, может погуглить самостоятельно. Если в двух словах, то эти классы нужны для манипуляций с сущностями (статьями) из базы данных. Советую еще почитать про паттерны Domain Model, Repository и Service Layer.
AlexPoletaev/Blog/Model/Post.php
<?php
namespace AlexPoletaev\Blog\Model;
use AlexPoletaev\Blog\Api\Data\PostInterface;
use AlexPoletaev\Blog\Model\ResourceModel\Post as PostResource;
use Magento\Framework\Model\AbstractModel;
/**
* Class Post
* @package AlexPoletaev\Blog\Model
*/
class Post extends AbstractModel implements PostInterface
{
/**
* @var string
*/
protected $_idFieldName = PostInterface::ID; //@codingStandardsIgnoreLine
/**
* @inheritdoc
*/
protected function _construct() //@codingStandardsIgnoreLine
{
$this->_init(PostResource::class);
}
/**
* @return int
*/
public function getAuthorId()
{
return $this->getData(PostInterface::AUTHOR_ID);
}
/**
* @param int $authorId
* @return $this
*/
public function setAuthorId($authorId)
{
$this->setData(PostInterface::AUTHOR_ID, $authorId);
return $this;
}
/**
* @return string
*/
public function getTitle()
{
return $this->getData(PostInterface::TITLE);
}
/**
* @param string $title
* @return $this
*/
public function setTitle(string $title)
{
$this->setData(PostInterface::TITLE, $title);
return $this;
}
/**
* @return string
*/
public function getContent()
{
return $this->getData(PostInterface::CONTENT);
}
/**
* @param string $content
* @return $this
*/
public function setContent(string $content)
{
$this->setData(PostInterface::CONTENT, $content);
return $this;
}
/**
* @return string
*/
public function getCreatedAt()
{
return $this->getData(PostInterface::CREATED_AT);
}
/**
* @param string $createdAt
* @return $this
*/
public function setCreatedAt(string $createdAt)
{
$this->setData(PostInterface::CREATED_AT, $createdAt);
return $this;
}
/**
* @return string
*/
public function getUpdatedAt()
{
return $this->getData(PostInterface::UPDATED_AT);
}
/**
* @param string $updatedAt
* @return $this
*/
public function setUpdatedAt(string $updatedAt)
{
$this->setData(PostInterface::UPDATED_AT, $updatedAt);
return $this;
}
}
AlexPoletaev/Blog/Model/ResourceModel/Post.php
<?php
namespace AlexPoletaev\Blog\Model\ResourceModel;
use AlexPoletaev\Blog\Api\Data\PostInterface;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
/**
* Class Post
* @package AlexPoletaev\Blog\Model\ResourceModel
*/
class Post extends AbstractDb
{
/**
* @var string
*/
const TABLE_NAME = 'alex_poletaev_blog_post';
/**
* Resource initialization
*
* @return void
*/
protected function _construct() //@codingStandardsIgnoreLine
{
$this->_init(self::TABLE_NAME, PostInterface::ID);
}
}
AlexPoletaev/Blog/Model/ResourceModel/Post/Collection.php
<?php
namespace AlexPoletaev\Blog\Model\ResourceModel\Post;
use AlexPoletaev\Blog\Model\Post;
use AlexPoletaev\Blog\Model\ResourceModel\Post as PostResource;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
/**
* Class Collection
* @package AlexPoletaev\Blog\Model\ResourceModel\Post
*/
class Collection extends AbstractCollection
{
/**
* @inheritdoc
*/
protected function _construct() //@codingStandardsIgnoreLine
{
$this->_init(Post::class, PostResource::class);
}
}
Внимательный читатель заметит, что наша модель как раз реализует созданный ранее интерфейс и все его геттеры и сеттеры.
Заодно реализуем репозиторий и его методы:
AlexPoletaev/Blog/Model/PostRepository.php
<?php
namespace AlexPoletaev\Blog\Model;
use AlexPoletaev\Blog\Api\Data\PostInterface;
use AlexPoletaev\Blog\Api\Data\PostSearchResultInterface;
use AlexPoletaev\Blog\Api\Data\PostSearchResultInterfaceFactory;
use AlexPoletaev\Blog\Api\PostRepositoryInterface;
use AlexPoletaev\Blog\Model\ResourceModel\Post as PostResource;
use AlexPoletaev\Blog\Model\ResourceModel\Post\Collection as PostCollection;
use AlexPoletaev\Blog\Model\ResourceModel\Post\CollectionFactory as PostCollectionFactory;
use AlexPoletaev\Blog\Model\PostFactory;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Exception\StateException;
/**
* Class PostRepository
* @package AlexPoletaev\Blog\Model
*/
class PostRepository implements PostRepositoryInterface
{
/**
* @var array
*/
private $registry = [];
/**
* @var PostResource
*/
private $postResource;
/**
* @var PostFactory
*/
private $postFactory;
/**
* @var PostCollectionFactory
*/
private $postCollectionFactory;
/**
* @var PostSearchResultInterfaceFactory
*/
private $postSearchResultFactory;
/**
* @param PostResource $postResource
* @param PostFactory $postFactory
* @param PostCollectionFactory $postCollectionFactory
* @param PostSearchResultInterfaceFactory $postSearchResultFactory
*/
public function __construct(
PostResource $postResource,
PostFactory $postFactory,
PostCollectionFactory $postCollectionFactory,
PostSearchResultInterfaceFactory $postSearchResultFactory
) {
$this->postResource = $postResource;
$this->postFactory = $postFactory;
$this->postCollectionFactory = $postCollectionFactory;
$this->postSearchResultFactory = $postSearchResultFactory;
}
/**
* @param int $id
* @return PostInterface
* @throws NoSuchEntityException
*/
public function get(int $id)
{
if (!array_key_exists($id, $this->registry)) {
$post = $this->postFactory->create();
$this->postResource->load($post, $id);
if (!$post->getId()) {
throw new NoSuchEntityException(__('Requested post does not exist'));
}
$this->registry[$id] = $post;
}
return $this->registry[$id];
}
/**
* @param \Magento\Framework\Api\SearchCriteriaInterface $searchCriteria
* @return \AlexPoletaev\Blog\Api\Data\PostSearchResultInterface
*/
public function getList(SearchCriteriaInterface $searchCriteria)
{
/** @var PostCollection $collection */
$collection = $this->postCollectionFactory->create();
foreach ($searchCriteria->getFilterGroups() as $filterGroup) {
foreach ($filterGroup->getFilters() as $filter) {
$condition = $filter->getConditionType() ? $filter->getConditionType() : 'eq';
$collection->addFieldToFilter($filter->getField(), [$condition => $filter->getValue()]);
}
}
/** @var PostSearchResultInterface $searchResult */
$searchResult = $this->postSearchResultFactory->create();
$searchResult->setSearchCriteria($searchCriteria);
$searchResult->setItems($collection->getItems());
$searchResult->setTotalCount($collection->getSize());
return $searchResult;
}
/**
* @param \AlexPoletaev\Blog\Api\Data\PostInterface $post
* @return PostInterface
* @throws StateException
*/
public function save(PostInterface $post)
{
try {
/** @var Post $post */
$this->postResource->save($post);
$this->registry[$post->getId()] = $this->get($post->getId());
} catch (\Exception $exception) {
throw new StateException(__('Unable to save post #%1', $post->getId()));
}
return $this->registry[$post->getId()];
}
/**
* @param \AlexPoletaev\Blog\Api\Data\PostInterface $post
* @return bool
* @throws StateException
*/
public function delete(PostInterface $post)
{
try {
/** @var Post $post */
$this->postResource->delete($post);
unset($this->registry[$post->getId()]);
} catch (\Exception $e) {
throw new StateException(__('Unable to remove post #%1', $post->getId()));
}
return true;
}
/**
* @param int $id
* @return bool
*/
public function deleteById(int $id)
{
return $this->delete($this->get($id));
}
}
Метод
\AlexPoletaev\Blog\Model\PostRepository::getList()
должен возвращать данные определенного формата, поэтому нам понадобится еще и такой интерфейс:AlexPoletaev/Blog/Api/Data/PostSearchResultInterface.php
<?php
namespace AlexPoletaev\Blog\Api\Data;
use Magento\Framework\Api\SearchResultsInterface;
/**
* Interface PostSearchResultInterface
* @package AlexPoletaev\Blog\Api\Data
*/
interface PostSearchResultInterface extends SearchResultsInterface
{
/**
* @return \AlexPoletaev\Blog\Api\Data\PostInterface[]
*/
public function getItems();
/**
* @param \AlexPoletaev\Blog\Api\Data\PostInterface[] $items
* @return $this
*/
public function setItems(array $items);
}
Чтобы проще было тестировать наш модуль, создадим два консольных скрипта, которые добавляют и удаляют тестовые данные из таблицы:
AlexPoletaev/Blog/Console/Command/DeploySampleDataCommand.php
<?php
namespace AlexPoletaev\Blog\Console\Command;
use AlexPoletaev\Blog\Api\PostRepositoryInterface;
use AlexPoletaev\Blog\Model\Post;
use AlexPoletaev\Blog\Model\PostFactory;
use Magento\User\Api\Data\UserInterface;
use Magento\User\Model\User;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Class DeploySampleDataCommand
* @package AlexPoletaev\Blog\Console\Command
*/
class DeploySampleDataCommand extends Command
{
/**#@+
* @var string
*/
const ARGUMENT_USERNAME = 'username';
const ARGUMENT_NUMBER_OF_RECORDS = 'number_of_records';
/**#@-*/
/**
* @var PostFactory
*/
private $postFactory;
/**
* @var PostRepositoryInterface
*/
private $postRepository;
/**
* @var UserInterface
*/
private $user;
/**
* @param PostFactory $postFactory
* @param PostRepositoryInterface $postRepository
* @param UserInterface $user
*/
public function __construct(
PostFactory $postFactory,
PostRepositoryInterface $postRepository,
UserInterface $user
) {
parent::__construct();
$this->postFactory = $postFactory;
$this->postRepository = $postRepository;
$this->user = $user;
}
/**
* @inheritdoc
*/
protected function configure()
{
$this->setName('alex_poletaev:blog:deploy_sample_data')
->setDescription('Blog: deploy sample data')
->setDefinition([
new InputArgument(
self::ARGUMENT_USERNAME,
InputArgument::REQUIRED,
'Username'
),
new InputArgument(
self::ARGUMENT_NUMBER_OF_RECORDS,
InputArgument::OPTIONAL,
'Number of test records'
),
])
;
parent::configure();
}
/**
* @inheritdoc
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$username = $input->getArgument(self::ARGUMENT_USERNAME);
/** @var User $user */
$user = $this->user->loadByUsername($username);
if (!$user->getId() && $output->getVerbosity() > 1) {
$output->writeln('<error>User is not found</error>');
return null;
}
$records = $input->getArgument(self::ARGUMENT_NUMBER_OF_RECORDS) ?: 3;
for ($i = 1; $i <= (int)$records; $i++) {
/** @var Post $post */
$post = $this->postFactory->create();
$post->setAuthorId($user->getId());
$post->setTitle('test title ' . $i);
$post->setContent('test content ' . $i);
$this->postRepository->save($post);
if ($output->getVerbosity() > 1) {
$output->writeln('<info>Post with the ID #' . $post->getId() . ' has been created.</info>');
}
}
}
}
AlexPoletaev/Blog/Console/Command/RemoveSampleDataCommand.php
<?php
namespace AlexPoletaev\Blog\Console\Command;
use AlexPoletaev\Blog\Model\ResourceModel\Post as PostResource;
use Magento\Framework\App\ResourceConnection;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Class RemoveSampleDataCommand
* @package AlexPoletaev\Blog\Console\Command
*/
class RemoveSampleDataCommand extends Command
{
/**
* @var ResourceConnection
*/
private $resourceConnection;
/**
* @param ResourceConnection $resourceConnection
*/
public function __construct(
ResourceConnection $resourceConnection
) {
parent::__construct();
$this->resourceConnection = $resourceConnection;
}
/**
* @inheritdoc
*/
protected function configure()
{
$this->setName('alex_poletaev:blog:remove_sample_data')
->setDescription('Blog: remove sample data')
;
parent::configure();
}
/**
* @inheritdoc
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$connection = $this->resourceConnection->getConnection();
$connection->truncateTable($connection->getTableName(PostResource::TABLE_NAME));
if ($output->getVerbosity() > 1) {
$output->writeln('<info>Sample data has been successfully removed.</info>');
}
}
}
Основной «фишкой» Magento 2 является повсеместное использование собственной реализации Dependency Injection. Чтобы Маджента знала какому интерфейсу какая соответствует реализация, нам необходимо указать эти зависимости в файле di.xml. Заодно и зарегистрируем в этом файле только что созданные консольные скрипты:
AlexPoletaev/Blog/etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="AlexPoletaev\Blog\Api\Data\PostInterface"
type="AlexPoletaev\Blog\Model\Post"/>
<preference for="AlexPoletaev\Blog\Api\PostRepositoryInterface"
type="AlexPoletaev\Blog\Model\PostRepository"/>
<preference for="AlexPoletaev\Blog\Api\Data\PostSearchResultInterface"
type="Magento\Framework\Api\SearchResults" />
<type name="Magento\Framework\Console\CommandList">
<arguments>
<argument name="commands" xsi:type="array">
<item name="deploy_sample_data"
xsi:type="object">AlexPoletaev\Blog\Console\Command\DeploySampleDataCommand</item>
<item name="remove_sample_data"
xsi:type="object">AlexPoletaev\Blog\Console\Command\RemoveSampleDataCommand</item>
</argument>
</arguments>
</type>
</config>
Теперь регистрируем роуты для REST API, делается это в файле webapi.xml:
AlexPoletaev/Blog/etc/webapi.xml
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/blog/posts" method="POST">
<service class="AlexPoletaev\Blog\Api\PostRepositoryInterface"
method="save"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/blog/posts/:id" method="DELETE">
<service class="AlexPoletaev\Blog\Api\PostRepositoryInterface"
method="deleteById"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/blog/posts/:id" method="GET">
<service class="AlexPoletaev\Blog\Api\PostRepositoryInterface"
method="get"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/blog/posts" method="GET">
<service class="AlexPoletaev\Blog\Api\PostRepositoryInterface"
method="getList"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
</routes>
Здесь мы указываем Мадженте какой интерфейс и какой метод из этого интерфейса использовать при запросе на определенный URL и с определенным http методом (POST, GET и тд.). Также, в целях упрощения, используется
anonymous
resource, что позволяет абсолютно любому человеку постучаться в наш API, в противном случае надо настраивать права доступа (ACL).Кульминация
Все дальнейшие действия подразумевают, что у вас включен developer mode. Это позволяет избежать лишних манипуляций с деплоем статик контента и DI компиляцией.
Регистрируем наш новый модуль, запускаем команду:
php bin/magento setup:upgrade
.Проверяем, что создалась новая таблица alex_poletaev_blog_post.
Далее загружаем тестовые данные, используя наш кастомный скрипт:
php bin/magento -v alex_poletaev:blog:deploy_sample_data admin
Параметр admin в данном скрипте это username из таблицы admin_user (у вас он может отличаться), одним словом, юзер из админки, который пропишется в колонку author_id.
Теперь можно приступать к тестированию. Для тестов я использовал Magento 2.2.4, домен
http://m224ce.local/
.Один из способов потестить REST API — это открыть
http://m224ce.local/swagger
и воспользоваться функционалом swagger-а, но следует помнить, метод getList
там работает некорректно. Я также проверил все методы с помощью curl, примеры:Получить статью с id = 2
curl -X GET -H "Accept: application/json" "http://m224ce.local/rest/all/V1/blog/posts/2"
Ответ:
{"id":2,"author_id":1,"title":"test title 2","content":"test content 2","created_at":"2018-06-06 21:35:54","updated_at":"2018-06-06 21:35:54"}
Получить список статей, у которых author_id = 2
curl -g -X GET -H "Accept: application/json" "http://m224ce.local/rest/all/V1/blog/posts?searchCriteria[filterGroups][0][filters][0][field]=author_id&searchCriteria[filterGroups][0][filters][0][value]=1&searchCriteria[filterGroups][0][filters][0][conditionType]=eq"
Ответ:
{"items":[{"id":1,"author_id":1,"title":"test title 1","content":"test content 1","created_at":"2018-06-06 21:35:54","updated_at":"2018-06-06 21:35:54"},{"id":2,"author_id":1,"title":"test title 2","content":"test content 2","created_at":"2018-06-06 21:35:54","updated_at":"2018-06-06 21:35:54"},{"id":3,"author_id":1,"title":"test title 3","content":"test content 3","created_at":"2018-06-06 21:35:54","updated_at":"2018-06-06 21:35:54"}],"search_criteria":{"filter_groups":[{"filters":[{"field":"author_id","value":"1","condition_type":"eq"}]}]},"total_count":3}
Удалить статью с id = 3
curl -X DELETE -H "Accept: application/json" "http://m224ce.local/rest/all/V1/blog/posts/3"
Ответ:
true
Сохраняем новую статью
curl -X POST -H "Content-Type: application/json" -H "Accept: application/json" -d '{"post": {"author_id": 1, "title": "test title 4", "content": "test content 4"}}' "http://m224ce.local/rest/all/V1/blog/posts"
Ответ:
{"id":4,"author_id":1,"title":"test title 4","content":"test content 4","created_at":"2018-06-06 21:44:24","updated_at":"2018-06-06 21:44:24"}
Обратите внимание, что для запроса с http методом POST обязательно надо передать ключ post, что на самом деле соответствует входному параметру ($post) для метода
\AlexPoletaev\Blog\Api\PostRepositoryInterface::save()
Развязка
Для тех, кому интересно, что происходит во время запроса и как Маджента его обрабатывает, ниже я приведу несколько ссылок на методы с моими комментариями. Если что-то не работает, то эти методы надо дебажить в первую очередь.
Контроллер, отвечающий за обработку запроса
\Magento\Webapi\Controller\Rest::dispatch()
Далее вызывается
\Magento\Webapi\Controller\Rest::processApiRequest()
Внутри
processApiRequest
вызывается много других методов, но следующий самый важный\Magento\Webapi\Controller\Rest\InputParamsResolver::resolve()
\Magento\Webapi\Controller\Rest\Router::match() — определяется конкретный роут (внутри через
\Magento\Webapi\Model\Rest\Config::getRestRoutes()
метод по данным из реквеста вытаскиваются все подходящие роуты). Объект роута содержит все необходимые данные, чтобы обработать запрос — класс, метод, права доступа и т.д.\Magento\Framework\Webapi\ServiceInputProcessor::process()
— используется
\Magento\Framework\Reflection\MethodsMap::getMethodParams()
, где через рефлексию вытаскиваются параметры метода\Magento\Framework\Webapi\ServiceInputProcessor::convertValue() — несколько вариантов конвертирования массива в DataObject или в массив из DataObject
\Magento\Framework\Webapi\ServiceInputProcessor::_createFromArray() — непосредственно конвертация, где через рефлексию проверяется наличие геттеров и сеттеров (помните, я говорил выше, что мы к ним вернемся?) и то, что они имеют публичную область видимости. Далее объект заполняется данными через сеттеры.
В самом конце, в методе
\Magento\Webapi\Controller\Rest::processApiRequest(), через
call_user_func_array
вызывается метод объекта репозитория.Эпилог
Репозиторий модуля на гитхабе
Установить можно двумя способами:
1) Через composer. Для этого добавьте следующий объект к массиву
repositories
в файле composer.json
{
"type": "git",
"url": "https://github.com/alexpoletaev/magento2-blog-demo"
}
Далее набрать в терминале следующую команду:
composer require alexpoletaev/magento2-blog-demo:dev-master
2) Скачать файлы модуля и вручную скопировать их в директорию
app/code/AlexPoletaev/Blog
Независимо от того, какой способ вы выбрали, в конце надо запустить апгрейд:
php bin/magento setup:upgrade
Надеюсь, эта статья окажется кому-нибудь полезной. Если есть какие-либо замечания, пожелания или вопросы, то добро пожаловать в комментарии. Спасибо за внимание.