5 сентября 2017

Drupal 8 и GraphQL. Часть 2

Документации по модулю GraphQL для Drupal очень мало, а та, что есть не успевает за разработкой новых версий модуля. В этих двух статьях я поделюсь своим опытом и тем, что смогла найти, читая Stack overflow и issues на Github.

Drupal 8 и GraphQL. Часть 1

Drupal 8 и GraphQL. Часть 2:

Кастомный запрос 

Допустим нам не хватает функционала Views, поэтому мы напишем свой запрос. Давайте рассмотрим такой пример: на сайте у нас есть страницы товаров. Каждая страница — это нода Drupal. При входе на эту страницу на сайте, адрес страницы будет такой: /node/:nid. Но мы хотим сделать красивые ссылки, вроде такой: /shop/jam или /shop/bicycle, следовательно нам нужно организовать поиск ноды по ее псевдониму.

Подготовим почву для будущей выборки. Создадим тип контента Product, также установим модуль Pathauto для генерации путей по шаблону.

composer require drupal/pathauto
 

Создадим псевдонимы для URL продуктов:

Генерируем эти URL. Не забываем также включить для типа контента Product тип отображения GraphQL:

А также включить этот тип контента в Exposed content модуля GraphQL.

Теперь нам надо написать запрос в ранее созданном модуле graphql_custom_fields. Добавляем новый класс:

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

Все очень похоже на предыдущий случай. Здесь мы не указываем types, так как это не поле, а запрос, также добавился параметр alias — псевдоним URL продукта.

Наконец-то все готово для запроса. Проверяем! Вы же помните, что все доступные поля и запросы вы можете проверить во вкладке Docs?

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

fragment product on NodeProduct {
  name: entityLabel
  body
  fieldPicture
}

В эксплорере внизу слева есть окошко для ввода переменных запроса. Попробуем получить информацию по банке варенья:

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

Выполняем запрос... Успешно!

Примеры запросов

Рассмотрим еще небольшой пример с созданием типа. Скажем, мы хотим передавать на сайт какие-то настройки, например, написали свой модуль с формой настроек. В примере попробуем передать в запрос некоторые поля из Basic site settings (/admin/config/system/site-information). 

Для начала создадим еще одну папку в созданном ранее модуле Types:

В ней добавим новый тип 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 {
}

Как видим, тут ничего сложного, name — это название типа. Далее создаем запрос, как в предыдущем разделе:

<?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')];
  }
}

Обратите внимание, что в качестве типа мы указали только что созданный класс. Теперь создадим два поля для вывода name и slogan:

<?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'];
  }
}

Код для slogan будет выглядеть аналогично, думаю, вы справитесь с этим. Составляем запрос:

query Settings {
  BasicSettings {
    name
    slogan
  }
}

А что, если нам надо передать массив данных? Пускай мы хотим получить список случайных продуктов. Давайте создадим новый запрос, который выдает заданное количество случайных продуктов.

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

}

Как видим, у нас добавился новый параметр — multi, который указывает на то, что результат запроса — массив. Остальной новый код помогает нам получить в классе $entityTypeManager,  чтобы загрузить ноды. Новый запрос будет выглядеть так:

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

fragment product on NodeProduct {
  name: entityLabel
  body
  fieldPicture
}

Если мы выполним запрос несколько раз, то обнаружим, что значения не меняются 😔 И изменятся они только если мы очистим кеш Drupal. Что же делать? Дальше мы  рассмотрим, как не кешировать некоторые запросы. 

Отключение кеширования запроса

Итак, мы выяснили, что все запросы GraphQL кешируются средствами Drupal. Изучая настройки модуля GraphQL, я наткнулась на такую переменную:

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

Если мы рискнем и присвоим ей false, то легко можем увидеть, что запрос из предыдущей главы работает, как и предполагалось - выдает разные значения. Но, этот вариант нам не подходит, зачем выключать кеширование всем запросам? Лучше давайте найдем способ отключить кеширование только для определенных запросов.

В Drupal 8 мы можем изменить статус кеширования для запроса. Поможет нам в этом RequestPolicyInterface. Давайте реализуем его и отследим запрос для произвольных значений продукта.

Вы можете создать новый модуль, я же приведу пример на уже существующем модуле для полей схемы. Добавляем папку Cache и файлы для класса и настроек:

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

}

Пока разрешаем всем запросам проходить.

graphql_custom_fields.services.yml

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

Посмотрим на структуру запроса GraphQL, например в Chrome:

У запроса есть параметр operationName, мы можем использовать его, чтобы отличить нужный нам запрос от других:

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

}

Если мы пойдем и запустим несколько раз ранее написанный запрос для вывода случайных продуктов, то увидим, что значения меняются!

На данном этапе мы уже вооружены достаточным количеством запросов, чтобы начать разрабатывать JS часть сайта. 

Перед тем, как мы начнем отправлять запросы с фронта на back-end, нам надо разрешить CORS в Drupal. В services.yml сайта надо изменить значения:

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