APIs that hold up

What keeps a REST contract stable: @api, extension attributes, URL versioning, 429 backpressure, and returning data interfaces.

Syntax<extension_attributes for="Magento\Catalog\Api\Data\ProductInterface"><attribute code="vendor_rating" type="float"/></extension_attributes>

A published endpoint is a promise to code you do not control. Two habits keep it: do not change what you have published, and add only in ways that cannot break a caller.

An interface is frozen once published

The @api annotation marks an interface as part of the public contract, covered by Adobe's backward compatibility policy. Adding a method to it is a breaking change, because any class implementing it elsewhere stops being valid. The accepted move is a new interface: leave the old one untouched, mark the superseded method @deprecated with a @see pointing at the replacement, and keep it working.

To add a field to a response without touching the data interface, declare an extension attribute in etc/extension_attributes.xml. It is returned inside extension_attributes and accepted under the same key on POST and PUT. For versioning, the only lever the framework offers is the URL: keep /V1 serving and add a /V2 route pointing at a new interface.

Return types and limits

Return the data interface, never a model. A model exposes whatever its getters happen to reach, ties the response to your table layout, and changes shape the moment you add a column.

On 2.4.9 Open Source the REST layer can answer 429 Too Many Requests through backpressure, which applies per service method rather than globally; order placement is the one configured out of the box, under sales/backpressure/. General per-client throttling belongs to the web server or CDN, not to Magento. Apply configuration with bin/magento cache:flush.

Example

Example · php
<?php
// app/code/Vendor/Reviews/Plugin/AddRatingToProduct.php

declare(strict_types=1);

namespace Vendor\Reviews\Plugin;

use Magento\Catalog\Api\Data\ProductExtensionFactory;
use Magento\Catalog\Api\Data\ProductInterface;
use Magento\Catalog\Api\ProductRepositoryInterface;
use Vendor\Reviews\Api\AverageRatingInterface;

class AddRatingToProduct
{
    public function __construct(
        private readonly ProductExtensionFactory $extensionFactory,
        private readonly AverageRatingInterface $averageRating
    ) {
    }

    /**
     * Add vendor_rating to any product read through the repository.
     */
    public function afterGet(
        ProductRepositoryInterface $subject,
        ProductInterface $result
    ): ProductInterface {
        $extension = $result->getExtensionAttributes() ?? $this->extensionFactory->create();
        $extension->setVendorRating($this->averageRating->forSku($result->getSku()));
        $result->setExtensionAttributes($extension);

        return $result;
    }
}

When to use it

  • Add a field to the product API response without editing a core interface.
  • Change a method's behaviour for new callers while the old shape keeps working.
  • Protect an expensive endpoint that an integrator calls in a tight loop.

More examples

Declaring the extension attribute

This is what makes setVendorRating() exist: Magento generates ProductExtensionInterface and its factory from this file, turning the attribute code into a camelCase getter and setter. GET /V1/products/24-MB01 then returns "extension_attributes": {"vendor_rating": 4.5}. Run bin/magento setup:di:compile to generate the types and bin/magento cache:clean config config_webservice reflection to publish the new response shape. The plugin shown above covers get() only - getList() needs its own afterGetList.

Example · xml
<!-- app/code/Vendor/Reviews/etc/extension_attributes.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Api/etc/extension_attributes.xsd">
    <extension_attributes for="Magento\Catalog\Api\Data\ProductInterface">
        <attribute code="vendor_rating" type="float"/>
    </extension_attributes>
</config>

Replacing a method without breaking callers

The old method keeps its signature and keeps working, usually by delegating to the new one with a default store. @deprecated tells anyone reading the code to stop using it and @see says what to use instead; static analysis and the coding standard both read these tags. Adding getByIdAndStore() to the existing interface instead would have broken every class implementing it, including ones in other vendors' modules.

Example · php
<?php
// app/code/Vendor/Reviews/Api/ReviewRepositoryInterface.php (published, unchanged)

/**
 * @api
 */
interface ReviewRepositoryInterface
{
    /**
     * @param int $reviewId
     * @return \Vendor\Reviews\Api\Data\ReviewInterface
     * @deprecated 2.1.0 Store scope is now significant.
     * @see \Vendor\Reviews\Api\ReviewLookupInterface::getByIdAndStore()
     */
    public function getById(int $reviewId): ReviewInterface;
}

// app/code/Vendor/Reviews/Api/ReviewLookupInterface.php (new file)

/**
 * @api
 */
interface ReviewLookupInterface
{
    /**
     * @param int $reviewId
     * @param int $storeId
     * @return \Vendor\Reviews\Api\Data\ReviewInterface
     * @throws \Magento\Framework\Exception\NoSuchEntityException
     */
    public function getByIdAndStore(int $reviewId, int $storeId): ReviewInterface;
}

Versioning in the URL

Two routes, two interfaces, one module. The framework does no negotiation and no aliasing: the version is part of the path, so an existing integration that hardcoded /V1 keeps getting exactly the response it was written against. Decide up front how long /V1 stays, announce it in your module's changelog, and remove it only in a major release of your own package.

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">
    <!-- Still serving the original shape. Do not touch it. -->
    <route url="/V1/vendor-reviews/:reviewId" method="GET">
        <service class="Vendor\Reviews\Api\ReviewRepositoryInterface" method="getById"/>
        <resources>
            <resource ref="Vendor_Reviews::reviews"/>
        </resources>
    </route>

    <!-- New callers get the store-aware version. -->
    <route url="/V2/vendor-reviews/:reviewId/store/:storeId" method="GET">
        <service class="Vendor\Reviews\Api\ReviewLookupInterface" method="getByIdAndStore"/>
        <resources>
            <resource ref="Vendor_Reviews::reviews"/>
        </resources>
    </route>
</routes>

Discussion

  • Be the first to comment on this lesson.