← Voltar ao blog

GraphQL no Magento 2: adicione uma query e um resolver customizados

Rodrigo Biazzi

Mais cedo ou mais tarde, uma loja headless, um PWA ou um app mobile precisa de dados que o schema GraphQL do core do Magento não expõe. A boa notícia é que adicionar a sua própria query dá pouco código. O que as pessoas erram não é o resolver em si, mas o que fica em volta dele: validação de input, autorização, contexto de store e cache.

Este artigo percorre uma query customizada completa para o Magento Open Source ou Adobe Commerce 2.4. O módulo de exemplo é o Vendor_StoreLocator e expõe uma única loja física pelo código dela. Troque o domínio pelo que você precisar, a estrutura continua a mesma.

Esqueleto do módulo

Uma extensão de GraphQL é um módulo normal. Ela precisa de registration.php, etc/module.xml e um arquivo chamado etc/schema.graphqls. O Magento mescla todo schema.graphqls dos módulos habilitados em um schema só, então você nunca edita arquivos do core para adicionar um campo ou uma query.

Declare um sequence em Magento_GraphQl para que o seu schema seja mesclado depois que os tipos base existirem.

<?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="Vendor_StoreLocator">
        <sequence>
            <module name="Magento_GraphQl"/>
            <module name="Magento_StoreGraphQl"/>
        </sequence>
    </module>
</config>

Uma convenção comum no core é manter o código de GraphQL em um módulo separado, por exemplo Vendor_StoreLocatorGraphQl, que depende do módulo de domínio. Isso mantém a lógica de domínio utilizável por REST, admin e cron sem arrastar classes de GraphQL junto. Para um projeto pequeno, um módulo só serve.

Escrevendo o schema

O arquivo de schema declara a query, seus argumentos e os tipos que ela devolve. Duas diretivas fazem a ligação: @resolver aponta para a classe PHP que devolve os dados e @cache aponta para a classe que monta as cache tags. Note as barras invertidas duplas nos nomes de classe dentro da string.

type Query {
    physicalStore(
        code: String! @doc(description: "Unique code of the physical store")
    ): PhysicalStore
        @resolver(class: "Vendor\\StoreLocator\\Model\\Resolver\\PhysicalStore")
        @cache(cacheIdentity: "Vendor\\StoreLocator\\Model\\Resolver\\PhysicalStore\\Identity")
        @doc(description: "Return a physical store by its code")
}

type PhysicalStore @doc(description: "A physical store location") {
    id: Int!
    code: String!
    name: String
    city: String
    opening_hours: [OpeningHours]
}

type OpeningHours {
    day: String
    opens: String
    closes: String
}

Algumas coisas que vale saber sobre o schema:

  • Os campos do core usam snake_case. Siga o padrão para que os clientes vejam um estilo consistente.
  • Só o campo de nível superior precisa de um resolver. Os campos aninhados são lidos do array que o resolver pai devolve, usando o nome do campo como chave do array. Você pode adicionar um resolver a um campo aninhado quando ele precisar de lógica própria.
  • Se quiser adicionar um campo a um tipo existente, declare o tipo de novo no seu arquivo só com o campo novo. O Magento mescla as definições. Para interfaces como a ProductInterface, adicione o campo à interface e aos tipos concretos que a implementam.
  • Use bastante o @doc. Os desenvolvedores de frontend leem o schema por introspection, e essas descrições são a única documentação que eles vão ver.

A classe do resolver

Um resolver implementa Magento\Framework\GraphQl\Query\ResolverInterface, que tem um único método, resolve. Ele recebe a definição do campo, o contexto da requisição, o resolve info, o valor do pai e os argumentos.

<?php
declare(strict_types=1);

namespace Vendor\StoreLocator\Model\Resolver;

use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Exception\GraphQlInputException;
use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Vendor\StoreLocator\Api\PhysicalStoreRepositoryInterface;

class PhysicalStore implements ResolverInterface
{
    public function __construct(
        private readonly PhysicalStoreRepositoryInterface $repository
    ) {
    }

    public function resolve(
        Field $field,
        $context,
        ResolveInfo $info,
        ?array $value = null,
        ?array $args = null
    ): array {
        $code = trim((string)($args['code'] ?? ''));
        if ($code === '') {
            throw new GraphQlInputException(__('"code" must not be empty.'));
        }

        $storeId = (int)$context->getExtensionAttributes()->getStore()->getId();

        try {
            $store = $this->repository->getByCode($code, $storeId);
        } catch (NoSuchEntityException $e) {
            throw new GraphQlNoSuchEntityException(__($e->getMessage()), $e);
        }

        return [
            'id' => (int)$store->getId(),
            'code' => $store->getCode(),
            'name' => $store->getName(),
            'city' => $store->getCity(),
            'opening_hours' => $store->getOpeningHours(),
        ];
    }
}

O repository aqui faz parte do módulo de exemplo. O resolver deve ser fino. Ele valida o input, lê o contexto, chama um serviço e mapeia o resultado para um array que bate com o schema. As regras de negócio pertencem à camada de serviço, onde podem ser testadas sem uma requisição GraphQL.

Use as classes de exceção do GraphQL

O Magento converte exceções no array errors da resposta. As classes em Magento\Framework\GraphQl\Exception são marcadas como seguras para o cliente, então a mensagem delas chega ao cliente. As que você mais vai usar:

  • GraphQlInputException para argumentos inválidos ou ausentes.
  • GraphQlNoSuchEntityException quando o registro solicitado não existe.
  • GraphQlAuthorizationException quando o chamador não tem permissão para ver os dados.

Qualquer outra exceção vira um erro interno genérico para o cliente e é registrada no servidor. É isso que você quer para falhas reais, mas faz casos esperados como "não encontrado" parecerem bugs, então mapeie-os explicitamente.

Contexto de store e autorização

O argumento $context implementa Magento\GraphQl\Model\Query\ContextInterface. Pelos extension attributes dele você obtém a store atual, resolvida a partir do header Store, e se o chamador é um cliente logado. Use sempre essa store em vez da store default do StoreManagerInterface, senão uma configuração multi store vai devolver dados errados.

Para dados de cliente, confira o contexto antes de fazer qualquer outra coisa:

if (!$context->getExtensionAttributes()->getIsCustomer()) {
    throw new GraphQlAuthorizationException(__('The current customer is not authorized.'));
}
$customerId = (int)$context->getUserId();

Nunca aceite um customer id como argumento e confie nele. O id deve vir do token, que é o que o getUserId() dá a você.

Cache com uma cache identity

Requisições de query enviadas com o método GET podem ser guardadas no full page cache, no Varnish ou no cache embutido. Para isso ser seguro, o Magento precisa saber de quais entidades uma resposta depende, para poder limpar a resposta em cache quando uma delas mudar. Esse é o trabalho da classe de identity referenciada por @cache(cacheIdentity: ...).

<?php
declare(strict_types=1);

namespace Vendor\StoreLocator\Model\Resolver\PhysicalStore;

use Magento\Framework\GraphQl\Query\Resolver\IdentityInterface;

class Identity implements IdentityInterface
{
    private const CACHE_TAG = 'vendor_physical_store';

    public function getIdentities(array $resolvedData): array
    {
        if (empty($resolvedData['id'])) {
            return [];
        }

        return [self::CACHE_TAG, self::CACHE_TAG . '_' . $resolvedData['id']];
    }
}

As tags só ajudam se algo as limpar. Seu model deve implementar Magento\Framework\DataObject\IdentityInterface e devolver as mesmas tags no método getIdentities(). Quando a entidade é salva por um resource model que dispara os eventos de clean cache, as tags são invalidadas e o Varnish descarta as respostas correspondentes. Se seus dados são gravados por um import ou por uma query SQL direta, você mesmo precisa limpar essas tags, por exemplo com Magento\Framework\App\Cache\TypeListInterface ou disparando clean_cache_by_tags com a entidade.

Quando não fazer cache

Tudo que depende do cliente, do carrinho ou de um segredo não pode ser compartilhado entre usuários. Marque essas queries com @cache(cacheable: false). O core faz o mesmo para queries de cliente e de carrinho. Ser explícito tira qualquer dúvida quando alguém mudar o cliente depois para enviar requisições GET.

Testando a query

Depois de habilitar o módulo, rode os comandos de sempre e então chame o endpoint. O schema é cacheado, então uma mudança no schema.graphqls não fica visível até o cache ser limpo.

bin/magento module:enable Vendor_StoreLocator
bin/magento setup:upgrade
bin/magento cache:flush

curl -s https://shop.test/graphql \
  -H 'Content-Type: application/json' \
  -H 'Store: default' \
  -d '{"query":"{ physicalStore(code: \"downtown\") { id name city opening_hours { day opens closes } } }"}'

Para conferir o cache, envie a mesma query como GET com a query string codificada e olhe os headers da resposta. Com o Varnish na frente você deve ver um cache hit na segunda chamada. Depois salve a entidade no admin e confirme que a chamada seguinte é um miss.

Para testes automatizados, o Magento tem um framework de testes funcionais de API em que as classes de teste estendem Magento\TestFramework\TestCase\GraphQlAbstract e enviam queries com o método graphQlQuery(). Mesmo um único teste que roda a query contra uma fixture pega a maioria dos erros de schema e de mapeamento.

Notas de performance

Um resolver que roda uma query por item vira problema assim que é usado dentro de uma lista. Se o seu campo vive em um tipo que aparece muitas vezes em uma resposta, como um campo nos produtos de uma listagem de categoria, olhe o Magento\Framework\GraphQl\Query\Resolver\BatchResolverInterface ou o padrão de deferred value usado no core. Eles permitem coletar todos os ids primeiro e carregá-los em uma query só.

Olhe também o ResolveInfo antes de carregar dados pesados. O método getFieldSelection() dele diz quais campos filhos o cliente pediu, então você pode pular o carregamento dos horários de funcionamento quando ninguém os solicitou.

Checklist antes de publicar

  • O schema usa snake_case e todo campo tem uma descrição @doc.
  • Os argumentos são validados e os erros usam as classes de exceção do GraphQL.
  • A store vem do contexto e o customer id vem do token.
  • Queries cacheáveis têm uma classe de identity e o model emite as mesmas tags.
  • Dados privados são marcados como não cacheáveis.
  • Nenhuma query por item dentro de listas.

FAQ

Onde fica o arquivo schema.graphqls em um módulo do Magento 2?

Na pasta etc do módulo, como etc/schema.graphqls. O Magento mescla os arquivos de todos os módulos habilitados em um schema só, então você pode adicionar queries novas e estender tipos existentes sem tocar no código do core.

Por que meu campo GraphQL novo não aparece depois que mudo o schema?

O schema mesclado é cacheado. Rode bin/magento cache:flush depois de toda mudança no schema.graphqls e garanta que o módulo está habilitado e que o setup:upgrade rodou.

As queries GraphQL do Magento são cacheadas pelo Varnish?

Queries enviadas com GET podem ser cacheadas pelo Varnish ou pelo full page cache embutido. Mutations e requisições POST não são cacheadas. Sua query precisa de uma classe de cache identity para o Magento poder limpar as respostas certas quando os dados mudarem.

Devo colocar lógica de negócio no resolver?

Não. Mantenha o resolver fino. Valide o input, leia o contexto, chame um serviço ou repository e mapeie o resultado para o schema. A lógica continua testável e reutilizável por REST, admin e cron.

Sou Rodrigo Biazzi, desenvolvedor Magento certificado, trabalho com Magento e Adobe Commerce há mais de 12 anos. Atendo como freelancer em Magento, de correções pontuais a projetos mais longos. Se esse é o tipo de problema que você tem na sua loja, fale comigo.

Artigos relacionados

Erros comuns do Magento 2 e como corrigir

Correções para erros clássicos do Magento 2: Area code is not set, Class does not exist, Invalid Form Key, indexers travados, unserialize e 404 em estáticos.