Creating custom REST API endpoints in Magento

In certain situations, you need a custom API endpoint in Magento. Let’s assume a scenario where you need to create two endpoints. First - to get a product feed with specific filters and a data set for an external service. Secondly - to modify some of the product attributes from the feed. Of course, you can create a simple controller and return JSON in it, but there is a better way - a Magento way ;)

Why not just use a simple controller?

Why not just a controller with JSON response? Let's see: why use a custom API endpoint in Magento? Well, first of all, you have to create some kind of authorization to limit access or make it available for everyone.

Making the endpoint public is risky. If the endpoint is “heavy” (e.g. gets a lot of data from the database and then processes it before returning), sending a lot of requests to it by a malicious party can make your server unavailable. Also, public, anonymous access to an endpoint that allows modification of product data is not the best idea, I would say :)

As an authentication, you could configure Basic access authentication, whitelist IP addresses on the web server or hardcode a token required in the request. However, managing it can be problematic. Imagine there are multiple services accessing these endpoints. Each has its own credentials and some of them require to have separate credentials for getting and modifying the data. Some want the data in JSON, others - in XML. It would be a nightmare to manage all of this.

Even if it is something simple at the beginning, you shouldn’t assume it won’t grow and fit the situation above in the future. Why not future-proof it from the start?

REST API in Magento

Magento has a lot of endpoints out-of-the-box. You will find them in the module’s /etc/webapi.xml file. For example, look at the module “Magento_Catalog”.

Basically, there are 4 components to the endpoint:

  1. endpoint declaration,
  2. ACL record (unless you are using an existing one),
  3. interface that will map data to an object and vice versa,
  4. implementation of the interface.

Enough theory, let’s write some code...

Specification

Let’s assume a store owner wants to outsource managing product descriptions to some company. Magento already has API endpoints to get and modify product data. But access to these endpoints would mean access to all of the data, not just the description. Thus, let’s create custom custom API endpoint in Magento. It will allow us to:

  1. Get basic data of a single product.
  2. Receive basic data of all products.
  3. Modify a product’s description.

I used Magento 2.3.5 with sample data in this example, but - as far as I know - everything should work the same on every 2.x version. Our module will be called Magently_RestApi.

Implementation

Module

Firstly, let’s start by creating a new module.

app/code/Magently/RestApi/etc/module.xml:

html
1 2 3 4 5 6 7 8 9 10 <?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="Magently_RestApi" setup_version="1.0.0"> <sequence> <module name="Magento_Webapi" /> <module name="Magento_Catalog" /> </sequence> </module> </config>

app/code/Magently/RestApi/registration.php:

html
1 2 3 4 5 6 7 8 9 <?php use Magento\Framework\Component\ComponentRegistrar; ComponentRegistrar::register( ComponentRegistrar::MODULE, 'Magently_RestApi', __DIR__ );

ACL

Now let’s create custom ACL entries inapp/code/Magently/RestApi/etc/acl.xml

html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 <?xml version="1.0"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd"> <acl> <resources> <resource id="Magento_Backend::admin"> <resource id="Magently_RestApi::products" title="Magently API - Products" translate="title" sortOrder="110"> <resource id="Magently_RestApi::products_get" title="Get product" translate="title" sortOrder="10" /> <resource id="Magently_RestApi::products_get_list" title="Get product list" translate="title" sortOrder="20" /> <resource id="Magently_RestApi::products_set_description" title="Set description" translate="title" sortOrder="30" /> </resource> </resource> </resources> </acl> </config>

We defined one main record and three child records - one for each endpoint, to have full control over who can access what.

Defining endpoints

Next - define the endpoints in app/code/Magently/RestApi/etc/webapi.xml

html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 <?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/rest_test/getProduct/:id" method="GET"> <service class="Magently\RestApi\Api\ProductRepositoryInterface" method="getItem" /> <resources> <resource ref="Magently_RestApi::products_get" /> </resources> </route> <route url="/V1/rest_test/getProducts" method="GET"> <service class="Magently\RestApi\Api\ProductRepositoryInterface" method="getList" /> <resources> <resource ref="Magently_RestApi::products_get_list" /> </resources> </route> <route url="/V1/rest_test/setDescription" method="PUT"> <service class="Magently\RestApi\Api\ProductRepositoryInterface" method="setDescription" /> <resources> <resource ref="Magently_RestApi::products_set_description" /> </resources> </route> </routes>

route.url is a URL of the endpoint, the full address would be: <domain>/rest/<store_code><route.url>

If you skip the store code, the default store will be used. So the example of the full URL would be http://example.com/rest/V1/rest_test/getProducts

Magento requires this value to start with a slash, “V” and an integer number, which should inform about our API version. Make sure the URL you want to use isn’t already in use (in combination with the same method).

route.method defines the request method. You should follow the rules of the REST API specification:

  • GET - to receive data,
  • POST - to create new object(s),
  • PUT - to update existing object(s),
  • DELETE - to delete object(s).

Both URL and method have to match in the request.

service informs about the interface and the method to be called when the endpoint is reached.

resource sets the ACL resource that is required to have access to the endpoint. If you want to make it public (no authentication), you can use: <resource ref="anonymous" />

Interfaces

There are a few rules you have to follow when creating the interfaces. Basically, @param and @return in PHPDoc have to be very specific. It allows Magento to correctly map JSON/XML data to objects and the other way around.

\Magently\RestApi\Api\ProductRepositoryInterface - this will be our entry point for all of the endpoints. Let’s create it.

php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 <?php // app/code/Magently/RestApi/Api/ProductRepositoryInterface.php namespace Magently\RestApi\Api; /** * Interface ProductRepositoryInterface * * @api */ interface ProductRepositoryInterface { /** * Return a filtered product. * * @param int $id * @return \Magently\RestApi\Api\ResponseItemInterface * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function getItem(int $id); /** * Return a list of the filtered products. * * @return \Magently\RestApi\Api\ResponseItemInterface[] */ public function getList(); /** * Set descriptions for the products. * * @param \Magently\RestApi\Api\RequestItemInterface[] $products * @return void */ public function setDescription(array $products); }

As you can see, we defined that getItem()will return an instance of ResponseItemInterface, getItems() - array of them and setDescription() will expect an array of RequestItemInterface instances on input. You probably noticed that getItem() can throw NoSuchEntityException. In the webapi area, it will be handled similarly to NotFoundException in the frontend area - the response code will be set to 404.

Two of the methods have arguments. Where will the values come from?

In case of getItem() the $id value will come from URL. We set it to /V1/rest_test/getProduct/:id. The :id part will be the value of this argument. The names in both cases have to match ($id and :id). If the method’s argument would have a default value declared, it would be optional in the URL. Of course, you can add more arguments if you need them.

If you want to receive more complex data on input, it’s better to use an interface. Then the request body will be converted to its instance.

php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 <?php // app/code/Magently/RestApi/Api/RequestItemInterface.php namespace Magently\RestApi\Api; /** * Interface RequestItemInterface * * @api */ interface RequestItemInterface { const DATA_ID = 'id'; const DATA_DESCRIPTION = 'description'; /** * @return int */ public function getId(); /** * @return string */ public function getDescription(); /** * @param int $id * @return $this */ public function setId(int $id); /** * @param string $description * @return $this */ public function setDescription(string $description); }

We want users to be able to modify the description of the product. Thus, the input data will consist of ID (to figure out which product) and description (the new value). We need getters and setters for the data we want to use. The constants, of course, are not required, but they will come into use soon.

Now Magento knows how the input like this:
{“id”: 23, “description”: “This is a brand new description”}
translate into a RequestItemInterface instance.

If you need more than one word in the data key, then use “_” and camelCase in getters/setters. An example:
“sku_prefix” -> getSkuPrefix(), setSkuPrefix(string $skuPrefix)

There is one more interface left to create.

php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 <?php // app/code/Magently/RestApi/Api/ResponseItemInterface.php namespace Magently\RestApi\Api; /** * Interface ResponseItemInterface * * @api */ interface ResponseItemInterface { const DATA_ID = 'id'; const DATA_SKU = 'sku'; const DATA_NAME = 'name'; const DATA_DESCRIPTION = 'description'; /** * @return int */ public function getId(); /** * @return string */ public function getSku(); /** * @return string */ public function getName(); /** * @return string|null */ public function getDescription(); // optional setters: /** * @param int $id * @return $this */ public function setId(int $id); /** * @param string $sku * @return $this */ public function setSku(string $sku); /** * @param string $name * @return $this */ public function setName(string $name); /** * @param string $description * @return $this */ public function setDescription(string $description); }

As you might’ve noticed, I added a comment that setters are optional. And they are optional for data that will be returned by the endpoint (but not for the input data!). In the interface implementation, you will usually have to use setters to set data anyway, so we may as well have it here for autocompletion in IDE, etc.

The actual code

Now that we have ACL, endpoints, and data structure defined, it’s time to create classes that implement our interfaces and process data.

php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 <?php // app/code/Magently/RestApi/Model/Api/RequestItem.php namespace Magently\RestApi\Model\Api; use Magently\RestApi\Api\RequestItemInterface; use Magento\Framework\DataObject; /** * Class RequestItem */ class RequestItem extends DataObject implements RequestItemInterface { /** * @return int */ public function getId() { return $this->_getData(self::DATA_ID); } /** * @return string */ public function getDescription() { return $this->_getData(self::DATA_DESCRIPTION); } /** * @param int $id * @return $this */ public function setId(int $id) { return $this->setData(self::DATA_ID, $id); } /** * @param string $description * @return $this */ public function setDescription(string $description) { return $this->setData(self::DATA_DESCRIPTION, $description); } }
php
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 <?php // app/code/Magently/RestApi/Model/Api/ResponseItem.php namespace Magently\RestApi\Model\Api; use Magently\RestApi\Api\ResponseItemInterface; use Magento\Framework\DataObject; /** * Class ResponseItem */ class ResponseItem extends DataObject implements ResponseItemInterface { /** * @return int */ public function getId() { return $this->_getData(self::DATA_ID); } /** * @return string */ public function getSku() { return $this->_getData(self::DATA_SKU); } /** * @return string */ public function getName() { return $this->_getData(self::DATA_NAME); } /** * @return string|null */ public function getDescription() { return $this->_getData(self::DATA_DESCRIPTION); } /** * @param int $id * @return $this */ public function setId(int $id) { return $this->setData(self::DATA_ID, $id); } /** * @param string $sku * @return $this */ public function setSku(string $sku) { return $this->setData(self::DATA_SKU, $sku); } /** * @param string $name * @return $this */ public function setName(string $name) { return $this->setData(self::DATA_NAME, $name); } /** * @param string $description * @return $this */ public function setDescription(string $description) { return $this->setData(self::DATA_DESCRIPTION, $description); } }

These two classes are pretty simple. They extend DataObject and implement getters and setters. To be clear, they don’t have to extend DataObject, it was just easier (and quicker) for me to implement it this way.

Let’s implement the last part - the class that actually does something ;)

We want to be able to:

  1. Revice data of the single product, determined by ID and converted into an instance of ResponseItemInterface.
  2. Get a complete list of the products, each converted to an instance of ResponseItemInterface.
  3. Udpate the description of the multiple products in a single request.
html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 <?php // app/code/Magently/RestApi/Model/Api/ProductRepository.php namespace Magently\RestApi\Model\Api; use Magently\RestApi\Api\ProductRepositoryInterface; use Magently\RestApi\Api\RequestItemInterfaceFactory; use Magently\RestApi\Api\ResponseItemInterfaceFactory; use Magento\Catalog\Api\Data\ProductInterface; use Magento\Catalog\Model\ResourceModel\Product\Action; use Magento\Catalog\Model\ResourceModel\Product\CollectionFactory; use Magento\Framework\Exception\NoSuchEntityException; use Magento\Store\Model\StoreManagerInterface; /** * Class ProductRepository */ class ProductRepository implements ProductRepositoryInterface { /** * @var \Magento\Catalog\Model\ResourceModel\Product\Action */ private $productAction; /** * @var \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory */ private $productCollectionFactory; /** * @var \Magently\RestApi\Api\RequestItemInterfaceFactory */ private $requestItemFactory; /** * @var \Magently\RestApi\Api\ResponseItemInterfaceFactory */ private $responseItemFactory; /** * @var \Magento\Store\Model\StoreManagerInterface */ private $storeManager; /** * @param \Magento\Catalog\Model\ResourceModel\Product\Action $productAction * @param \Magento\Catalog\Model\ResourceModel\Product\CollectionFactory $productCollectionFactory * @param \Magently\RestApi\Api\RequestItemInterfaceFactory $requestItemFactory * @param \Magently\RestApi\Api\ResponseItemInterfaceFactory $responseItemFactory * @param \Magento\Store\Model\StoreManagerInterface $storeManager */ public function __construct( Action $productAction, CollectionFactory $productCollectionFactory, RequestItemInterfaceFactory $requestItemFactory, ResponseItemInterfaceFactory $responseItemFactory, StoreManagerInterface $storeManager ) { $this->productAction = $productAction; $this->productCollectionFactory = $productCollectionFactory; $this->requestItemFactory = $requestItemFactory; $this->responseItemFactory = $responseItemFactory; $this->storeManager = $storeManager; } /** * {@inheritDoc} * * @param int $id * @return \Magently\RestApi\Api\ResponseItemInterface * @throws \Magento\Framework\Exception\NoSuchEntityException */ public function getItem(int $id) { $collection = $this->getProductCollection() ->addAttributeToFilter('entity_id', ['eq' => $id]); /** @var \Magento\Catalog\Api\Data\ProductInterface $product */ $product = $collection->getFirstItem(); if (!$product->getId()) { throw new NoSuchEntityException(__('Product not found')); } return $this->getResponseItemFromProduct($product); } /** * {@inheritDoc} * * @return \Magently\RestApi\Api\ResponseItemInterface[] */ public function getList() { $collection = $this->getProductCollection(); $result = []; /** @var \Magento\Catalog\Api\Data\ProductInterface $product */ foreach ($collection->getItems() as $product) { $result[] = $this->getResponseItemFromProduct($product); } return $result; } /** * {@inheritDoc} * * @param \Magently\RestApi\Api\RequestItemInterface[] $products * @return void */ public function setDescription(array $products) { foreach ($products as $product) { $this->setDescriptionForProduct( $product->getId(), $product->getDescription() ); } } /** * @return \Magento\Catalog\Model\ResourceModel\Product\Collection */ private function getProductCollection() { /** @var \Magento\Catalog\Model\ResourceModel\Product\Collection $collection */ $collection = $this->productCollectionFactory->create(); $collection ->addAttributeToSelect( [ 'entity_id', ProductInterface::SKU, ProductInterface::NAME, 'description' ], 'left' ); return $collection; } /** * @param \Magento\Catalog\Api\Data\ProductInterface $product * @return \Magently\RestApi\Api\ResponseItemInterface */ private function getResponseItemFromProduct(ProductInterface $product) { /** @var \Magently\RestApi\Api\ResponseItemInterface $responseItem */ $responseItem = $this->responseItemFactory->create(); $responseItem->setId($product->getId()) ->setSku($product->getSku()) ->setName($product->getName()) ->setDescription($product->getDescription()); return $responseItem; } /** * Set the description for the product. * * @param int $id * @param string $description * @return void */ private function setDescriptionForProduct(int $id, string $description) { $this->productAction->updateAttributes( [$id], ['description' => $description], $this->storeManager->getStore()->getId() ); } }

And the last thing in the module we need to add is di.xml, to set the preferences for the interfaces.

app/code/Magently/RestApi/etc/di.xml:

html
1 2 3 4 5 6 7 8 9 10 <?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="Magently\RestApi\Api\ProductRepositoryInterface" type="Magently\RestApi\Model\Api\ProductRepository" /> <preference for="Magently\RestApi\Api\RequestItemInterface" type="Magently\RestApi\Model\Api\RequestItem" /> <preference for="Magently\RestApi\Api\ResponseItemInterface" type="Magently\RestApi\Model\Api\ResponseItem" /> </config>

Bear in mind, this is just a simple example to show you the basics of REST API in Magento. You should probably add some kind of “pagination”, to avoid sending large chunks of data, before actually using this module in a real-life scenario. Also, some kind of description validation would be in order.

We are done with the code. Now it’s time to create an integration in the backend to generate a token we can use for authentication.

Creating integration

To create a new integration, go to the backend, open System > Extensions > Integrations, and click on the “Add New Integration” button.

On the first tab, all you need to enter are Name and Your password. Name can be anything - it's information for you to be able to identify the integration. And the password is your (current admin user) password.

The second tab is the place where you select resources from ACL the integration will have access to. At the bottom, there should be our “Magently API - Products” resource. Just select it.

Now, click on the arrow next to the “Save” button and then on “Save & Activate”. You can also just save it and activate it later on. During activation, you will see a summary of what you want to give access to. Confirm it by clicking on “Allow”.

Four tokens will be generated and displayed to you. We will be using the simplest authentication method in this example - Bearer Authentication. Because of that, you will only need “Access Token”. You will be able to check token values later on as well - just click on the pencil icon on the integration list.

Using custom API endpoint in Magento

Bearer Authentication means you will need to add a new header to each API request:
Authorization: Bearer <token>
<token> is the Access Token from the integration we just created.

If you want to receive the response as an XML, instead of JSON, you have to add an additional header:
Accept: application/xml
You can add this header to the requests below to check how the response will look like as XML.

In my case, the hostname is http://rest.localhost and my access token is zxr61q7haqolvhuquwkg2ajn1jfqrwwd

getProducts

Let’s send a request to the getProducts endpoint using curl command:
curl http://rest.localhost/rest/V1/rest_test/getProducts \
-X GET \
-H "Authorization: Bearer zxr61q7haqolvhuquwkg2ajn1jfqrwwd"

You should receive something similar to this (not formatted though):

html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [ { "id":1, "sku":"24-MB01", "name":"Joust Duffle Bag", "description":"<p>The sporty Joust Duffle Bag can't be beat - not in the gym, not on the luggage carousel, not anywhere. Big enough to haul a basketball or soccer ball and some sneakers with plenty of room to spare, it's ideal for athletes with places to go.<p>\n<ul>\n<li>Dual top handles.<\/li>\n<li>Adjustable shoulder strap.<\/li>\n<li>Full-length zipper.<\/li>\n<li>L 29\" x W 13\" x H 11\".<\/li>\n<\/ul>" }, { "id":2, "sku":"24-MB04", "name":"Strive Shoulder Pack", "description":"<p>Convenience is next to nothing when your day is crammed with action. So whether you're heading to class, gym, or the unbeaten path, make sure you've got your Strive Shoulder Pack stuffed with all your essentials, and extras as well.<\/p>\n<ul>\n<li>Zippered main compartment.<\/li>\n<li>Front zippered pocket.<\/li>\n<li>Side mesh pocket.<\/li>\n<li>Cell phone pocket on strap.<\/li>\n<li>Adjustable shoulder strap and top carry handle.<\/li>\n<\/ul>" }, [...] ]

As you can see, the response is exactly what we defined in the interface - an array of objects consisting of ID, SKU, name, and description (all of the getters in ResponseItemInterface).

getProduct

In a similar way you can send a request to the getProduct endpoint:
curl http://rest.localhost/rest/V1/rest_test/getProduct/2 \
-X GET \
-H "Authorization: Bearer zxr61q7haqolvhuquwkg2ajn1jfqrwwd"

If the product with provided ID exists you will receive something like this:

html
1 2 3 4 5 6 { "id":2, "sku":"24-MB04", "name":"Strive Shoulder Pack", "description":"<p>Convenience is next to nothing when your day is crammed with action. So whether you're heading to class, gym, or the unbeaten path, make sure you've got your Strive Shoulder Pack stuffed with all your essentials, and extras as well.<\/p>\n<ul>\n<li>Zippered main compartment.<\/li>\n<li>Front zippered pocket.<\/li>\n<li>Side mesh pocket.<\/li>\n<li>Cell phone pocket on strap.<\/li>\n<li>Adjustable shoulder strap and top carry handle.<\/li>\n<\/ul>" }

It’s similar to the previous response, but it’s a single object, not an array of these objects.

You can try and use an ID of a product that doesn’t exist. The response will depend on whether Magento is in developer mode or not.

There will always be a message (from the exception) and the response code will be 404, but in developer mode a trace will be added, containing the stack trace.

setDescription

As you recall, this endpoint is handled by the setDescription(array $products)method. The name of the argument matters, because this is a data key the input will be wrapped in. We also defined that this is an array of RequestItemInterface instances, which contains ID and description (all the getters and setters).

Let’s change the description for products with IDs 1 and 2.

curl http://rest.localhost/rest/V1/rest_test/setDescription \
-X PUT \
-H "Authorization: Bearer zxr61q7haqolvhuquwkg2ajn1jfqrwwd" \
-H "Content-Type: application/json" \
-d '{"products":[{"id":1,"description":"Test for product #1"},{"id":2,"description":"Test for product #2"}]}'

The request contains data, so we need to let Magento know how it’s serialized. That’s why there is a Content-Type header. The method doesn’t return anything, so the response will be an empty array: [].

You can use one of the previous endpoints to make sure the data has actually changed.

Summary

As you can see, it’s pretty easy to create your own custom API endpoint in Magento, which will take care of authentication, managing access, forcing data format, etc. for us.

The module we created only scratches the surface when it comes to API in Magento. You can for example create async and bulk endpoints, use OAuth to authenticate, authenticate as a specific customer or admin, use SOAP instead of REST, and much more.

A custom API endpoint in Magento: Useful links

Magento documentation about REST API is scattered, but here are some links you may find useful if you want to dig deeper into the subject: