Sep 05, 2017

Drupal 8 and GraphQL. Part 2

GraphQL module for Drupal is poorly documented and the existing documentation is behind on new versions of the module. In these two articles I will share my experience with this module and what I found reading Stack overflow and issues on Github. 

Drupal 8 and GraphQL. Part 1

Drupal 8 and GraphQL. Part 2:

Custom query 

Let's say the Views functionality is not enough for us, so we will write a custom query. Let's have a look at the following example: a website has web pages for different products. Every page is a Drupal node. If we visit such a web page, its address will be /node/:nid. But if we want something more fancy, like /shop/jam or /shop/bicycle, we need to search nodes by alias.

Let's lay a basis for the future selection. We create the Product content type and install Pathauto module to generate pattern-based paths.

composer require drupal/pathauto

Then we create aliases for URLs of the products:

We generate the URLs and choose to display the Product content type as GraphQL:

Also we add this content type to Exposed Content in GraphQL module.

Now we need to write a query in the our graphql_custom_fields module (see previous article). Add a new class:

<?php

namespace Drupal\graphql_custom_fields\Plugin\GraphQL\Fields;

use Drupal\graphql_core\GraphQL\FieldPluginBase;
use Drupal\node\Entity\Node;
use Youshido\GraphQL\Execution\ResolveInfo;

/**
 * Retrieve Node by url alias
 *
 * @GraphQLField(
 *   id = "node_by_alias",
 *   name = "NodeByAlias",
 *   type = "Entity",
 *   arguments = {
 *    "alias" = "String"
 *   },
 * )
 */
class NodeByAlias extends FieldPluginBase {

  public function resolveValues($value, array $args, ResolveInfo $info) {
      $path = \Drupal::service('path.alias_manager')->getPathByAlias('/'.trim($args['alias'], '/'));
      if(preg_match('/node\/(\d+)/', $path, $matches)) {
        $node = Node::load($matches[1]);

        yield $node;
      }
  }
}

All this looks quite similar to the previous case. We don't specify types here because it is a query, not a field, but we have the alias parameter, which is the alias for a URL of a product.

Everything is ready to run the query. Let's test it! Also, don't forget that you can find all the available fields and queries in the Docs sidebar.

query Product($alias: String!) {
  product: NodeByAlias(alias: $alias) {
    ...product
  }
}

fragment product on NodeProduct {
  name: entityLabel
  body
  fieldPicture
}

At the bottom left of the explorer you can enter the variables for the query. Let’s try to get information on jam:

{
  "alias": "/shop/jam"
}

We run the query... and get the expected result!

Query examples

Let's look at a short example on type creation. Suppose we want to send a website some settings, for example, we created a module with a settings form. In this example we will try to send several fields from Basic Site Settings (/admin/config/system/site-information) in a query. 

In the Types directory, create a file with a new type  BasicSettings.php:

<?php

namespace Drupal\graphql_custom_fields\Plugin\GraphQL\Types;

use Drupal\graphql_core\GraphQL\TypePluginBase;

/**
 * GraphQL type representing Gravitzapa settings.
 *
 * @GraphQLType(
 *   id = "basic_settings_type",
 *   name = "BasicSettings"
 * )
 */
class BasicSettings extends TypePluginBase {
}

It's all simple, name is just the name of the type. Then we create a query as in the previous part:

<?php

namespace Drupal\graphql_custom_fields\Plugin\GraphQL\Fields;

use Drupal\graphql_core\GraphQL\FieldPluginBase;
use Youshido\GraphQL\Execution\ResolveInfo;

/**
 * Retrieve basic site settings
 *
 * @GraphQLField(
 *   id = "basic_settings",
 *   name = "BasicSettings",
 *   type = "BasicSettings",
 * )
 */
class BasicSettings extends FieldPluginBase {

  public function resolveValues($value, array $args, ResolveInfo $info) {
    $config = \Drupal::config('system.site');

    yield ['name' => $config->get('name'), 'slogan' => $config->get('slogan')];
  }
}

Note that we indicated the class we created as the type. Now we create two fields to show name and slogan. Name field:

<?php

namespace Drupal\graphql_custom_fields\Plugin\GraphQL\Fields;

use Drupal\graphql_core\GraphQL\FieldPluginBase;
use Youshido\GraphQL\Execution\ResolveInfo;

/**
 * GraphQL field resolving a Basic site settings field "name".
 *
 * @GraphQLField(
 *   id = "basic_site_name",
 *   name = "name",
 *   type = "String",
 *   types = {"BasicSettings"}
 * )
 */
class BasicSiteName extends FieldPluginBase {
  public function resolveValues($value, array $args, ResolveInfo $info) {
    yield $value['name'];
  }
}

Code for the slogan field will be the same, so we'll skip it. Here is our query: 

query Settings {
  BasicSettings {
    name
    slogan
  }
}

But what if we need to send a data array? For example, we want to get a list of random products. Let's create a new query that returns a certain number of random products.

<?php

namespace Drupal\graphql_custom_fields\Plugin\GraphQL\Fields;

use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\graphql_core\GraphQL\FieldPluginBase;
use Youshido\GraphQL\Execution\ResolveInfo;

/**
 * Retrieve random products
 *
 * @GraphQLField(
 *   id = "random_products",
 *   name = "RandomProducts",
 *   type = "Entity",
 *   arguments = {
 *    "count" = "Int"
 *   },
 *   multi = true
 * )
 */
class RandomProducts extends FieldPluginBase implements ContainerFactoryPluginInterface {
  use DependencySerializationTrait;

  protected $entityTypeManager;

  public static function create(ContainerInterface $container, array $configuration, $pluginId, $pluginDefinition) {
    return new static($configuration, $pluginId, $pluginDefinition, $container->get('entity_type.manager'));
  }

  public function __construct(array $configuration, $pluginId, $pluginDefinition, EntityTypeManagerInterface $entityTypeManager)           {
    $this->entityTypeManager = $entityTypeManager;
    parent::__construct($configuration, $pluginId, $pluginDefinition);
  }

  public function resolveValues($value, array $args, ResolveInfo $info) {
    $query = \Drupal::entityQuery('node');
    $query->condition('type', 'product');

    $result = $query->execute();
    if (count($result)) {
      $keys = array_rand($result, $args['count']);
      $keys = array_filter($result, function($key) use ($keys) {
        return in_array($key, $keys);
      }, ARRAY_FILTER_USE_KEY);
      $nodes = $this->entityTypeManager->getStorage('node')->loadMultiple($keys);

      foreach ($nodes as $node) {
        yield $node;
      }
    } else {
      yield null;
    }
  }

}

As you can see, we have a new parameter, multi, which indicates that the result of the query is an array. The rest of the new code will get $entityTypeManager for the class to load nodes. New query will look like this:

query RandomProduct($count: Int!) {
  products: RandomProducts(count: $count) {
    ...product
  }
}

fragment product on NodeProduct {
  name: entityLabel
  body
  fieldPicture
}

If we run the query several times, we'll get the same results 😔 They will change only after we clear the Drupal cache. So, what do we do? Next, we'll learn how not to cache specific queries. 

Disable caching for a query

So, we know that all GraphQL queries are cached through Drupal. Exploring the settings of GraphQL module, I came across this variable:

parameters:
  graphql.config:
    # GraphQL result cache:
    #
    # By default, the GraphQL results get cached. This can be disabled during development.
    #
    # @default true
    result_cache: true

If we take our chances and set it to false, we'll see that the query from before works as expected: we get different results. But this is not an option for us. Why would we turn off caching for all queries? Let's rather find a way to disable only for specific queries.

Drupal 8 allows us to change the caching status of a query. To do so, we'll use RequestPolicyInterface and then observe how the query for random products will behave.

You can create a new module, but I will demonstrate how it works on the already existing module for schema fields. We add the Cache folder and files for the class and the settings:

RequestPolicy.php

<?php

namespace Drupal\graphql_custom_fields\Cache;

use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;

class RequestPolicy implements RequestPolicyInterface {

  public function check(Request $request) {
    return static::ALLOW;
  }

}

All queries are still cached.

graphql_custom_fields.services.yml

services:
  graphql.request_custom_policy:
      class: Drupal\graphql_custom_fields\Cache\RequestPolicy
      tags:
        - { name: graphql_request_policy }

Let's look at the structure of a GraphQL query, for example, in Chrome:

This query has the operationName parameter. We can use it to distinguish the query we want from the others:

<?php

namespace Drupal\graphql_custom_fields\Cache;

use Drupal\Core\PageCache\RequestPolicyInterface;
use Symfony\Component\HttpFoundation\Request;

class RequestPolicy implements RequestPolicyInterface {

  public function check(Request $request) {
    $json = json_decode($request->getContent());
    if (!is_null($json) && isset($json->operationName) && strtolower($json->operationName) === 'randomproduct') {
      return static::DENY;
    }

    return static::ALLOW;
  }

}

If we run the query for random products several times, the results will be different every time!

By now we've got enough queries to start developing the JS-powered part of our website. 

Before we start sending requests from front-end to back-end, we need to allow CORS in Drupal. To do that, we change these values in services.yml of our website:

cors.config:
    enabled: true
    # Specify allowed headers, like 'x-allowed-header'.
    allowedHeaders: ['x-csrf-token','authorization','content-type','accept','origin','x-requested-with']
    # Specify allowed request methods, specify ['*'] to allow all possible ones.
    allowedMethods: ['*']
    # Configure requests allowed from specific origins.
    allowedOrigins: ['*']
    # Sets the Access-Control-Expose-Headers header.
    exposedHeaders: false
    # Sets the Access-Control-Max-Age header.
    maxAge: 1000
    # Sets the Access-Control-Allow-Credentials header.
    supportsCredentials: false