Your own endpoint, end to end

Four files make a working REST endpoint: a data interface, a repository, di.xml preferences and webapi.xml.

Syntaxpublic function getById(int $id): \Vendor\Module\Api\Data\ThingInterface;

An endpoint of your own is four files, and each has one job.

The four files

A data interface in Api/Data describes the entity as getters and setters. It is the contract the API serialises, so it carries the @api annotation. A repository interface in Api describes the operations: getById, save, getList, deleteById. etc/di.xml binds each interface to its implementation with a preference, because the framework only ever asks for the interface. etc/webapi.xml maps a URL and HTTP method onto one repository method.

What comes back

The framework reads the @return docblock of the service method, walks the getters of that interface and converts each name to snake_case:

{
    "review_id": 42,
    "product_sku": "24-MB01",
    "rating": 5
}

Only getters declared on the interface appear. Data the model holds but the interface does not mention is dropped, which is the usual reason a field is missing from a response that looks correct in the database.

What trips people up

After adding the files run bin/magento setup:upgrade, then bin/magento setup:di:compile so the generated factories exist, then bin/magento cache:clean config config_webservice reflection. In production mode nothing is generated on request, so a skipped compile surfaces as a class-not-found error in var/log/ rather than as a routing problem.

Example

Example · php
<?php
// app/code/Vendor/Reviews/Api/Data/ReviewInterface.php

declare(strict_types=1);

namespace Vendor\Reviews\Api\Data;

/**
 * A single product review.
 *
 * @api
 */
interface ReviewInterface
{
    public const REVIEW_ID = 'review_id';
    public const PRODUCT_SKU = 'product_sku';
    public const RATING = 'rating';

    /** @return int|null */
    public function getReviewId(): ?int;

    /**
     * @param int $reviewId
     * @return \Vendor\Reviews\Api\Data\ReviewInterface
     */
    public function setReviewId(int $reviewId): ReviewInterface;

    /** @return string */
    public function getProductSku(): string;

    /**
     * @param string $productSku
     * @return \Vendor\Reviews\Api\Data\ReviewInterface
     */
    public function setProductSku(string $productSku): ReviewInterface;

    /** @return int */
    public function getRating(): int;

    /**
     * @param int $rating
     * @return \Vendor\Reviews\Api\Data\ReviewInterface
     */
    public function setRating(int $rating): ReviewInterface;
}

When to use it

  • Publish a module's own table as a REST resource for a middleware layer.
  • Give an admin-side tool and an external system the same read and write path.
  • Replace a one-off controller that returned JSON with a service other code can also call.

More examples

The repository that answers the route

ReviewFactory and ReviewSearchResultsInterfaceFactory are generated classes: a model and a search-results object are not injectable, because each call needs a fresh one, so you inject a factory and call create(). Throwing NoSuchEntityException is what turns a missing row into a 404 instead of a 500. Never ask the object manager for these yourself - a constructor-injected factory is what survives compilation and code review.

Example · php
<?php
// app/code/Vendor/Reviews/Model/ReviewRepository.php

declare(strict_types=1);

namespace Vendor\Reviews\Model;

use Magento\Framework\Api\SearchCriteria\CollectionProcessorInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Vendor\Reviews\Api\Data\ReviewInterface;
use Vendor\Reviews\Api\Data\ReviewSearchResultsInterface;
use Vendor\Reviews\Api\Data\ReviewSearchResultsInterfaceFactory;
use Vendor\Reviews\Api\ReviewRepositoryInterface;
use Vendor\Reviews\Model\ResourceModel\Review as ReviewResource;
use Vendor\Reviews\Model\ResourceModel\Review\CollectionFactory;

class ReviewRepository implements ReviewRepositoryInterface
{
    public function __construct(
        private readonly ReviewFactory $reviewFactory,
        private readonly ReviewResource $reviewResource,
        private readonly CollectionFactory $collectionFactory,
        private readonly CollectionProcessorInterface $collectionProcessor,
        private readonly ReviewSearchResultsInterfaceFactory $searchResultsFactory
    ) {
    }

    public function getById(int $reviewId): ReviewInterface
    {
        $review = $this->reviewFactory->create();
        $this->reviewResource->load($review, $reviewId);

        if (!$review->getReviewId()) {
            throw new NoSuchEntityException(__('Review with ID "%1" does not exist.', $reviewId));
        }

        return $review;
    }

    public function getList(SearchCriteriaInterface $searchCriteria): ReviewSearchResultsInterface
    {
        $collection = $this->collectionFactory->create();
        $this->collectionProcessor->process($searchCriteria, $collection);

        $results = $this->searchResultsFactory->create();
        $results->setSearchCriteria($searchCriteria);
        $results->setItems($collection->getItems());
        $results->setTotalCount($collection->getSize());

        return $results;
    }
}

Binding the interfaces

Without these three lines the framework cannot turn the interface named in webapi.xml into an object, and every call to the route fails with an exception about an abstract class. Vendor\Reviews\Model\ReviewSearchResults is a two-line class extending Magento\Framework\Api\SearchResults, which is how core modules do it. Any di.xml change needs bin/magento setup:di:compile in production mode and bin/magento cache:clean config everywhere.

Example · xml
<!-- app/code/Vendor/Reviews/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <preference for="Vendor\Reviews\Api\Data\ReviewInterface"
                type="Vendor\Reviews\Model\Review"/>
    <preference for="Vendor\Reviews\Api\Data\ReviewSearchResultsInterface"
                type="Vendor\Reviews\Model\ReviewSearchResults"/>
    <preference for="Vendor\Reviews\Api\ReviewRepositoryInterface"
                type="Vendor\Reviews\Model\ReviewRepository"/>
</config>

The route that exposes it

On the GET route, :reviewId fills the $reviewId parameter. On the POST route the body is {"review": {"product_sku": "24-MB01", "rating": 5}} - the key matches the parameter name of save(), and each snake_case field inside it is passed to the matching setter on ReviewInterface. Both URLs become live once bin/magento cache:clean config config_webservice reflection has run.

Example · xml
<!-- app/code/Vendor/Reviews/etc/webapi.xml -->
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
    <route url="/V1/vendor-reviews/:reviewId" method="GET">
        <service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="getById"/>
        <resources>
            <resource ref="Vendor_Reviews::reviews"/>
        </resources>
    </route>
    <route url="/V1/vendor-reviews" method="POST">
        <service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="save"/>
        <resources>
            <resource ref="Vendor_Reviews::reviews_save"/>
        </resources>
    </route>
</routes>

Discussion

  • Be the first to comment on this lesson.