The API
One repository exposed twice: a REST route in webapi.xml and a GraphQL query, each with tests.
<route url="/V1/acme-locator/locations/:locationId" method="GET">Both APIs read the repository built in the data layer. Neither needs a controller.
REST
etc/webapi.xml maps a URL and an HTTP method onto a method of an existing interface. Magento creates the route, converts the request into the method's arguments, serialises the return value to JSON using the interface's type annotations, and checks the ACL resource in <resources> before any code runs. The URL placeholder must be spelled like the parameter: :locationId feeds getById($locationId).
Requests are authenticated with a bearer token. POST /rest/V1/integration/admin/token with an admin username and password returns one, and it is sent as Authorization: Bearer <token>. A route whose resource is anonymous needs no token, which also means it is public.
GraphQL
GraphQL is declared separately in etc/schema.graphqls. A field names its resolver with the @resolver directive, and that class implements Magento\Framework\GraphQl\Query\ResolverInterface. The array the resolver returns is keyed by the schema's field names, so those keys are snake_case where the schema is. GraphQL has no ACL layer of its own; a field is reachable by anyone who can reach /graphql.
Tests
API functional tests call a running instance over HTTP. The REST ones extend Magento\TestFramework\TestCase\WebapiAbstract and call _webApiCall(); the GraphQL ones extend GraphQlAbstract and call graphQlQuery().
Both config files are cached, so run bin/magento cache:flush, and bin/magento setup:di:compile after adding an interface.
Example
<!-- app/code/Acme/Locator/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/acme-locator/locations/:locationId" method="GET">
<service class="Acme\Locator\Api\LocationRepositoryInterface" method="getById"/>
<resources>
<resource ref="Acme_Locator::location"/>
</resources>
</route>
<route url="/V1/acme-locator/locations" method="GET">
<service class="Acme\Locator\Api\LocationRepositoryInterface" method="getList"/>
<resources>
<resource ref="Acme_Locator::location"/>
</resources>
</route>
<route url="/V1/acme-locator/locations" method="POST">
<service class="Acme\Locator\Api\LocationRepositoryInterface" method="save"/>
<resources>
<resource ref="Acme_Locator::location_save"/>
</resources>
</route>
</routes>
<!--
What changes: three endpoints appear under /rest/<store>/V1/acme-locator/.
GET .../locations/5 returns one location as JSON, built from the getters
declared on LocationInterface. GET .../locations accepts the standard
searchCriteria query parameters and returns items, search_criteria and
total_count. POST .../locations expects {"location": {...}} because the
save() parameter is named $location.
The resource refs are the ids declared in etc/acl.xml. A token whose role
lacks Acme_Locator::location_save gets 403 on the POST while the GETs
still work.
Apply it with:
bin/magento cache:flush
-->When to use it
- Expose a custom entity to a mobile app or a middleware integration over REST.
- Serve the same data to a headless storefront through GraphQL without writing a second read path.
- Prove an endpoint still returns what the contract promises before upgrading Magento.
More examples
schema.graphqls for the same data
The schema is the contract, and it is merged with every other module's schema.graphqls, so a type name here must be unique across the whole installation: hence the Acme prefix. Class names inside a directive are written with doubled backslashes because the value is a quoted string. The @cache directive is optional; without it the query result is not cached in the full-page cache layer for GraphQL, and with it the named identity class decides the cache tags. Changes to a .graphqls file are picked up after bin/magento cache:flush, and the new query appears in any schema introspection or GraphQL IDE pointed at /graphql.
# app/code/Acme/Locator/etc/schema.graphqls
type Query {
acmeLocations(
city: String @doc(description: "Return only locations in this city")
): AcmeLocations
@resolver(class: "Acme\\Locator\\Model\\Resolver\\Locations")
@doc(description: "Active pickup locations")
@cache(cacheIdentity: "Acme\\Locator\\Model\\Resolver\\Locations\\Identity")
}
type AcmeLocations @doc(description: "A page of pickup locations") {
items: [AcmeLocation]!
total_count: Int!
}
type AcmeLocation @doc(description: "One pickup location") {
location_id: Int!
name: String!
identifier: String!
city: String
}The resolver
The signature is fixed by Magento\Framework\GraphQl\Query\ResolverInterface: resolve(Field $field, $context, ResolveInfo $info, ?array $value = null, ?array $args = null). The nullable array types matter on PHP 8.4, where an implicitly nullable parameter is deprecated. A resolver returns data, not objects: the array keys must match the field names in the schema, which is why getData() works here, the column names being already snake_case. The $args values come straight from the client, so they go through addFilter, which binds them. Query a field in the same response from several resolvers and each is called separately, which is where the N+1 problem in GraphQL comes from.
<?php
// app/code/Acme/Locator/Model/Resolver/Locations.php
declare(strict_types=1);
namespace Acme\Locator\Model\Resolver;
use Acme\Locator\Api\Data\LocationInterface;
use Acme\Locator\Api\LocationRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
class Locations implements ResolverInterface
{
public function __construct(
private readonly LocationRepositoryInterface $locationRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder
) {
}
public function resolve(
Field $field,
$context,
ResolveInfo $info,
?array $value = null,
?array $args = null
) {
$this->searchCriteriaBuilder->addFilter(LocationInterface::IS_ACTIVE, 1);
if (!empty($args['city'])) {
$this->searchCriteriaBuilder->addFilter(LocationInterface::CITY, $args['city']);
}
$results = $this->locationRepository->getList($this->searchCriteriaBuilder->create());
$items = [];
foreach ($results->getItems() as $location) {
$items[] = $location->getData();
}
return [
'items' => $items,
'total_count' => $results->getTotalCount(),
];
}
}An API functional test for the REST route
_webApiCall($serviceInfo, $arguments = []) sends a real HTTP request to the test instance and returns the decoded response, so this test proves the route, the ACL check, the serialisation and the repository all work together; a unit test cannot. It lives under dev/tests/api-functional/testsuite rather than in the module because it needs an installed Magento with a web server, and the suite's phpunit configuration decides whether the REST or SOAP adapter is used. The GraphQL equivalent extends Magento\TestFramework\TestCase\GraphQlAbstract and calls $this->graphQlQuery($query), which takes the query string and returns the decoded data array. Rows the test relies on come from a fixture or from the module's data patch.
<?php
// dev/tests/api-functional/testsuite/Acme/Locator/Api/LocationRepositoryTest.php
declare(strict_types=1);
namespace Acme\Locator\Api;
use Magento\Framework\Webapi\Rest\Request;
use Magento\TestFramework\TestCase\WebapiAbstract;
class LocationRepositoryTest extends WebapiAbstract
{
private const RESOURCE_PATH = '/V1/acme-locator/locations';
public function testGetListReturnsTheSeededLocations(): void
{
$searchCriteria = 'searchCriteria[filterGroups][0][filters][0][field]=is_active'
. '&searchCriteria[filterGroups][0][filters][0][value]=1'
. '&searchCriteria[pageSize]=10';
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH . '?' . $searchCriteria,
'httpMethod' => Request::HTTP_METHOD_GET,
],
];
$response = $this->_webApiCall($serviceInfo);
$this->assertArrayHasKey('items', $response);
$this->assertArrayHasKey('total_count', $response);
$this->assertGreaterThanOrEqual(2, $response['total_count']);
$this->assertArrayHasKey('name', $response['items'][0]);
}
}
Discussion