Beispiel für Individualsoftware

Wie wir die GraphQL-Schnittstelle von Craft CMS erweitern

In diesem Artikel zeigen wir, wie wir die GraphQL Schnittstelle von Craft CMS erweitern, um verpflichtende Filter setzen zu können. Damit kann die Schnittstelle an individuelle Anforderungen angepasst werden.

Dass Craft CMS unsere bevorzugte Wahl für klassische Webseiten ist, haben wir schon in einigen Blogartikeln beschrieben, zum Beispiel hier und hier und hier. Auch unsere Vorliebe von ReactJS bei der Umsetzung von modernen Webapps ist kein Geheimnis. Bei manchen Projekten bietet sich dann die Gelegenheit, diese zwei Technologien zu verbinden. Um aber React sinnvoll nutzen zu können benötigt es eine Schnittstelle zur Datenbank. Zum Glück bietet Craft CMS von Haus aus eine GraphQL Schnittstelle, die dafür verwendet werden kann. Allerdings fehlt dieser die Möglichkeit, gewisse Filter zu setzen. Zum Beispiel könnte es eine Anforderung sein, dass nur Einträge abgefragt werden können, welche man selbst erstellt hat. Wie wir diese Anforderung gelöst haben, stellen wir in diesem Blogartikel vor.

Herangehensweise #

Wenn man beginnt, ein Problem zu lösen, ist es zuerst einmal sinnvoll zu prüfen, ob andere das Problem bereits gelöst hat. Nach kurzer Suche im Craft CMS Plugin Store findet man eine Erweiterung, die genau dieses Problem löst. Allerdings kommen damit auch noch weitere Funktionen, die wir in diesem Fall nicht wollen. Außerdem, wenn man schon Softwareentwickler ist, dann schadet es auch nicht, hin und wieder Software selbst zu entwickeln.

Wir konnten in anderen Projekten schon gute Erfahrungen mit dem Craft CMS Eventsystem sammeln und die wie immer exzellente Craft CMS Dokumentation bietet einen eigenen Eintrag für das Erweitern der GraphQL Schnittstelle. Damit war die Entscheidung gefallen, dass wir die Craft CMS GraphQL Schnittstelle selbst erweitern.

Überlegungen und Vorbereitung #

Um GraphQL mit Craft CMS verwenden zu können, muss zuerst ein sinnvolles Schema definiert werden. Da wir durch diese Erweiterung den Zugriff auf die Daten einschränken wollen, würde es keinen Sinn machen, mehr Daten zur Verfügung zu stellen, als notwendig. Also erstellen wir ein eigenes Schema, sichern es mit einem Token ab und schränken es auf den Eintragstypen ein, den wir freigeben wollen. Craft CMS erstellt dann für uns ein Schema, das wir im GraphQL Explorer ausprobieren können. Dort sehen wir dann auch gleich, dass nicht nur unser Eintragstyp freigegeben wurde, sondern auch das Abfragen der Anzahl der Einträge zu diesem Typen, alle Einträge von allen Eintragstypen die freigegeben sind und deren Anzahl.

Das ist etwas mehr als wir wollen, vor allem, da Benutzer:innen aus diesen Informationen vielleicht sogar mehr rauslesen können, als sie sollten. Das bedeutet, wir haben eine neue Anforderung bekommen. Wir wollen alle Abfragen, die nicht unseren Eintragstyp abfragen, unterbinden.

Erstellen des Moduls #

Als nächstes legen wir ein neues Craft CMS Modul an und erstellen in der init Methode einen neuen Hook:

Event::on(Gql::class, Gql::EVENT_BEFORE_EXECUTE_GQL_QUERY, function (ExecuteGqlQueryEvent $event) {
    $parsedQuery = Parser::parse(new Source($event->query, "GraphQL"));
    $operation = "";
    $queryCollection = "";

    Visitor::visit($parsedQuery, [NodeKind::OPERATION_DEFINITION => function ($node) use (&$operation, &$queryCollection) {
        $nodeArray = $node->toArray();
        $operation = $nodeArray["operation"];
        $queryCollection = $nodeArray["selectionSet"]->selections[0]->name->value;

        return Visitor::stop();
        },
    ]);

    if ($operation === "query") {
        $this->handleQueries($event, $parsedQuery, $queryCollection);
    }
});

Dieser Event wir ausgelöst bevor eine hereinkommende GraphQL Query ausgeführt wird, das heißt gerade früh genug, um sie nochmal zu bearbeiten. Kurz widerstehen wir der Versuchung, einfach nur mittels strcmp nach der aufgerufenen Query zu suchen und verwenden stattdessen den selben Parser, den auch CraftCMS unter der Haube verwendet, um die Query in einen AST (Abstract Syntax Tree) umzuwandeln. Mittels der Visitor Klasse suchen wir dann nach der Operation, die ausgeführt wird (query oder mutation) und speichern das in der Variable $operation. Außerdem wollen wir wissen, welche "Collection" abgefragt wird und speichern das Ergebnis in der Variable $queryCollection.

Da wir derzeit nur Querys bearbeiten wollen, prüfen wir die Variable $operation und falls diese den Wert "query" hat, geben wir das Ergebnis in eine weitere Funktion.

Query filtern #

Wir haben jetzt den AST der eingegangen Anfrage, wir wissen, dass es sich dabei um eine Query handelt und welche "Collection" abgefragt wird. Jetzt müssen wir nur noch alle Querys rausfiltern, welche Collection abfragen, die nicht erlaubt sind. Das geschieht im if Block der nachfolgenden Funktion. Weil wir nett sein wollen und nicht einfach nur einen Namenlosen Fehler zurückgeben wollen, geben wir einen Hinweis welche "Collection" bei einer Query in unserem System erlaubt sein soll.

Danach abstrahieren wir die Funktion, welche den zusätzlichen Filter einfügt, einerseits um unsere Funktionen kurz und leserlich zu halten und andererseits, können wir die Funktion so bei einer weiteren "Collection" wiederverwenden, falls wir dort einen anderen Filter einfügen wollen.

private function handleQueries($event, $parsedQuery, $queryCollection): void
{
    if (
        $queryCollection === "entries" ||
        $queryCollection === "entry" ||
        $queryCollection === "entryCount" ||
        $queryCollection === "exampleCount" ||
    ) {
        $event->handled = true;
        throw new Exception("Queries not permitted, use 'examples' to get all example entries for the logged in user");
    }

    $event->query = $this->addOrChangeFilter(
        $parsedQuery,
        $queryCollection,
        "examples",
        "authorId",
        (string) Craft::$app->getUser()->id
    );
}

Entscheidend an dieser Stelle ist, dass Craft CMS uns schon sagen kann, welche:r Benutzer:in die Query abgeschickt hat. Wir können an die nächste Funktion also einfach Craft::$app->getUser()->id übergeben, als Wert der für authorID eingesetzt werden soll. Damit stellen wir sicher, das nur die Einträge dieses Benutzers bzw. dieser Benutzerin zurückgeliefert werden.

Filter einfügen #

Die oben aufgerufene Funktion bekommt folgende Argumente:

  • $parsed: Der AST der Query
  • $queryCollection: Der Name der "Collection", die abgefragt wird
  • $queryName: Der Name der "Collection", auf die der Filter angewandt werden soll
  • $name: Der Name des Filterparameters
  • $value: Der Wert, den der Filter haben soll

Zuerst überprüfen wir, ob die derzeit abgefragte "Collection" überhaupt die von uns zu filternde ist: if ($queryCollection === $queryName). Nur in diesem Fall wollen wir den Filter auch wirklich einbauen.

Danach Suchen wir nach der "Selection", welche unsere zu filternde "Collection" behandelt und durchsuchen alle vorhandenen Argumente. Entspricht eines der Argumente dem Filter den wir setzen wollen, ersetzen wir den Wert und merken uns, dass wir den Wert bereits ersetzt haben. War das von uns gesuchte Argument nicht vorhanden, müssen wir es selbst einfügen:

$argument = new ArgumentNode([
    "name" => new NameNode(["value" => $name]),
    "value" => new StringValueNode([
        "value" => $value,
    ]),
]);

Um aus dem AST jetzt wieder die Query im String Format zu bekommen, rufen wir Printer::doPrint($parsed); auf und geben den Wert zurück.

Die ganze Funktion sieht dann so aus:

private function addOrChangeFilter(
    $parsed,
    string $queryCollection,
    string $queryName,
    string $name,
    string $value
): string {
    if ($queryCollection === $queryName) {
        Visitor::visit($parsed, [
            NodeKind::OPERATION_DEFINITION => function ($node) use (&$queryName, &$name, &$value) {
                $replaced = false;
                $nodeArray = $node->toArray();
                $selections = $nodeArray["selectionSet"]->selections;
                foreach ($selections as $selection) {
                    if ($selection->name->value === $queryName) {
                        $arguments = $selection->arguments;

                        foreach ($arguments as $argument) {
                            if ($argument->name->value === $name) {
                                $argument->value->value = $value;
                                $replaced = true;
                            }
                        }
                        if (!$replaced) {
                            $argument = new ArgumentNode([
                                "name" => new NameNode(["value" => $name]),
                                "value" => new StringValueNode([
                                    "value" => $value,
                                ]),
                            ]);
                            $selection->arguments = new NodeList([$argument]);
                        }
                    }
                }

                return Visitor::stop();
            },
        ]);
    }
    return Printer::doPrint($parsed);
}

Überprüfen #

Zum Überprüfen, ob unser Filter funktioniert, können wir den GraphQL Explorer verwenden. Wir benötigen zumindest zwei Einträge von verschiedenen Benutzer:innen und wenn wir dann die Query abschicken, wird nur der Eintrag zurückgeliefert, welcher vom eingeloggten Benutzer geschrieben wurde.

Ergebnis #

Craft CMS lässt sich sehr leicht erweitern, das gilt auch für die bereitgestellte GraphQL Schnittstelle, welche mit nur wenigen Zeilen Code auf die eigenen Bedürfnisse angepasst werden kann.

Benötigen Sie Hilfe beim Umsetzen Ihres Projekts mit Craft CMS? Dann melden sie sich bei uns, wir besprechen Ihre Idee gerne in einem kostenlosen und unverbindlichen Erstgespräch mit Ihnen.