Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add APQ plugin #1189

Merged
merged 13 commits into from
Sep 28, 2022
15 changes: 15 additions & 0 deletions graphql.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ services:
tags:
- { name: cache.context }

# Cache bin for the persisted queries.
cache.graphql.apq:
class: Drupal\Core\Cache\CacheBackendInterface
tags:
- { name: cache.bin }
factory: cache_factory:get
arguments: [graphql_apq]

# Cache bin for the parsed sdl ast.
cache.graphql.ast:
class: Drupal\Core\Cache\CacheBackendInterface
Expand Down Expand Up @@ -108,6 +116,13 @@ services:
tags:
- { name: event_subscriber }

# Cache the queries to be persistent.
graphql.apq_subscriber:
class: Drupal\graphql\EventSubscriber\ApqSubscriber
arguments: ['@cache.graphql.apq']
tags:
- { name: event_subscriber }

# Plugin manager for schemas
plugin.manager.graphql.schema:
class: Drupal\graphql\Plugin\SchemaPluginManager
Expand Down
2 changes: 1 addition & 1 deletion src/Entity/ServerInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public function removeAllPersistedQueryInstances();
/**
* Returns the current persisted queries set.
*
* @return \Drupal\graphql\Plugin\PersistedQueryPluginInterface[]
* @return \Drupal\graphql\Plugin\PersistedQueryPluginInterface[]|null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would technically be a breaking change. Since the following code would break:

foreach ($this->getPersistedQueryInstances() as $instance) {}

I suspect this is something suggested by PHPStan? I can see where it comes from in the Server implementation. That implementation uses PHP's "convert NULL to empty array on array access" side-effect.

I think the better (and more ergonomic for callers) fix would be to add $this-> persisted_query_instances = [];, right after the is_null if statement's closing bracked at the top of the function. That has a few positive effects:

  1. The function never returns NULL so calling it is easier (because I'm only dealing with arrays)
  2. If there are no plugins defined then we don't search for them every time getPersistedQueryInstances is called (which happens now, because in the no-plugin case, $this->persisted_query_instances is still NULL when the function is called after the first time).

For clarity we should probably just split that change out into a separate PR since it's not necessarily related to APQ? Then this PR doesn't need to be blocked by that change either :)

*/
public function getPersistedQueryInstances();

Expand Down
65 changes: 65 additions & 0 deletions src/EventSubscriber/ApqSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace Drupal\graphql\EventSubscriber;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\graphql\Event\OperationEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use GraphQL\Error\Error;

/**
* Save persisted queries to cache.
*/
class ApqSubscriber implements EventSubscriberInterface {

/**
* The cache to store persisted queries.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;

/**
* Constructs a ApqSubscriber object.
*
* @param \Drupal\Core\Cache\CacheBackendInterface $cache
* The cache to store persisted queries.
*/
public function __construct(CacheBackendInterface $cache) {
$this->cache = $cache;
}

/**
* Handle operation start events.
*
* @param \Drupal\graphql\Event\OperationEvent $event
* The kernel event object.
*
* @throws \GraphQL\Error\Error
*/
public function onBeforeOperation(OperationEvent $event): void {
if (!array_key_exists('automatic_persisted_query', $event->getContext()->getServer()->getPersistedQueryInstances() ?? [])) {
return;
}
$query = $event->getContext()->getOperation()->query;
$queryHash = $event->getContext()->getOperation()->extensions['persistedQuery']['sha256Hash'] ?? '';

if ($query && $queryHash) {
$computedQueryHash = hash('sha256', $query);
if ($queryHash !== $computedQueryHash) {
throw new Error('Provided sha does not match query');
}
$this->cache->set($queryHash, $query);
}
}

/**
* {@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
OperationEvent::GRAPHQL_OPERATION_BEFORE => 'onBeforeOperation',
];
}

}
56 changes: 56 additions & 0 deletions src/Plugin/GraphQL/PersistedQuery/AutomaticPersistedQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace Drupal\graphql\Plugin\GraphQL\PersistedQuery;

use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\graphql\PersistedQuery\PersistedQueryPluginBase;
use GraphQL\Server\OperationParams;
use GraphQL\Server\RequestError;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
* Load persisted queries from the cache.
*
* @PersistedQuery(
* id = "automatic_persisted_query",
* label = "Automatic Persisted Query",
* description = "Load persisted queries from the cache."
* )
*/
class AutomaticPersistedQuery extends PersistedQueryPluginBase implements ContainerFactoryPluginInterface {

/**
* The cache to store persisted queries.
*
* @var \Drupal\Core\Cache\CacheBackendInterface
*/
protected $cache;

/**
* {@inheritdoc}
*/
public function __construct(array $configuration, $plugin_id, $plugin_definition, CacheBackendInterface $cache) {
parent::__construct($configuration, $plugin_id, $plugin_definition);

$this->cache = $cache;
}

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

/**
* {@inheritdoc}
*/
public function getQuery($id, OperationParams $operation) {
if ($query = $this->cache->get($id)) {
return $query->data;
}
throw new RequestError('PersistedQueryNotFound');
}

}
96 changes: 96 additions & 0 deletions tests/src/Kernel/Framework/AutomaticPersistedQueriesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

namespace Drupal\Tests\graphql\Kernel\Framework;

use Drupal\Tests\graphql\Kernel\GraphQLTestBase;
use Symfony\Component\HttpFoundation\Request;

/**
* Tests the automatic persisted query plugin.
*
* @group graphql
*/
class AutomaticPersistedQueriesTest extends GraphQLTestBase {

/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$schema = <<<GQL
schema {
query: Query
}
type Query {
field_one: String
}
GQL;

$this->setUpSchema($schema);
$this->mockResolver('Query', 'field_one', 'this is the field one');

/** @var \Drupal\graphql\Plugin\DataProducerPluginManager $manager */
$manager = $this->container->get('plugin.manager.graphql.persisted_query');

$this->plugin_apq = $manager->createInstance('automatic_persisted_query');
}

/**
* Test the automatic persisted queries plugin.
*/
public function testAutomaticPersistedQueries(): void {
// Before adding the persisted query plugins to the server, we want to make
// sure that there are no existing plugins already there.
$this->server->removeAllPersistedQueryInstances();
$this->server->addPersistedQueryInstance($this->plugin_apq);
$this->server->save();

$endpoint = $this->server->get('endpoint');

$query = 'query { field_one } ';
$parameters['extensions']['persistedQuery']['sha256Hash'] = 'some random hash';

// Check we get PersistedQueryNotFound.
$request = Request::create($endpoint, 'GET', $parameters);
$result = $this->container->get('http_kernel')->handle($request);
$this->assertSame(200, $result->getStatusCode());
$this->assertSame([
'errors' => [
[
'message' => 'PersistedQueryNotFound',
'extensions' => ['category' => 'request'],
],
],
], json_decode($result->getContent(), TRUE));

// Post query to endpoint with a not matching hash.
$content = json_encode(['query' => $query] + $parameters);
$request = Request::create($endpoint, 'POST', [], [], [], [], $content);
$result = $this->container->get('http_kernel')->handle($request);
$this->assertSame(200, $result->getStatusCode());
$this->assertSame([
'errors' => [
[
'message' => 'Provided sha does not match query',
'extensions' => ['category' => 'graphql'],
],
],
], json_decode($result->getContent(), TRUE));

// Post query to endpoint to get the result and cache it.
$parameters['extensions']['persistedQuery']['sha256Hash'] = hash('sha256', $query);

$content = json_encode(['query' => $query] + $parameters);
$request = Request::create($endpoint, 'POST', [], [], [], [], $content);
$result = $this->container->get('http_kernel')->handle($request);
$this->assertSame(200, $result->getStatusCode());
$this->assertSame(['data' => ['field_one' => 'this is the field one']], json_decode($result->getContent(), TRUE));

// Execute first request again.
$request = Request::create($endpoint, 'GET', $parameters);
$result = $this->container->get('http_kernel')->handle($request);
$this->assertSame(200, $result->getStatusCode());
$this->assertSame(['data' => ['field_one' => 'this is the field one']], json_decode($result->getContent(), TRUE));
}

}