Blog

🦸🏻‍♂️ Presentando: Headless WordPress sin WordPress

Leonardo Losoviz
Por Leonardo Losoviz ·

Desde el desastre Matt Mullenweg vs WPEngine, he notado a más y más gente en Reddit (y en otros lugares) pidiendo alternativas a WordPress, no necesariamente para abandonar WordPress (al menos no inmediatamente), sino para entender qué opciones tienen, y cuán dolorosa sería una posible migración. Quieren saber cómo cubrir sus apuestas.

Para quienes están trabajando con headless WordPress, Gato GraphQL ahora ofrece una nueva característica chula: Headless WordPress sin WordPress.

Esta entrada lo explica todo, describiendo cómo es posible, y mostrando un vídeo que lo demuestra.

Ejecutar Gato GraphQL como una app PHP standalone

Gato GraphQL se ha construido usando componentes PHP standalone, gestionados vía Composer, de tal forma que ¡todos los componentes PHP que conforman el servidor GraphQL no dependen de WordPress!

Como tal, el servidor GraphQL puede ejecutarse como una aplicación PHP standalone, y puedes incluirlo dentro de cualquier aplicación PHP, basada en WordPress o cualquier otra cosa.

Si para algún caso de uso tu aplicación no necesita acceder a datos de WordPress, entonces, al menos para ese caso de uso, estás listo para empezar.

Este vídeo demuestra tal caso de uso: Interactuar con la API de GitHub, para descargar/instalar artefactos desde GitHub Actions durante el desarrollo:

Demo de Headless WordPress sin WordPress: Ejecutando una consulta GraphQL

En el vídeo, la consulta GraphQL ejecuta una petición HTTP para obtener los plugins más recientes de Gato GraphQL generados en GitHub Actions, que se suben como artefactos al mergear un pull request.

Las URLs de los artefactos de la respuesta GraphQL son entonces inyectadas en WP-CLI, para que los plugins sean instalados automáticamente en un servidor local DEV, para ejecutar tests.

(Explicaré más en detalle en la última sección de esta entrada.)

En este caso de uso, como no se accede a ningún dato de WordPress en absoluto, el servidor GraphQL ya puede ejecutarse como una app PHP standalone.

¡Si lo necesitara, incluso podría usarlo dentro de mi workflow de GitHub Actions!

Migrar una app headless WordPress

Cuando sí accedas a datos de WordPress, veamos cómo ejecutar eso sin WordPress.

El esquema GraphQL proporcionado por Gato GraphQL contiene campos para obtener datos de WordPress: posts, users, comments, tags, categories, etc.

El código en los resolvers PHP que obtiene datos de WordPress depende de WordPress; ese código no puede ejecutarse en una app no-WordPress.

Sin embargo, Gato GraphQL tiene cada uno de estos resolvers implementados vía 2 paquetes:

  1. Uno "vanilla" PHP, que contiene todo el código genérico
  2. Uno específico de WordPress, que contiene las invocaciones reales a los métodos de WordPress que satisfacen ese resolver

Por ejemplo, en esta consulta GraphQL:

{
  posts {
    id
    title
  }
}

...la lógica para obtener entradas se compone de:

  1. El campo Root.posts: Vive en el paquete posts genérico
  2. Su resolución para WordPress vía el método get_posts: Vive en el paquete posts-wp específico de WordPress.

La división del código entre paquetes no-WordPress/WordPress es algo así como 80/20 %, lo que significa que el 80 % del código es reutilizable con otro framework/CMS, y solo el 20 % del código necesitaría ser reimplementado.

Además, toda la funcionalidad en Gato GraphQL se distribuye vía módulos, y los módulos pueden habilitarse/deshabilitarse a voluntad.

Módulos de esquema
Módulos de esquema

Modules es una característica implementada por motivos de seguridad: Si no necesitas exponer datos de usuario en tu API pública, entonces puedes deshabilitar el módulo Users, y los campos correspondientes (como Root.users) nunca se añadirán al esquema.

Los módulos están mapeados directamente a los paquetes PHP subyacentes. Como tal, al ejecutar Gato GraphQL como una app standalone, podemos cargar selectivamente los módulos/paquetes que necesitemos, y ningún otro.

Por ejemplo, si tu aplicación solo imprime datos para posts, categorías y tags, entonces solo necesitan cargarse los paquetes posts-wp, categories-wp, y tags-wp (junto con sus dependencias).

Entonces, al migrar lejos de WordPress (digamos, a Laravel, o Symfony), solo esos 3 paquetes específicos de WordPress necesitarían ser reimplementados para el nuevo framework/CMS, y nada más.

En consecuencia, puedes usar headless WordPress hoy, sabiendo que más adelante puedes migrar tu aplicación a otro framework o CMS con un esfuerzo mínimo.

Transicionar a Gato GraphQL desde otra API

Si ya estás haciendo headless WordPress, lo más probable es que tu app esté usando o bien la WP REST API o WPGraphQL.

Desafortunadamente, con cualquiera de estas dos APIs estás atado a WordPress: No hay WP REST API fuera de WordPress, y WPGraphQL no puede ejecutarse sin WordPress.

Afortunadamente, es posible intercambiar cualquiera de ellas por Gato GraphQL, y ganar la capacidad de migrar tu app headless WordPress fuera de WordPress.

Estos 2 pasos serían entonces necesarios:

  1. Transicionar de WP REST API o WPGraphQL a Gato GraphQL
  2. Reimplementar los paquetes específicos de WordPress requeridos

Veamos cómo se puede hacer la transición de la API.

WP REST API a persisted queries de Gato GraphQL

Con la extensión Persisted queries puedes publicar endpoints similares a REST, compuestos usando GraphQL.

Para cada uno de los endpoints REST en tu aplicación, puedes crear un endpoint de persisted query correspondiente que recupere los mismos datos, y usar ese endpoint en su lugar.

Por ejemplo, la siguiente consulta GraphQL puede reemplazar al endpoint REST /wp-json/wp/v2/posts/:

{
  posts {
    id
    date: dateStr(format: "Y-m-d\\TH:i:s")
    modified: modifiedDateStr(format: "Y-m-d\\TH:i:s")
    slug
    status
    link: url
    title: self {
      rendered: title
    }
    content: self {
      rendered: content
    },
    excerpt: self {
      rendered: excerpt
    }
    author
    featured_media: featuredImage
    sticky: isSticky
    categories
    tags
  }
}

Gracias a la jerarquía de la API, la persisted query puede publicarse bajo la ruta /graphql-query/wp/v2/posts/, haciendo fácil mapear endpoints.

Para replicar el endpoint REST /wp-json/wp/v2/posts/{id}/, que recupera los datos para la entrada con ID dado, podemos proporcionar el ID de la entrada bajo el parámetro URL postId.

Por ejemplo, la siguiente persisted query puede invocarse bajo el endpoint /graphql-query/wp/v2/posts/single/?postId={id}:

query GetPost($postId: ID!) {
  post(by: { id: $postId }) {
    id
    date: dateStr(format: "Y-m-d\\TH:i:s")
    modified: modifiedDateStr(format: "Y-m-d\\TH:i:s")
    slug
    status
    link: url
    title: self {
      rendered: title
    }
    content: self {
      rendered: content
    },
    excerpt: self {
      rendered: excerpt
    }
    author
    featured_media: featuredImage
    sticky: isSticky
    categories
    tags
  }
}

WPGraphQL a Gato GraphQL

El esquema GraphQL de WPGraphQL y de Gato GraphQL son similares pero ligeramente diferentes, así que necesitan adaptarse.

El starter de WordPress con Next.js leoloso/next-wordpress-starter funciona tanto con WPGraphQL como con Gato GraphQL. El starter usa la misma lógica JS para cualquiera de los servidores, solo las consultas GraphQL son diferentes.

Este starter proporciona varios ejemplos de adaptación de las consultas entre los dos servidores. Por ejemplo, esta consulta WPGraphQL:

fragment PostFields on Post {
  id
  categories {
    edges {
      node {
        databaseId
        id
        name
        slug
      }
    }
  }
  databaseId
  date
  isSticky
  postId
  slug
  title
}

...se adapta así para Gato GraphQL:

fragment PostFields on Post {
  id
  categories: self {
    edges: categories(pagination: { limit: -1 }) {
      node: self {
        databaseId: id
        id
        name
        slug
      }
    }
  }
  databaseId: id
  date: dateStr
  isSticky
  postId: id
  slug
  title
}

En detalle: Ejecutar Gato GraphQL como una app PHP standalone

Aquí está la explicación detallada del vídeo de demostración anterior.

Proporcionamos la consulta GraphQL a ejecutar en el fichero retrieve-github-artifacts.gql.

La consulta se conecta a la API de GitHub obteniendo el token de acceso desde la variable de entorno GITHUB_ACCESS_TOKEN. Genera dinámicamente la ruta completa para el endpoint actions/artifacts desde las variables proporcionadas, y luego envía una petición HTTP contra él.

Desde la respuesta, extrae la "download URL" de dentro de cada elemento de artefacto, y envía peticiones HTTP asíncronas contra ellas. Desde la cabecera Location de cada una de estas "download URLs", obtenemos la URL real del fichero descargable.

Finalmente, imprime todas las URLs juntas separadas por un espacio, para que sea cómodo inyectarlas en WP-CLI.

# File retrieve-github-artifacts.gql
 
query RetrieveProxyArtifactDownloadURLs(
  $repoOwner: String!
  $repoProject: String!
  $perPage: Int = 1
  $artifactName: String = ""
) {
  githubAccessToken: _env(name: "GITHUB_ACCESS_TOKEN")
    @remove
 
  # Create the authorization header to send to GitHub
  authorizationHeader: _sprintf(
    string: "Bearer %s"
    values: [$__githubAccessToken]
  )
    @remove
 
  # Create the authorization header to send to GitHub
  githubRequestHeaders: _echo(
    value: [
      { name: "Accept", value: "application/vnd.github+json" }
      { name: "Authorization", value: $__authorizationHeader }
    ]
  )
    @remove
    @export(as: "githubRequestHeaders")
 
  githubAPIEndpoint: _sprintf(
    string: "https://api.github.com/repos/%s/%s/actions/artifacts?per_page=%s&name=%s"
    values: [$repoOwner, $repoProject, $perPage, $artifactName]
  )
 
  # Use the field from "Send HTTP Request Fields" to connect to GitHub
  gitHubArtifactData: _sendJSONObjectItemHTTPRequest(
    input: {
      url: $__githubAPIEndpoint
      options: { headers: $__githubRequestHeaders }
    }
  )
    @remove
 
  # Finally just extract the URL from within each "artifacts" item
  gitHubProxyArtifactDownloadURLs: _objectProperty(
    object: $__gitHubArtifactData
    by: { key: "artifacts" }
  )
    @underEachArrayItem(passValueOnwardsAs: "artifactItem")
      @applyField(
        name: "_objectProperty"
        arguments: { object: $artifactItem, by: { key: "archive_download_url" } }
        setResultInResponse: true
      )
    @export(as: "gitHubProxyArtifactDownloadURLs")
}
 
query CreateHTTPRequestInputs
  @depends(on: "RetrieveProxyArtifactDownloadURLs")
{
  httpRequestInputs: _echo(value: $gitHubProxyArtifactDownloadURLs)
    @underEachArrayItem(passValueOnwardsAs: "url")
      @applyField(
        name: "_objectAddEntry"
        arguments: {
          object: {
            options: { headers: $githubRequestHeaders, allowRedirects: null }
          }
          key: "url"
          value: $url
        }
        setResultInResponse: true
      )
    @export(as: "httpRequestInputs")
    @remove
}
 
query RetrieveActualArtifactDownloadURLs
  @depends(on: "CreateHTTPRequestInputs")
{
  _sendHTTPRequests(inputs: $httpRequestInputs) {
    artifactDownloadURL: header(name: "Location")
      @export(as: "artifactDownloadURLs", type: LIST)
  }
}
 
query PrintSpaceSeparatedArtifactDownloadURLs
  @depends(on: "RetrieveActualArtifactDownloadURLs")
{
  spaceSeparatedArtifactDownloadURLs: _arrayJoin(
    array: $artifactDownloadURLs
    separator: " "
  )
}

La lógica PHP carga directamente el código desde el plugin Gato GraphQL, y desde el bundle "Power Extensions" (necesario para enviar peticiones HTTP, y otras funcionalidades).

Como app PHP standalone, debemos indicar explícitamente qué módulos se inicializan, y proporcionar cualquier configuración no predeterminada.

Por ejemplo, le decimos al módulo SendHTTPRequests que permita conectarse a https://api.github.com/repos, y al módulo EnvironmentFields que permita acceder a la variable de entorno GITHUB_ACCESS_TOKEN.

Fíjate que el esquema GraphQL se genera la primera vez que se ejecuta la consulta GraphQL, y se cachea a disco. De esta forma, desde la 2ª vez en adelante, no se ejecuta nada del código para calcular el esquema, haciendo la ejecución más rápida.

Finalmente, la app standalone inicializa el servidor GraphQL, ejecuta la consulta contra él, e imprime la respuesta.

<?php
// File retrieve-github-artifacts.php
 
declare(strict_types=1);
 
use GraphQLByPoP\GraphQLServer\Server\StandaloneGraphQLServer;
use PoP\Root\Container\ContainerCacheConfiguration;
 
// Load the GraphQL server via the standalone PHP components
require_once (__DIR__ . '/wordpress/wp-content/plugins/gatographql/vendor/scoper-autoload.php');
 
// Load the PRO extensions via the standalone PHP components
require_once (__DIR__ . '/wordpress/wp-content/plugins/gatographql-power-extensions-bundle/vendor/scoper-autoload.php');
 
// Modules required in the GraphQL query
$moduleClasses = [
  \PoPSchema\EnvironmentFields\Module::class,
  \PoPSchema\FunctionFields\Module::class,
  \GraphQLByPoP\ExportDirective\Module::class,
  \GraphQLByPoP\DependsOnOperationsDirective\Module::class,
  \GraphQLByPoP\RemoveDirective\Module::class,
  \PoPSchema\ApplyFieldDirective\Module::class,
  \PoPSchema\SendHTTPRequests\Module::class,
  \PoPSchema\ConditionalMetaDirectives\Module::class,
  \PoPSchema\DataIterationMetaDirectives\Module::class,
];
 
// Configure the modules
$moduleClassConfiguration = [
  \PoP\GraphQLParser\Module::class => [
    \PoP\GraphQLParser\Environment::ENABLE_MULTIPLE_QUERY_EXECUTION => true,
    \PoP\GraphQLParser\Environment::USE_LAST_OPERATION_IN_DOCUMENT_FOR_MULTIPLE_QUERY_EXECUTION_WHEN_OPERATION_NAME_NOT_PROVIDED => true,
    \PoP\GraphQLParser\Environment::ENABLE_RESOLVED_FIELD_VARIABLE_REFERENCES => true,
    \PoP\GraphQLParser\Environment::ENABLE_COMPOSABLE_DIRECTIVES => true,
  ],
  \PoPSchema\SendHTTPRequests\Module::class => [
    \PoPSchema\SendHTTPRequests\Environment::SEND_HTTP_REQUEST_URL_ENTRIES => [
      '#https://api.github.com/repos/(.*)#',
    ],
  ],
  \PoPSchema\EnvironmentFields\Module::class => [
    \PoPSchema\EnvironmentFields\Environment::ENVIRONMENT_VARIABLE_OR_PHP_CONSTANT_ENTRIES => [
      'GITHUB_ACCESS_TOKEN',
    ],
  ],
];
 
// Cache the schema to disk, to speed-up execution from the 2nd time onwards
$containerCacheConfiguration = new ContainerCacheConfiguration('MyGraphQLServer', true, 'retrieve-github-artifacts', __DIR__ . '/tmp');
 
// Initialize the server
$graphQLServer = new StandaloneGraphQLServer($moduleClasses, $moduleClassConfiguration, [], [], $containerCacheConfiguration);
 
/**
 * GraphQL query to execute, stored in its own .gql file
 *
 * @var string
 */
$query = file_get_contents(__DIR__ . '/retrieve-github-artifacts.gql');
 
// GraphQL variables
$variables = [
  'repoOwner' => 'GatoGraphQL',
  'repoProject' => 'GatoGraphQL',
  'perPage' => 3
];
 
// Execute the query
$response = $graphQLServer->execute(
  $query,
  $variables,
);
 
// Print the response
echo $response->getContent();

Para ejecutar la consulta GraphQL, lo ejecutamos en el terminal (usando jq para imprimir la salida JSON con formato):

php retrieve-github-artifacts.php | jq

Finalmente, para extraer las URLs de los artefactos desde la respuesta GraphQL, e inyectarlas en WP-CLI, ejecutamos:

GITHUB_ARTIFACT_URLS=$(php retrieve-github-artifacts.php \
  | grep -E -o '"spaceSeparatedArtifactDownloadURLs\":"(.*)"' \
  | cut -d':' -f2- | cut -d'"' -f2- | rev | cut -d'"' -f2- | rev \
  | sed 's/\\\//\//g')
wp plugin install ${GITHUB_ARTIFACT_URLS} --force --activate

Como se muestra en el vídeo, somos capaces de ejecutar Gato GraphQL sin WordPress.


Suscríbete a nuestra newsletter

Mantente al tanto de todas las novedades de Gato GraphQL.