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

  • 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

Надеюсь, эта статья окажется кому-нибудь полезной. Если есть какие-либо замечания, пожелания или вопросы, то добро пожаловать в комментарии. Спасибо за внимание.

Similar posts

AdBlock has stolen the banner, but banners are not teeth — they will be back

More
Ads

Comments 7

    0
    Вообще magento отказались от прямой имплементации апишных интерфейсов моделями. Более гибкий подход с Data-объектами, наследованными, например, от \Magento\Framework\Api\AbstractExtensibleObject, это позволяет добавлять дополнительные данные в API, не изменяя код модуля.

    Подробный пример реализации можно подсмотреть в модуле Magento_Customer
      0
      А можно, пожалуйста, какой-нибудь пруф, где написано, что в мадженте отказались от этого? Все, что я нашел, это вот эта статья (только для версии 2.1), где написано:
      We recommend you put data model classes in the Model/Data directory inside your module’s root directory.

      и чуть ниже для моделей:
      A model class might have a data interface implementation.

      А сущности модулей Magento_Sales, Magento_Catalog, Magento_Quote по-прежнему имплементируют интерфейсы.
        0
        1. Вообще всмысле отказались? Если я не ошибаюсь, то пример разделение «Api Data Object»/ «Old Active Record Model» применен только в привеленном Вами модуле Magento_Customer. Если мы посмотрим на предлагаемый разработчиками свежий модуль, соответствующий последним течениям Magento_Inventory/Magento_InventoryApi, то там будет применен подход как в статье. Используется общий класс, имплементирующий как интерфейс DTO из API так и унаследованный от AR модели.

        2. Чтобы позволить сторонним модулям расширять функционал через Extension Attributes не обязательно от кого-то наследоваться, имплементация дело второе, про которую не знает сторонний модуль. Достаточно чтобы ваш DTO имплементировал интерфейс \Magento\Framework\Api\ExtensibleDataInterface. (если этого по каким-то причинам не достаточно, то это технический долг). Для примера в указанном выше модуле Inventory класс имплементирующий интерфейс DTO наследуется от Magento\Framework\Model\AbstractExtensibleModel (которая уже имплементит \Magento\Framework\Api\ExtensibleDataInterface) github.com/magento-engcom/msi/blob/2.3-develop/app/code/Magento/Inventory/Model/SourceItem.php

        3. Extension Attributes это не про персист сущностей в бд (то, что существует конфиг extension_attributes.xml с настройками таблиц и джоинами это имхо дырка в абстракции и уровнях приложения). Вы можете впринципе расширять сущность, которая не персистится никак в хранилище.

        PS. Возможно maghamed нам прояснит ситуацию
          +1
          сорри, только сейчас заметил ваш диалог.
          В целом Вы isxam все правильно написали.

          Magento отказалась от наследования DTO классов от Magento\Framework\Api\AbstractExtensibleObject, и сейчас новый код, в частности новые модули Inventory, используют наследование от Magento\Framework\Model\AbstractExtensibleModel.

          Основную неразбериху, думаю вызвало то, что класс AbstractExtensibleObject помечен как `@api`, в то время как AbstractExtensibleModel не имеет соответствующей пометки.

          Вчера на внутреннем архитектурном митинге, где мы в частности обсуждали этот вопрос приняли решение:
          • Добавить PHP аннотацию `@api` для AbstractExtensibleModel
          • Добавить PHP аннотацию `@deprecated` для AbstractExtensibleObject и `@see` которая ссылается на AbstractExtensibleModel


            +1
            По поводу пункта 3.

            Вы тоже правы, что extension_attributes это не о персистенсе, более того на этом уровне сущность должна быть описана persistence-agnostic.
            И у нас по прежнему есть описание типа «join» в XSD Schema — github.com/magento/magento2/blob/2.2-develop/lib/internal/Magento/Framework/Api/etc/extension_attributes.xsd#L48-L55

            но по факту вы не найдете его использования в модулях Magento, только в тестах.
            Этот механизм, который назывался Join Directives был обьявлен как deprecated из-за причин описанных Вами и его поддержка остается только по причинам Backward Compatibility, поэтому сейчас обязанности по сохранению и извлечению атрибута возложены на программиста, зачастую это делается с помощью механизма плагинизации, например сервиса Repository::save($entity).

            Более элегантное решение с точки зрения описания Entity, а также два типа декларации: persistence-agnostic высокоуровневой декларации, и низкоуровневой декларации, которая декларативно опишет загрузку данных (для ускорения производительности, так как сейчас с этим есть определенные пробелемы, так как дополнительные атрибуты хранятся в отдельных таблицах и нужно сохранять и загружать их отдельными запросами) появятся вместе с новым ORM в Magento

              0
              Спасибо за развернутый ответ.
              «новый ORM» это будет развитие \Magento\Framework\EntityManager\EntityManager?
              В какой версии предполагается релиз этого функционала?
                0
                нет, это будет новый Entity Manager, мы добавили для старого \Magento\Framework\EntityManager\EntityManager такой комментарий в коде: github.com/magento/magento2/blob/2.2-develop/lib/internal/Magento/Framework/EntityManager/EntityManager.php#L15-L24

                Основная причина отказаться от него — неэффективная работа с экстеншен атрибутами, работа с разными хранилищами данных, отсутствие эффективного механизма Query API.

                >В какой версии предполагается релиз этого функционала?
                Какие-то части функционала нового ORM мы уже зарелизим в 2.3.*
                например декларативную схему — community.magento.com/t5/Magento-DevBlog/A-Declarative-Approach-for-Database-Schema-Upgrades/ba-p/70763
                которая уже есть в 2.3-alpha

                но полность функционал нового ORM должен быть доступен в версии 2.4

        Only users with full accounts can post comments. Log in, please.