Skip to main content

Apollo Client React Hooks with Drupal GraphQL - Create Schema and Resolvers in Server, part 1

With version 16.8 React.js made the breaking change of introducing hooks as a new way to write your functional components. If you have used the Apollo Client to connect your GraphQL server to your React application - which you should, there are now more good news - Apollo Client has adopted the hooks-concept, making fetching and updating data a lot easier. Let’s see how we can do that with Drupal’s GraphQL implementation.

Apollo Client React Hooks with Drupal GraphQL

Published on 2019-09-07 at 18:44

Drupal decoupled React.js GraphQL Apollo

Set up Drupal’s GraphQL module for queries

Create content type

For this article we’ll create a new content type in Drupal and serve its’ data from the 4.x-version of the GraphQL module. The content type would be player, containing two custom fields - the first and last name of the player. Let’s remove the body field and just use two fields of type “Text (plain)” for the names. At the end we the “Manage fields”-screen ot the “Players” content type should look like this:

 

Fields on player content type
Fields on player content type

Create custom schema

Great! Now let’s create the custom schema for GraphQL in a custom module. In contrast to version 3 of the module, where we were provided with an almost full schema of the entities available in Drupal, version 4 of the module allows you to tailor your GraphQL-schema exactly to the data requirements of the frontend, taking only the data that is needed with you and also removing Drupalisms along the way, that would not make any sense to frontend developers not familiar with Drupal.

Disclaimer: big thanks to Joao Garin for the excellent documentation on the GraphQL module, that helped me a lot to figure out the best way to set up the schema in Drupal.

In order to create the schema we need to make sure to download and install the 4.x-version of GraphQL:

composer require drupal/graphql:4.x-dev
drush en graphql

We also need a custom module where to define our schema and data producers. First step is to define the schema definition, based on the data we want to make available in our API. For this we need to create a file graphql/players.graphqls in our module, where players is what the machine name of our resolver (the connector between the schema and Drupals’ data model) will be.

schema {
  query: Query
}

type Query {
  player(id: Int!): Player
  players(
    offset: Int = 0
    limit: Int = 10
  ): PlayerConnection!
}

type Player {
  id: Int!
  first_name: String!
  last_name: String!
}

type PlayerConnection {
  total: Int!
  items: [Player!]
}

We want two types of queries. One that would return all currently available player nodes in Drupal and one that would fetch a single one, identified by the node id. Note that the id-parameter is not defined as nid, as node identifiers are called in Drupal, as this makes no sense outside of the Drupal community. Same with the first_name and last_name fields, they are not prefixed with field_ as Drupal would do. This schema is what our frontend requirements might very well look like and GraphQL 4.x in Drupal 8 allows us to mimic it as close as possible.

Create resolvers and data producers

Let’s hook up the schema to a resolver and data producer in Drupal. We need to extend SdlSchemaPluginBase, done in a file src/Plugin/GraphQL/Schema/PlayersSchema.php in our module:

<?php

namespace Drupal\drupov_apollo_react_hooks\Plugin\GraphQL\Schema;

use Drupal\graphql\GraphQL\ResolverBuilder;
use Drupal\graphql\GraphQL\ResolverRegistry;
use Drupal\graphql\Plugin\GraphQL\Schema\SdlSchemaPluginBase;
use Drupal\drupov_apollo_react_hooks\Wrappers\PlayerConnection;

/**
 * @Schema(
 *   id = "players",
 *   name = "Players schema"
 * )
 */
class PlayersSchema extends SdlSchemaPluginBase {

  /**
   * {@inheritdoc}
   */
  public function getResolverRegistry() {
    $registry = new ResolverRegistry();
    $builder = new ResolverBuilder();

    $this->addQueryFields($registry, $builder);
    $this->addPlayerFields($registry, $builder);

    // Re-usable connection type fields.
    $this->addConnectionFields('PlayerConnection', $registry, $builder);

    return $registry;
  }

  /**
   * @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
   * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
   */
  protected function addQueryFields(ResolverRegistry $registry, ResolverBuilder $builder) {
    $registry->addFieldResolver('Query', 'player',
      $builder->produce('entity_load')
        ->map('type', $builder->fromValue('node'))
        ->map('bundles', $builder->fromValue(['player']))
        ->map('id', $builder->fromArgument('id'))
    );

    $registry->addFieldResolver('Query', 'players',
      $builder->produce('query_players')
        ->map('offset', $builder->fromArgument('offset'))
        ->map('limit', $builder->fromArgument('limit'))
    );
  }

  /**
   * @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
   * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
   */
  protected function addPlayerFields(ResolverRegistry $registry, ResolverBuilder $builder) {
    $registry->addFieldResolver('Player', 'id',
      $builder->produce('entity_id')
        ->map('entity', $builder->fromParent())
    );

    $registry->addFieldResolver('Player', 'first_name',
      $builder->produce('property_path')
        ->map('type', $builder->fromValue('entity:node'))
        ->map('value', $builder->fromParent())
        ->map('path', $builder->fromValue('field_first_name.value'))
    );

    $registry->addFieldResolver('Player', 'last_name',
      $builder->produce('property_path')
        ->map('type', $builder->fromValue('entity:node'))
        ->map('value', $builder->fromParent())
        ->map('path', $builder->fromValue('field_last_name.value'))
    );
  }

  /**
   * @param string $type
   * @param \Drupal\graphql\GraphQL\ResolverRegistry $registry
   * @param \Drupal\graphql\GraphQL\ResolverBuilder $builder
   */
  protected function addConnectionFields($type, ResolverRegistry $registry, ResolverBuilder $builder) {
    $registry->addFieldResolver($type, 'total',
      $builder->callback(function (PlayerConnection $connection) {
        return $connection->total();
      })
    );

    $registry->addFieldResolver($type, 'items',
      $builder->callback(function (PlayerConnection $connection) {
        return $connection->items();
      })
    );
  }

}

The protected methods in the class are very much aligned with the schema we defined previously - we have

  • addQueryFields - which differentiates between the two queries we want to have - player and players

  • addPlayerFields - here we resolve the 3 values we want to have returned by our Player type: id, first_name and last_name. Nothing more, nothing less.

  • addConnectionFields - here we return specific values for our PlayerConnection needed in the players-query, as these are not entity API specific and need to be provided specifically.

The data producer for the players query should live in src/Plugin/GraphQL/DataProducer/QueryPlayers.php and contain the following code:

<?php

namespace Drupal\drupov_apollo_react_hooks\Plugin\GraphQL\DataProducer;

use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\drupov_apollo_react_hooks\Wrappers\PlayerConnection;
use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
use GraphQL\Error\UserError;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * @DataProducer(
 *   id = "query_players",
 *   name = @Translation("Load players"),
 *   description = @Translation("Loads a list of players."),
 *   produces = @ContextDefinition("any",
 *     label = @Translation("Player connection")
 *   ),
 *   consumes = {
 *     "offset" = @ContextDefinition("integer",
 *       label = @Translation("Offset"),
 *       required = FALSE
 *     ),
 *     "limit" = @ContextDefinition("integer",
 *       label = @Translation("Limit"),
 *       required = FALSE
 *     )
 *   }
 * )
 */
class QueryPlayers extends DataProducerPluginBase implements ContainerFactoryPluginInterface {

  const MAX_LIMIT = 100;

  /**
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityManager;

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('entity.manager')
    );
  }

  /**
   * {@inheritdoc}
   */
  public function __construct(
    array $configuration,
    $pluginId,
    $pluginDefinition,
    EntityTypeManagerInterface $entityManager
  ) {
    parent::__construct($configuration, $pluginId, $pluginDefinition);
    $this->entityManager = $entityManager;
  }

  /**
   * @param $offset
   * @param $limit
   * @param \Drupal\Core\Cache\RefinableCacheableDependencyInterface $metadata
   *
   * @return \Drupal\drupov_apollo_react_hooks\Wrappers\PlayerConnection
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   */
  public function resolve($offset, $limit, RefinableCacheableDependencyInterface $metadata) {
    if (!$limit > static::MAX_LIMIT) {
      throw new UserError(sprintf('Exceeded maximum query limit: %s.', static::MAX_LIMIT));
    }

    $storage = $this->entityManager->getStorage('node');
    $type = $storage->getEntityType();
    $query = $storage->getQuery()
      ->currentRevision()
      ->accessCheck();

    $query->condition($type->getKey('bundle'), 'player');
    $query->range($offset, $limit);

    $metadata->addCacheTags($type->getListCacheTags());
    $metadata->addCacheContexts($type->getListCacheContexts());

    return new PlayerConnection($query);
  }

}

Take a look at the resolve-method here. Here we prepare all query parameters for the Drupal query and pass it into the player connection class. Let’s create this one in src/Wrappers/PlayerConnection.php too, to wrap things up:

<?php

namespace Drupal\drupov_apollo_react_hooks\Wrappers;

use Drupal\Core\Entity\Query\QueryInterface;
use GraphQL\Deferred;

class PlayerConnection {

  /**
   * @var \Drupal\Core\Entity\Query\Sql\Query
   */
  protected $query;

  /**
   * QueryConnection constructor.
   *
   * @param \Drupal\Core\Entity\Query\QueryInterface $query
   */
  public function __construct(QueryInterface $query) {
    $this->query = $query;
  }

  /**
   * @return int
   */
  public function total() {
    $query = clone $this->query;
    $query->range(NULL, NULL)->count();
    return $query->execute();
  }

  /**
   * @return array|\GraphQL\Deferred
   */
  public function items() {
    $result = $this->query->execute();
    if (empty($result)) {
      return [];
    }

    $buffer = \Drupal::service('graphql.buffer.entity');
    $callback = $buffer->add($this->query->getEntityTypeId(), array_values($result));
    return new Deferred(function () use ($callback) {
      return $callback();
    });
  }

}

The PlayerConnection is simply a helper class, that has the two methods total() and items(), corresponding to what we have defined as requirements in our schema.

Now that was a lot of code we created, but we’re ready with our API! What’s left is very clicks in Drupal. Go to /admin/config/graphql and create a new server. Give it some meaningful label and choose the just created “Players schema”. The endpoints can be anything that makes sense, usually /graphql is chosen. Click save and visit /node/add/player in your Drupal installation to create a new Player-node. Save it and choose the GraphiQL-explorer from the drop-down menu of your new server. Here is my result :)

Drupal GraphQL player query
Drupal GraphQL player query

You can get the full code at this repository. This is it for now. Having our API finished we can now see how to fetch data from a React.js application with the new Apollo Client React hook useQuery() in the next article.

Find me on LinkedIn or Twitter with any questions you have on this topic, looking forward to get in touch with you.

Share this