Blog

🍾 Gato GraphQL agora é scoped, graças ao PHP-Scoper!

Leonardo Losoviz
Por Leonardo Losoviz ·

O plugin Gato GraphQL agora é scoped. Isso significa que o plugin pode finalmente ser enviado ao diretório de plugins do WordPress.

Falando de negócios

Para conseguir isso, estou usando o maravilhoso PHP-Scoper. Usar essa biblioteca com WordPress não é isento de desafios, então vou explicar neste post como consegui resolver o problema.

Seções:

Tomando a decisão de fazer o scope

Algumas semanas atrás, Matt Mullenweg anunciou que vai ficar de olho no "plugin GraphQL", obviamente se referindo ao WPGraphQL. Sua expressão demonstra que ele acredita existir apenas um plugin GraphQL, quando na verdade há dois (o que ficou de fora é, bem, o meu). Isso me fez perceber o quanto meu plugin tem pouca visibilidade, e eu me senti mal com isso.

Matt não sabia que meu plugin existia. A maior parte da comunidade WordPress tampouco. Claramente não estou o divulgando bem o suficiente. Sei que sou ruim em marketing e redes sociais; me viro apenas com coisas técnicas (ou pelo menos acredito nisso). Então decidi fazer algo a respeito, pelo menos dentro das minhas capacidades.

É nisso que estou trabalhando:

  • Acabei de terminar de programar este mesmo site, gatographql.com, e o lancei há 2 semanas (eba! 🥳 Aliás, o que você achou? Fique à vontade para me dar um feedback, via DM ou email)
  • Há 3 dias, finalmente comecei a fazer o scope do plugin, e terminei essa tarefa ontem! (Às 3 da manhã, mas valeu a pena 😅)
  • E por fim, já estou trabalhando na próxima versão 0.8, que será a primeira disponível no repositório de plugins

Fazer o scope do plugin é obrigatório para enviá-lo ao repositório, pois do contrário ele poderia entrar em conflito com outro plugin que requer a mesma dependência do meu, mas com uma versão diferente. Ter feito isso é um marco realmente importante; nenhum outro desenvolvimento é tão relevante. Por exemplo, ainda preciso completar o schema GraphQL para que corresponda totalmente ao modelo de dados do WordPress, mas isso será feito de forma constante a cada novo lançamento.

Então, em algumas semanas, o plugin aparecerá ao pesquisar por "GraphQL", e as pessoas que realmente precisam implementar uma API GraphQL vão conhecer a existência do meu plugin.

De fato, quero que meu plugin seja seriamente considerado para o futuro do WordPress. Estou trabalhando nele há vários anos. O repositório foi criado em agosto de 2016; isso é até antes de o WPGraphQL existir, e no início do GraphQL. Mas eu não sabia que o projeto se tornaria um servidor GraphQL; ele tomou essa direção apenas cerca de 1,5 anos atrás.

(O projeto é na verdade um framework para construir aplicações usando componentes do lado do servidor, e um servidor GraphQL poderia perfeitamente ser construído usando essa arquitetura. Então eu simplesmente o construí).

O WPGraphQL é um plugin estabelecido, e com razão: foi iniciado há alguns anos, e uma comunidade foi construída ao redor dele. O trabalho de Jason Bahl (que é contratado pelo Gatsby) e dos colaboradores do projeto tem sido excepcional: integrar o WordPress ao Jamstack agora é mais fácil do que nunca.

Mas uma coisa é o Gatsby e o Jamstack, e outra coisa é o WordPress. O WordPress representa 40% da web, não apenas uma entrada para um gerador de sites estáticos.

Então agora podemos avaliar se o WPGraphQL é a opção certa, sem que essa decisão seja tomada por nós por falta de alternativas. Podemos agora analisar ambos os plugins para ver cujos objetivos estão mais alinhados com o que é importante para o WordPress.

O Gato GraphQL também pode funcionar com o Jamstack. Mas seus principais objetivos são, acredito, mais grandiosos: "democratizar a publicação de dados", para que editar uma API se torne tão fácil quanto editar um post (algo que qualquer pessoa pode fazer), e fazer do WordPress o sistema operacional da web.

Quando o plugin estiver disponível no repositório, espero que mais pessoas o experimentem e digam "Ei, isso é incrível demais! Como é que eu não sabia dessa coisa antes?".

E então, a escolha do "plugin GraphQL" não será predeterminada, e a comunidade WordPress poderá considerar tanto o WPGraphQL quanto o Gato GraphQL com base nos seus próprios méritos.

Agora que minhas motivações estão explicadas, vamos falar de coisas técnicas 🤓.

Verificando as opções

Fazer o scope de um plugin envolve executar algumas ferramentas, que recebem o código do plugin como entrada e produzem o plugin com scope. Nada demais, certo? Quão difícil pode ser?

Falando de técnica

Bem, dependendo da codebase, apenas executar o comando de scope não será suficiente. Depois disso, precisamos verificar erros no console, corrigi-los, testar a aplicação detalhadamente, identificar erros e por que ocorrem, corrigi-los, e iterar. Para acertar completamente, pode ser necessário algum tempo.

Há 2 bibliotecas para scope, com objetivos diferentes:

  • Mozart, para código WordPress
  • PHP-Scoper, para qualquer código PHP, particularmente ao produzir PHARs

Como tenho um plugin WordPress, tentei o Mozart primeiro. Vamos ver como foi.

Tentando o Mozart, e falhando

Tentei o Mozart cerca de 1 ano atrás. Pelo que diz na documentação, "o comando mozart compose faz toda a mágica". Então esperava que tudo fosse muito rápido e simples, e que eu pudesse aproveitar um drinque pelo resto do dia.

Infelizmente, o Mozart nunca funcionou para minha codebase. Continuava encontrando problemas, então o scope nunca se concretizou. E não consegui obter a ajuda necessária: submeti um PR, mas ele não foi considerado para merge, e nem sequer fui notificado sobre isso, então continuei esperando até perder naturalmente o interesse nesse projeto.

Acredito que o Mozart não conseguia lidar com algumas das dependências do meu plugin. Faço uso de vários componentes do Symfony, incluindo DependencyInjection, Cache e Dotenv, com tudo gerenciado pelo Composer.

Fazer o scope de PHP não é apenas sobre PHP, então o scoper terá muitos obstáculos a evitar e desafios a resolver. Por exemplo, o Symfony DependencyInjection usa arquivos YAML para configuração, e esses também precisam ter scope. E o arquivo composer.json contém a configuração para o autoloading PSR-4, e esse também precisa ter scope. E, acredito, o Mozart não conseguia lidar com essas complexidades corretamente.

Mas tenho certeza de que minha experiência não é a única, e que há muitos usuários satisfeitos por aí. Além disso, minha tentativa fracassada aconteceu 1 ano atrás, então me pergunto se a ferramenta foi melhorada desde então. E não se esqueça do ditado: "Todos os plugins com scope se parecem; cada plugin sem scope é assim à sua própria maneira", então possivelmente falha apenas para mim.

Se o seu plugin WordPress é simples, com lógica autossuficiente, e o scope precisa ser realizado apenas dentro do código PHP, então há boas chances de que o Mozart funcione. Você só precisa descobrir.

Conhecendo o PHP-Scoper, e saindo em pânico

Então me dirigi ao PHP-Scoper. No entanto, nunca cheguei nem a tentar experimentá-lo, porque fui assustado imediatamente.

Para começar, essa ferramenta não suporta WordPress nativamente. E para continuar, eles recomendam dar uma olhada no seu próprio Makefile, que se parece com isso:

# See https://tech.davis-hansson.com/p/make/
MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules
 
.DEFAULT_GOAL := help
 
PHPBIN=php
PHPNOGC=php -d zend.enable_gc=0
IS_PHP8=$(shell php -r "echo version_compare(PHP_VERSION, '8.0.0', '>=') ? 'true' : 'false';")
 
SRC_FILES=$(shell find bin/ src/ -type f)
 
.PHONY: help
help:
	@echo "\033[33mUsage:\033[0m\n  make TARGET\n\n\033[32m#\n# Commands\n#---------------------------------------------------------------------------\033[0m\n"
	@fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' | awk 'BEGIN {FS = ":"}; {printf "\033[33m%s:\033[0m%s\n", $$1, $$2}'
 
 
#
# Build
#---------------------------------------------------------------------------
 
.PHONY: clean
clean:	 ## Clean all created artifacts
clean:
	git clean --exclude=.idea/ -ffdx
 
update-root-version: ## Check the lastest GitHub release and update COMPOSER_ROOT_VERSION accordingly
update-root-version:
	rm .composer-root-version || true
	$(MAKE) .composer-root-version

E mais 600 linhas, todas assim. Parece um enigma. Acreditando que precisaria entender aquele código só para fazer o scope do meu plugin, fugi sem cerimônias.

(Bem, entender aquele código é a recomendação deles para testar a aplicação com scope, mas não é obrigatório. Podemos também simplesmente executar o comando php-scoper add-prefix, deixá-lo fazer toda a mágica, e ir tomar nosso drinque.)

Voltando ao PHP-Scoper, desta vez de verdade

Então, há 3 dias, tomei a decisão de implementar o scope, de alguma forma. Precisava fazer isso acontecer.

Voltei ao PHP-Scoper, para experimentá-lo de verdade. Eu sabia que o WordPress podia ter scope com ele depois de ler PHP Scoper: How to Avoid Namespace Issues in your Composer Dependencies (pelas brilhantes pessoas da Delicious Brains). Era apenas uma questão de atitude e perseverança.

Explorei algumas das soluções existentes, incluindo:

  • Esta de Lucas Bustamante
  • Esta do Yoast
  • Esta do Google Site Kit
  • Esta do Google Web Stories

Mas todas me pareceram não totalmente satisfatórias: ou o código parece hacky, ou frágil e esperando para quebrar em algum momento.

Por exemplo, o plugin Google Web Stories faz o scope do código, e depois desfaz cada um dos conflitos:

return [
  'patchers'                   => [
		function ( $file_path, $prefix, $contents ) {
			/*
			 * There is currently no easy way to simply whitelist all global WordPress functions.
			 *
			 * This list here is a manual attempt after scanning through the AMP plugin, which means
			 * it needs to be maintained and kept in sync with any changes to the dependency.
			 *
			 * As long as there's no built-in solution in PHP-Scoper for this, an alternative could be
			 * to generate a list based on php-stubs/wordpress-stubs. devowlio/wp-react-starter/ seems
			 * to be doing just this successfully.
			 *
			 * @see https://github.com/humbug/php-scoper/issues/303
			 * @see https://github.com/php-stubs/wordpress-stubs
			 * @see https://github.com/devowlio/wp-react-starter/
			 */
			$contents = str_replace( "\\$prefix\\_doing_it_wrong", '\\_doing_it_wrong', $contents );
			$contents = str_replace( "\\$prefix\\__", '\\__', $contents );
			$contents = str_replace( "\\$prefix\\esc_html_e", '\\esc_html_e', $contents );
			$contents = str_replace( "\\$prefix\\esc_html", '\\esc_html', $contents );
			$contents = str_replace( "\\$prefix\\esc_attr", '\\esc_attr', $contents );
			$contents = str_replace( "\\$prefix\\esc_url", '\\esc_url', $contents );
      $contents = str_replace( "\\$prefix\\do_action", '\\do_action', $contents );
      // ...
    }
  ]
]

Entendo por que fazem isso, mas não gosto. Sempre que uma nova função WordPress é referenciada, eles precisam se certificar de que ela também aparece nesta lista. É manual demais, frágil demais.

Então este era meu desafio: não existe uma maneira mais simples de fazer o scope de um plugin, dependendo de código que possamos apresentar para nossos amigos e colegas sem corar?

PHP-Scoper, do jeito fácil 😎

Na verdade, foi mais fácil do que eu pensava! Em apenas algumas horas, tinha tudo funcionando.

Scope em poucas horas

Agora, quando digo "fácil" e "horas", quero dizer na verdade: tudo funcionou imediatamente, mas apenas depois de gastar 2 meses criando a estrutura adequada para a codebase (vou explicar melhor mais adiante).

Mas o importante é: se você tem a configuração certa para o projeto, fazer o scope pode ser feito em pouco tempo.

O problema com o scope do código WordPress é, bem, o código WordPress. O problema é explicado aqui, mas se resume ao fato de que todas as funções e classes do WordPress também recebem namespace. Então se referenciarmos WP_Query ou chamarmos get_posts no nosso código, eles serão transformados em MyPrefixedNamespace\WP_Query e MyPrefixedNamespace\get_posts, produzindo uma falha épica em tempo de execução. E isso não pode ser evitado no PHP-Scoper sem hacks.

Então, qual é a solução para isso? Simples: não referencie WP_Query, não chame get_posts, e não use nenhum código WordPress na codebase que terá scope.

Estou louco?

Não, não estou louco, e tenho certeza que você também não está. E sim, eu sei que estamos construindo um plugin WordPress... Deixa eu explicar.

Como podemos não incluir código WordPress? Dividindo a codebase em 2 conjuntos de pacotes:

  • Aqueles contendo código WordPress, sem referenciar código de nenhuma biblioteca externa
  • Aqueles contendo lógica de negócio, sem conter nenhum código WordPress, e incluindo todas as dependências necessárias e referências ao seu código

Dessa forma, em vez de ter uma única codebase, temos múltiplas codebases (ou pacotes), onde alguns terão scope e outros não, e todos formam o plugin, ligados entre si via Composer.

Então, não fazemos o scope do pacote contendo código WordPress, evitando o conflito. Isso funciona porque ele não referencia nenhum código pertencente a uma dependência externa. Todas as referências são internas, como MyNamespace\MyPlugin\MyClass. Mas essas não precisam ter scope, porque podemos assumir com segurança que haverá apenas 1 versão do plugin instalada no site WordPress, e podemos adicionar nosso namespace MyNamespace\* à whitelist.

Além disso, se o nosso plugin pode ser estendido, então adicionar nosso próprio namespace à whitelist é obrigatório. Por exemplo, um field resolver para o Gato GraphQL é implementado estendendo a classe PoP\ComponentModel\FieldResolvers\AbstractFieldResolver. Se eu fizesse o scope disso, os desenvolvedores seriam forçados a referenciar PoP\ComponentModel\FieldResolvers\AbstractFieldResolver para desenvolvimento, e PrefixedByPoP\PoP\ComponentModel\FieldResolvers\AbstractFieldResolver para produção. Isso não é viável.

Então, fazemos o scope apenas dos pacotes de lógica de negócio, que contêm referências a todas as bibliotecas externas, mas nenhum código WordPress.

Em resumo, estamos mudando desta estratégia:

"Ter uma única codebase, fazer o scope, e depois desfazer dolorosamente e com muita paciência os danos, rezando para que nenhum conflito passe despercebido e 💣 exploda em produção"

Para esta:

"Dividir a codebase em 2 grupos, fazer o scope apenas daquele que contém as referências às dependências externas e nenhum código WordPress, e ir tomar seu merecido drinque 🍹".

Mostre-me a coisa de verdade

É hora de abrir a salsicha e ver se tem carne de verdade dentro 🌭.

Há 4 dias, eu tinha o seguinte código no meu plugin:

namespace GraphQLAPI\GraphQLAPI\ContentProcessors;
 
use Parsedown;
 
class MarkdownContentParser
{
  protected function getHTMLContent(string $fileContent): string
  {
    return (new Parsedown())->text($markdownContent);
  }
}

A classe Parsedown vem da dependência externa erusev/parsedown, conforme definido no composer.json do plugin:

{
  "require": {
    "erusev/parsedown": "^1.7"
  }
}

Portanto, meu plugin continha referências a uma biblioteca externa, então precisava fazer o scope, para transformar Parsedown em PrefixedByPoP\Parsedown. Mas fazer isso também faria o scope de todo o código WordPress do plugin, causando os conflitos.

Então extraí o código para um pacote separado, chamado graphql-api/markdown-convertor, e substituí a dependência de terceiros no composer.json pela minha própria dependência:

{
  "require": {
    "graphql-api/markdown-convertor": "^0.8"
  }
}

Agora, o plugin evita referenciar a biblioteca externa; em vez disso, referencia o serviço MarkdownConvertorInterface do novo pacote:

namespace GraphQLAPI\GraphQLAPI\ContentProcessors;
 
use GraphQLAPI\MarkdownConvertor\MarkdownConvertorInterface;
 
class MarkdownContentParser extends AbstractContentParser
{
    protected MarkdownConvertorInterface $markdownConvertorInterface;
 
    function __construct(MarkdownConvertorInterface $markdownConvertorInterface)
    {
        $this->markdownConvertorInterface = $markdownConvertorInterface;
    }
 
    protected function getHTMLContent(string $fileContent): string
    {
        return $this->markdownConvertorInterface->convertMarkdownToHTML($fileContent);
    }
}

A referência à dependência de terceiros é feita no novo pacote:

namespace GraphQLAPI\MarkdownConvertor;
 
use Parsedown;
 
class MarkdownConvertor implements MarkdownConvertorInterface
{
  public function convertMarkdownToHTML(string $markdownContent): string
  {
    return (new Parsedown())->text($markdownContent);
  }
}

Por fim, precisamos:

  • Fazer o scope da dependência graphql-api/markdown-convertor
  • Pular o scope do código do plugin
  • Adicionar o namespace GraphQLAPI\* à whitelist, para evitar que minhas próprias classes tenham scope

Essa é praticamente a estratégia. A partir daqui, será uma repetição dessa mesma ideia, para remover todas as dependências externas do código, até que, voilà, o plugin possa ter scope.

As dependências a extrair são apenas aquelas da seção require do seu arquivo composer.json; para require-dev você pode manter qualquer dependência, externa ou não, pois não precisamos fazer o scope de dependências usadas para desenvolvimento; apenas as usadas para criar e distribuir o plugin, para produção, precisam ter scope.

No final, o composer.json do seu plugin não deve conter nenhuma dependência externa. Para o meu plugin, é assim:

{
  "require": {
    "php": "^7.4|^8.0",
    "getpop/engine-wp": "^0.8",
    "graphql-api/markdown-convertor": "^0.8",
    "graphql-by-pop/graphql-clients-for-wp": "^0.8",
    "graphql-by-pop/graphql-endpoint-for-wp": "^0.8",
    "graphql-by-pop/graphql-server": "^0.8",
    "pop-schema/basic-directives": "^0.8",
    "pop-schema/comment-mutations-wp": "^0.8",
    "pop-schema/commentmeta-wp": "^0.8",
    "pop-schema/comments-wp": "^0.8",
    "pop-schema/custompost-mutations-wp": "^0.8",
    "pop-schema/custompostmedia-mutations-wp": "^0.8",
    "pop-schema/custompostmedia-wp": "^0.8",
    "pop-schema/custompostmeta-wp": "^0.8",
    "pop-schema/generic-customposts": "^0.8",
    "pop-schema/media-wp": "^0.8",
    "pop-schema/pages-wp": "^0.8",
    "pop-schema/post-mutations": "^0.8",
    "pop-schema/post-tags-wp": "^0.8",
    "pop-schema/posts-wp": "^0.8",
    "pop-schema/taxonomymeta-wp": "^0.8",
    "pop-schema/taxonomyquery-wp": "^0.8",
    "pop-schema/user-roles-access-control": "^0.8",
    "pop-schema/user-roles-wp": "^0.8",
    "pop-schema/user-state-mutations-wp": "^0.8",
    "pop-schema/user-state-wp": "^0.8",
    "pop-schema/usermeta-wp": "^0.8",
    "pop-schema/users-wp": "^0.8"
  }
}

Todos esses pacotes, com os namespaces getpop, graphql-api, graphql-by-pop, e pop-schema, são todos meus: dependências contendo todo o código do plugin. Eles são distribuídos em namespaces diferentes para gerenciar melhor o código, mas você não precisa fazer isso: usar um único namespace funciona bem.

Agora, à medida que o número de pacotes na sua aplicação cresce, você precisará hospedá-los todos em um monorepo, ou vai enlouquecer criando pull requests envolvendo mais de um pacote (acredite em mim, eu já passei por isso). No meu caso, todos os meus pacotes estão hospedados no monorepo GatoGraphQL/GatoGraphQL, e os mantenho sincronizados através do maravilhoso Monorepo Builder (preciso escrever um artigo sobre essa ferramenta, é um verdadeiro salva-vidas!).

Os namespaces para esses pacotes são PoP, GraphQLAPI, GraphQLByPoP e PoPSchema. Como são meus, sei que aparecerão apenas uma vez na aplicação, e então posso evitar fazer o scope deles.

Para isso, os adiciono à whitelist em scoper.inc.php:

return [
  'whitelist' => [
    // Own namespaces
    'PoPSchema\*',
    'PoP\*',
    'GraphQLByPoP\*',
    'GraphQLAPI\*',
    // Own container cache
    'PoPContainer\*',
  ],
];

A última entrada corresponde ao container de injeção de dependência, que também precisa ter scope. Por padrão, esse container recebe o nome ProjectServiceContainer, diretamente no namespace global. Mas o PHP-Scoper não suporta adicionar à whitelist classes específicas do namespace global. Portanto, adicionei o namespace artificial PoPContainer à whitelist, e atribuí esse namespace ao despejar o container em disco:

$dumper = new PhpDumper($containerBuilder);
file_put_contents(
  self::$cacheFile,
  $dumper->dump(
    // Save under own namespace to avoid conflicts
    array('namespace' => 'PoPContainer')
  )
);

Você pode notar que, em relação aos pacotes, alguns terminam com -wp (como pop-schema/users-wp) enquanto outros não (como graphql-by-pop/graphql-server). Sim, você adivinhou certo: os primeiros contêm código WordPress e nenhuma referência a bibliotecas externas, e os últimos podem conter referências a bibliotecas externas, mas nenhum código WordPress.

Então, pulo o scope dos pacotes WordPress:

return [
  'finders' => [
    // Scope packages under vendor/, excluding local WordPress packages
    Finder::create()
      ->files()
      ->notPath([
        // Exclude libraries ending in "-wp"
        '#getpop/[a-zA-Z0-9_-]*-wp/#',
        '#pop-schema/[a-zA-Z0-9_-]*-wp/#',
        '#graphql-by-pop/[a-zA-Z0-9_-]*-wp/#',
      ])
      ->in('vendor')
  ]
];

O que acontece se algum pacote WordPress precisar referenciar uma biblioteca externa, e isso não puder ser extraído para outro pacote? Por exemplo, meu pacote getpop/routing-wp depende de brain/cortex, e isso é inevitável.

Não posso fazer o scope do pacote inteiro, já que getpop/routing-wp contém código WordPress. Em vez disso, o que faço é identificar os arquivos onde essas referências são feitas, e garantir que eles não contenham nenhum código WordPress. Então posso fazer o scope apenas desses arquivos.

Nesse caso, a referência a Cortex/Brain é feita em 2 arquivos, incluindo layers/Engine/packages/routing-wp/src/Hooks/SetupCortexHookSet.php:

namespace PoP\RoutingWP\Hooks;
 
use PoP\Hooks\AbstractHookSet;
use Brain\Cortex\Route\RouteCollectionInterface;
use Brain\Cortex\Route\RouteInterface;
use Brain\Cortex\Route\QueryRoute;
use PoP\RoutingWP\WPQueries;
use PoP\Routing\Facades\RoutingManagerFacade;
 
class SetupCortexHookSet extends AbstractHookSet
{
  protected function init()
  {
    $this->hooksAPI->addAction(
      'cortex.routes',
      [$this, 'setupCortex'],
      1
    );
  }
 
  /**
   * @param RouteCollectionInterface<RouteInterface> $routes
   */
  public function setupCortex(RouteCollectionInterface $routes): void
  {
    $routingManager = RoutingManagerFacade::getInstance();
    foreach ($routingManager->getRoutes() as $route) {
      $routes->addRoute(new QueryRoute(
        $route,
        function (array $matches) {
          return WPQueries::STANDARD_NATURE;
        }
      ));
    }
  }
}

Notou a estranheza aqui? Esta é uma implementação de um hook, mas nenhum add_action é chamado, já que não posso ter código WordPress aqui. Em vez disso, chama a função addAction do serviço HooksAPIInterface, e esse serviço é implementado pela classe HooksAPI no pacote getpop/hooks-wp, onde podemos ter código WordPress:

namespace PoP\HooksWP;
 
use PoP\Hooks\HooksAPIInterface;
 
class HooksAPI implements HooksAPIInterface
{
  public function addAction(string $tag, callable $function_to_add, int $priority = 10, int $accepted_args = 1): void
  {
    add_action($tag, $function_to_add, $priority, $accepted_args);
  }
}

Agora que o código está dividido de forma limpa, podemos fazer o scope desses 2 arquivos que referenciam dependências externas:

return [
  'finders' => [
    Finder::create()->append([
      'vendor/getpop/routing-wp/src/Component.php',
      'vendor/getpop/routing-wp/src/Hooks/SetupCortexHookSet.php',
    ])
  ]
];

Anteriormente mencionei que configurar o scope levou algumas horas, mas apenas após 2 meses de trabalho. Bem, esse exemplo demonstra o que eu quis dizer: o trabalho real está em dividir a codebase de forma limpa nos 2 conjuntos.

No meu caso, o trabalho levou 2 meses porque o nível de detalhe era extremo: o plugin se tornou uma composição de 125 pacotes! Mas este é um caso excepcional, com o objetivo de que o servidor subjacente do plugin seja CMS-agnostic, para suportar uma implementação para outros CMSs/frameworks apenas reimplementando os pacotes -wp correspondentes.

(Escrevi em detalhes sobre essa estratégia, nos artigos Abstracting WordPress Code To Reuse With Other CMSs: Concepts e Implementation.)

É certamente bastante trabalho, mas a limpeza melhorada do código vale a pena. E não apenas para fazer o scope do plugin, o que veio como uma surpresa total para mim, e ainda estou euforico com essa felicidade inesperada. Por exemplo, executo PHPStan e PHPUnit separadamente no código WordPress e não-WordPress, evitando muitas dores de cabeça.

Uma vez que a codebase está organizada, o mundo de repente se torna um lugar muito melhor.

Testes

Então, como testamos essa fera?

A solução que encontrei é depender do Rector, a mesma ferramenta que uso para fazer o downgrade do código de PHP 7.4, para desenvolvimento, para 7.1, para produção.

A ideia é a seguinte:

  1. Fazer o scope do plugin
  2. Analisá-lo com o Rector, aplicando qualquer regra (não importa qual)

Se algo deu errado ao fazer o scope, então o Rector não conseguirá carregar alguma classe, e lançará um erro. Por exemplo, se a classe Brain\Cortex foi scoped como PrefixedByPoP\Brain\Cortex, mas alguma referência a ela foi mantida como Brain\Cortex, então o autoloading dessa classe falhará.

Esta é minha GitHub Action para testes (working-directory está sendo usado, porque estou operando a partir da raiz do monorepo, mas o scope acontece na pasta do plugin):

name: Scope Gato GraphQL tests
on:
  push:
    branches:
      - master
  pull_request: null
 
env:
  COMPOSER_ROOT_VERSION: "dev-master"
 
jobs:
  main:
    defaults:
      run:
        working-directory: layers/GraphQLAPIForWP/plugins/graphql-api-for-wp
 
    name: Scope the plugin code via PHP-Scoper, and execute tests
 
    runs-on: ubuntu-latest
    
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
 
      - name: Set-up PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: 7.4
          coverage: none
        env:
          COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Install root dependencies
        uses: "ramsey/composer-install@v1"
 
      - name: Install plugin dependencies for PROD
        run: composer install --no-dev --no-progress --no-interaction --ansi
 
      - name: Install PHP-Scoper
        run: |
          composer global config minimum-stability dev
          composer global config prefer-stable true
          composer global require humbug/php-scoper
 
      # The scoped results correspond to vendor/, so must generate them in such folder
      - name: Scope plugin into separate folder
        run: php-scoper add-prefix --output-dir ../../../../build-prefixed/vendor --ansi
 
      - name: Copy scoped code back into plugin
        run: rsync -av build-prefixed/ layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/ --quiet
        working-directory: .
 
      - name: Regenerate autoloader
        run: composer dumpautoload --optimize --classmap-authoritative --ansi
 
      - name: Run Rector on the scoped code
        run: vendor/bin/rector process --config=layers/GraphQLAPIForWP/plugins/graphql-api-for-wp/rector-test-scoping.php --ansi
        working-directory: .
 

E esta é minha configuração do Rector:

use Rector\CodeQuality\Rector\LogicalAnd\AndAssignsToSeparateLinesRector;
use Rector\Core\Configuration\Option;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
 
return static function (ContainerConfigurator $containerConfigurator): void {
  $services = $containerConfigurator->services();
  $services->set(AndAssignsToSeparateLinesRector::class);
  $parameters->set(Option::AUTO_IMPORT_NAMES, true);
 
  $parameters->set(Option::AUTOLOAD_PATHS, [
    __DIR__ . '/vendor/scoper-autoload.php',
    __DIR__ . '/vendor/erusev/parsedown/Parsedown.php',
    __DIR__ . '/vendor/jrfnl/php-cast-to-type/cast-to-type.php',
    __DIR__ . '/vendor/jrfnl/php-cast-to-type/class.cast-to-type.php',
  ]);
 
  // files to rector
  $parameters->set(Option::PATHS, [
    __DIR__ . '/vendor',
  ]);
 
  // files to skip
  $parameters->set(Option::SKIP, [
    // Exclude tests
    '*/tests/*',
    __DIR__ . '/vendor/nikic/fast-route/test/*',
    __DIR__ . '/vendor/psr/log/Psr/Log/Test/*',
    __DIR__ . '/vendor/symfony/service-contracts/Test/*',
  ]);
};

Você pode notar que alguns arquivos de dependências, como erusev/parsedown/Parsedown.php' precisam ser adicionados a Option::AUTOLOAD_PATHS. Isso porque fazer o scope do composer.json do pacote não é 100% confiável, e então o autoloading deles pode falhar.

Sempre que isso acontece, o Rector reclamará que alguma classe falhou no autoloading. A partir daí, identificamos o arquivo correspondente, e o adicionamos manualmente aos caminhos de autoloading.

Confira os resultados

Este é o código-fonte do plugin, e esta é sua versão com scope (e com downgrade para PHP 7.1).

Encontre as 7 diferenças 😁. (Vou dar uma dica: procure por PrefixedByPoP.)

E este é o arquivo final do plugin graphql-api.zip, pronto para ser instalado no seu site.

É isso. Espero que tenha sido útil 😃💪🚀


Assine nossa newsletter

Fique por dentro de todas as atualizações do Gato GraphQL.