Blog

🦸🏻‍♂️ Vorstellung: Headless WordPress ohne WordPress

Leonardo Losoviz
Von Leonardo Losoviz ·

Seit dem Debakel zwischen Matt Mullenweg und WPEngine habe ich bemerkt, dass immer mehr Menschen auf Reddit (und anderswo) nach Alternativen zu WordPress fragen – nicht unbedingt um WordPress sofort zu verlassen, sondern um zu verstehen, welche Möglichkeiten sie haben und wie schmerzhaft eine eventuelle Migration wäre. Sie wollen wissen, wie sie ihre Optionen absichern können.

FĂĽr alle, die mit headless WordPress arbeiten, bietet Gato GraphQL jetzt eine coole neue Funktion: Headless WordPress ohne WordPress.

Dieser Artikel erklärt alles dazu, beschreibt, wie das überhaupt möglich ist, und zeigt ein Demo-Video.

Gato GraphQL als eigenständige PHP-App ausführen

Gato GraphQL wurde mit eigenständigen PHP-Komponenten entwickelt, die über Composer verwaltet werden, sodass alle PHP-Komponenten des GraphQL-Servers nicht von WordPress abhängig sind!

Daher kann der GraphQL-Server als eigenständige PHP-Anwendung laufen, und du kannst ihn in jede beliebige PHP-Anwendung einbinden – ob auf WordPress basierend oder auf etwas anderem.

Wenn deine Anwendung fĂĽr einen bestimmten Anwendungsfall nicht auf WordPress-Daten zugreifen muss, bist du zumindest fĂĽr diesen Anwendungsfall sofort startklar.

Dieses Video zeigt einen solchen Anwendungsfall: die Interaktion mit der GitHub-API, um Artefakte aus GitHub Actions während der Entwicklung herunterzuladen/zu installieren:

Demo Headless WordPress ohne WordPress: AusfĂĽhrung einer GraphQL query

Im Video fĂĽhrt die GraphQL query eine HTTP-Anfrage aus, um die neuesten Gato GraphQL-Plugins zu laden, die in GitHub Actions generiert und beim Mergen eines Pull Requests als Artefakte hochgeladen werden.

Die URLs der Artefakte aus der GraphQL-Antwort werden dann in WP-CLI eingespeist, sodass die Plugins automatisch auf einem lokalen DEV-Webserver installiert werden, um Tests auszufĂĽhren.

(Ich erkläre das im letzten Abschnitt dieses Artikels noch ausführlicher.)

In diesem Anwendungsfall, da überhaupt keine WordPress-Daten abgerufen werden, kann der GraphQL-Server bereits als eigenständige PHP-App laufen.

Bei Bedarf könnte ich ihn sogar innerhalb meines GitHub Actions-Workflows nutzen!

Eine headless WordPress-App migrieren

Immer wenn du tatsächlich auf WordPress-Daten zugreifst, schauen wir uns an, wie das ohne WordPress funktionieren kann.

Das von Gato GraphQL bereitgestellte GraphQL-Schema enthält Felder zum Abrufen von WordPress-Daten: posts, users, comments, tags, categories usw.

Der Code in den PHP-Resolvern, der WordPress-Daten abruft, ist von WordPress abhängig; dieser Code kann nicht auf einer Nicht-WordPress-App ausgeführt werden.

Allerdings hat Gato GraphQL jeden dieser Resolver ĂĽber 2 Pakete implementiert:

  1. Ein "vanilla" PHP-Paket, das den gesamten generischen Code enthält
  2. Ein WordPress-spezifisches Paket, das die eigentlichen Aufrufe an WordPress-Methoden enthält, die diesen Resolver bedienen

Zum Beispiel setzt sich die Logik zum Abrufen von Posts in dieser GraphQL query:

{
  posts {
    id
    title
  }
}

...aus folgenden Teilen zusammen:

  1. Das Root.posts-Feld: Es befindet sich im generischen posts-Paket
  2. Seine WordPress-spezifische Auflösung über die get_posts-Methode: Sie befindet sich im WordPress-spezifischen posts-wp-Paket.

Die Code-Aufteilung zwischen Nicht-WordPress- und WordPress-Paketen beträgt etwa 80/20 %, was bedeutet, dass 80 % des Codes mit einem anderen Framework/CMS wiederverwendbar sind und nur 20 % des Codes neu implementiert werden müssten.

Außerdem werden alle Funktionen in Gato GraphQL über Module ausgeliefert, und Module können nach Belieben aktiviert/deaktiviert werden.

Schema-Module
Schema-Module

Modules ist eine Funktion, die aus Sicherheitsgründen implementiert wurde: Wenn du keine Benutzerdaten in deiner öffentlichen API verfügbar machen musst, kannst du das Users-Modul deaktivieren, und die entsprechenden Felder (wie Root.users) werden dem Schema nie hinzugefügt.

Module sind direkt den zugrunde liegenden PHP-Paketen zugeordnet. Daher können wir beim Ausführen von Gato GraphQL als eigenständige App gezielt die benötigten Module/Pakete laden und keine anderen.

Wenn deine Anwendung beispielsweise nur Daten für Posts, Kategorien und Tags ausgibt, müssen nur die Pakete posts-wp, categories-wp und tags-wp (zusammen mit ihren Abhängigkeiten) geladen werden.

Wenn du dann von WordPress weg migrierst (z. B. zu Laravel oder Symfony), müssen nur diese 3 WordPress-spezifischen Pakete für das neue Framework/CMS neu implementiert werden – und nichts weiter.

Dadurch kannst du headless WordPress heute nutzen und weißt, dass du deine Anwendung später zu einem anderen Framework oder CMS mit minimalem Aufwand migrieren kannst.

Von einer anderen API zu Gato GraphQL wechseln

Wenn du bereits headless WordPress betreibst, verwendet deine App wahrscheinlich entweder die WP REST API oder WPGraphQL.

Leider bist du mit beiden APIs an WordPress gebunden: Es gibt keine WP REST API auĂźerhalb von WordPress, und WPGraphQL kann nicht ohne WordPress laufen.

Glücklicherweise ist es möglich, eine der beiden durch Gato GraphQL zu ersetzen und so die Möglichkeit zu gewinnen, deine headless WordPress-App von WordPress zu migrieren.

Dafür wären diese 2 Schritte erforderlich:

  1. Von WP REST API oder WPGraphQL zu Gato GraphQL wechseln
  2. Die erforderlichen WordPress-spezifischen Pakete neu implementieren

Schauen wir uns an, wie der API-Wechsel durchgefĂĽhrt werden kann.

Von WP REST API zu Gato GraphQLs persisted queries

Mit der Persisted Queries-Erweiterung kannst du REST-ähnliche Endpunkte veröffentlichen, die mit GraphQL zusammengestellt werden.

FĂĽr jeden der REST-Endpunkte in deiner Anwendung kannst du einen entsprechenden persisted query-Endpunkt erstellen, der dieselben Daten abruft, und diesen Endpunkt stattdessen verwenden.

Beispielsweise kann die folgende GraphQL query den REST-Endpunkt /wp-json/wp/v2/posts/ ersetzen:

{
  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
  }
}

Dank der API-Hierarchie kann die persisted query unter dem Pfad /graphql-query/wp/v2/posts/ veröffentlicht werden, was das Mapping der Endpunkte erleichtert.

Um den REST-Endpunkt /wp-json/wp/v2/posts/{id}/ zu replizieren, der Daten für den Post mit der angegebenen ID abruft, können wir die Post-ID über den URL-Parameter postId übergeben.

Beispielsweise kann die folgende persisted query unter dem Endpunkt /graphql-query/wp/v2/posts/single/?postId={id} aufgerufen werden:

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
  }
}

Von WPGraphQL zu Gato GraphQL

Das GraphQL-Schema von WPGraphQL und das von Gato GraphQL sind ähnlich, aber leicht unterschiedlich, daher müssen sie angepasst werden.

Der Next.js WordPress-Starter leoloso/next-wordpress-starter funktioniert sowohl mit WPGraphQL als auch mit Gato GraphQL. Der Starter verwendet dieselbe JS-Logik fĂĽr beide Server, nur die GraphQL queries sind unterschiedlich.

Dieser Starter bietet mehrere Beispiele fĂĽr die Anpassung der queries zwischen den beiden Servern. Zum Beispiel wird diese WPGraphQL query:

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

...so fĂĽr Gato GraphQL angepasst:

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
}

Im Detail: Gato GraphQL als eigenständige PHP-App ausführen

Hier ist die ausführliche Erklärung des Demo-Videos von vorhin.

Wir stellen die auszufĂĽhrende GraphQL query in der Datei retrieve-github-artifacts.gql bereit.

Die query verbindet sich mit der GitHub-API, indem sie das Zugriffstoken aus der Umgebungsvariable GITHUB_ACCESS_TOKEN liest. Sie generiert dynamisch den vollständigen Pfad für den actions/artifacts-Endpunkt aus den bereitgestellten Variablen und sendet dann eine HTTP-Anfrage dagegen.

Aus der Antwort extrahiert sie dann die "Download-URL" aus jedem Artefakt-Element und sendet asynchrone HTTP-Anfragen dagegen. Aus dem Location-Header jeder dieser "Download-URLs" erhalten wir die tatsächliche URL der herunterladbaren Datei.

AbschlieĂźend gibt sie alle URLs durch ein Leerzeichen getrennt aus, um die Einspeisung in WP-CLI zu erleichtern.

# 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: " "
  )
}

Die PHP-Logik lädt direkt den Code aus dem Gato GraphQL-Plugin und aus dem "Power Extensions"-Bundle (notwendig zum Senden von HTTP-Anfragen und für andere Funktionen).

Als eigenständige PHP-App müssen wir explizit angeben, welche Module initialisiert werden, und jede nicht standardmäßige Konfiguration bereitstellen.

Beispielsweise weisen wir das Modul SendHTTPRequests an, die Verbindung zu https://api.github.com/repos zu erlauben, und das Modul EnvironmentFields, den Zugriff auf die Umgebungsvariable GITHUB_ACCESS_TOKEN zu erlauben.

Beachte, dass das GraphQL-Schema beim ersten AusfĂĽhren der GraphQL query generiert und auf der Festplatte gecacht wird. Auf diese Weise wird ab dem 2. Mal kein Code mehr zur Berechnung des Schemas ausgefĂĽhrt, was die AusfĂĽhrung beschleunigt.

Schließlich initialisiert die eigenständige App den GraphQL-Server, führt die query dagegen aus und gibt die Antwort aus.

<?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();

Um die GraphQL query auszufĂĽhren, starten wir im Terminal (mit jq zur ĂĽbersichtlichen Darstellung der JSON-Ausgabe):

php retrieve-github-artifacts.php | jq

AbschlieĂźend, um die Artefakt-URLs aus der GraphQL-Antwort zu extrahieren und in WP-CLI einzuspeisen, fĂĽhren wir aus:

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

Wie im Video gezeigt, können wir Gato GraphQL ohne WordPress ausführen.


Abonniere unseren Newsletter

Bleib ĂĽber alle Updates zu Gato GraphQL auf dem Laufenden.