8 June 2018

Magento 2 REST API на примере простого модуля

PHPAPIDevelopment for e-commerceMagento
Tutorial
Приветствую вас, уважаемые хабравчане! Поскольку я занимаюсь разработкой на e-commerce платформе Magento с 2013 года, то набравшись храбрости и посчитав, что в этой области я могу себя назвать, как минимум, уверенным разработчиком, решил написать свою первую статью на хабре именно об этой системе. И начну я с реализации REST API в Magento 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

Надеюсь, эта статья окажется кому-нибудь полезной. Если есть какие-либо замечания, пожелания или вопросы, то добро пожаловать в комментарии. Спасибо за внимание.
Tags:phpmagento 2
Hubs: PHP API Development for e-commerce Magento
+5
10.9k 31
Comments 7
Ads
Top of the last 24 hours