Example for custom software

How We Extend the GraphQL API of CraftCMS

In this article we will show how we extend the GraphQL API of Craft CMS to make it possible to set mandatory filters. This allows the interface to be customized to individual requirements.

We've already mentioned that Craft CMS is our preferred choice for classic websites in several blog articles, for example here and here and here. Also our preference of ReactJS for the development of modern webapps is no secret. In some projects we have the opportunity to combine these two technologies. But to use React in a meaningful way it needs an API to the database. Fortunately, Craft CMS comes with a GraphQL API that can be used for this purpose. However, it lacks the ability to set certain filters. For example, it could be a requirement that only entries can be queried that you have created yourself. In this blog article we will show you how we solved this problem.

Approach

When you start to solve a problem, it's a good idea to check if someone else has already solved the problem. After a short search in the Craft CMS Plugin Store you can find an extension that solves exactly this problem. However, it adds more functionality, which we don't want in this instance. Besides, if you are a software developer, it doesn't hurt to develop software every now and then.

We already had some good experiences with the Craft CMS event system in other projects and the Craft CMS documentation, which is excellent as always, offers a dedicated entry for extending the GraphQL interface. So the decision was made, we would extend the Craft CMS GraphQL interface ourselves.

Considerations and Preparation

In order to use GraphQL with Craft CMS, a reasonable schema needs to be defined. It wouldn't make sense to use an extension to restrict the access to the data but at the same time provide much more data than needed. So we create our own schema, secure it with a token and restrict it to the entry type we want to share. Craft CMS then generates the schema for us which we can try out in the built in GraphQL Explorer. There we can see right away that not only our entry type has been shared, but also querying the number of entries for that type, all entries from all entry types that are shared and their count.

That's a bit more than we want, especially since users might be able to glean even more from this information than they should. This means we got a new requirement. We want to prevent all queries that do not query our entry type.

Creating the Module

Next, we create a new Craft CMS module and create a new hook in the init method:

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

This event is triggered before an incoming GraphQL query is executed, which is just early enough to edit it. For now, we resist the temptation to just use strcmp to search for the incoming query and instead use the same parser that CraftCMS uses under the hood to convert the query into an AST (Abstract Syntax Tree). Using the visitor class we then search for the operation that is being performed (query or mutation) and store that in the $operation variable. We also want to know which collection is being queried and store the result in the $queryCollection variable.

Since we currently only want to process queries, we check the $operation variable and if it has the value "query" we pass the result into another function.

Filter Queries

Now we got the AST of the received query, we know that it is a query and which collection is queried, all we need to do now is to filter out all queries that query collections that are not permitted. This is done in the if block of the following function. Because we want to be nice and not just return a nameless error, we give a hint which collection can be used.

After that we abstract the function which inserts the additional filter, both to keep our functions short and readable and to allow us to reuse the function in another collection if we want to insert a different filter there.

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

Decisive at this point is that Craft CMS can already tell us which user has sent the request. So we can simply pass Craft::$app->getUser()->id to the next function as the value to use for authorID. This way we make sure that only the entries of this user are returned.

Insert Filter

The function called above gets the following arguments:

  • $parsed: The AST of the query
  • $queryCollection: The name of the collection that is queried
  • $queryName: The name of the collection to apply the filter to
  • $name: The name of the filter parameter
  • $value: The value that the filter should have

First we check if the currently requested collection is the one we want to filter: if ($queryCollection === $queryName) only in this case we really want to apply the filter.

After that we search for the selection which handles our collection to be filtered and search through all arguments. If one of the arguments matches the filter we want to set, we replace the value and note that we have already replaced the value. If the argument we are looking for was not present, we have to insert it ourselves:

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

To get the query from the AST in string format we call Printer::doPrint($parsed) and return the value.

The whole function looks like this:

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

Verify

To verify that our filter works we can use the GraphQL Explorer. We need at least two entries from different users and when we submit the query only the entry written by the logged in user will be returned.

Result

Craft CMS is very easy to extend, this also applies to the provided GraphQL interface, which can be customized with only a few lines of code.

If you need help implementing your project with Craft CMS, don't hesitate to contact us for a free initial consultation.