Completed
Pull Request — 8.x-3.x (#671)
by Sebastian
02:11
created

QueryProcessor::maxAgeToExpire()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
nc 2
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace Drupal\graphql\GraphQL\Execution;
4
5
use Drupal\Core\Cache\CacheableDependencyInterface;
6
use Drupal\Core\Cache\Cache;
7
use Drupal\Core\Cache\CacheableMetadata;
8
use Drupal\Core\Cache\CacheBackendInterface;
9
use Drupal\Core\Cache\Context\CacheContextsManager;
10
use Drupal\Core\Session\AccountProxyInterface;
11
use Drupal\graphql\Plugin\SchemaPluginManager;
12
use Drupal\graphql\GraphQL\QueryProvider\QueryProviderInterface;
13
use Drupal\graphql\GraphQL\Cache\CacheableRequestError;
14
use GraphQL\Error\Error;
15
use GraphQL\Error\FormattedError;
16
use GraphQL\Executor\ExecutionResult;
17
use GraphQL\Executor\Executor;
18
use GraphQL\Executor\Promise\Adapter\SyncPromiseAdapter;
19
use GraphQL\Executor\Promise\PromiseAdapter;
20
use GraphQL\Language\AST\DocumentNode;
21
use GraphQL\Language\Parser;
22
use GraphQL\Language\Visitor;
23
use GraphQL\Server\Helper;
24
use GraphQL\Server\OperationParams;
25
use GraphQL\Server\RequestError;
26
use GraphQL\Server\ServerConfig;
27
use GraphQL\Utils\AST;
28
use GraphQL\Utils\TypeInfo;
29
use GraphQL\Utils\Utils;
30
use GraphQL\Validator\DocumentValidator;
31
use GraphQL\Validator\Rules\AbstractValidationRule;
32
use GraphQL\Validator\ValidationContext;
33
use Symfony\Component\HttpFoundation\RequestStack;
34
35
// TODO: Refactor this and clean it up.
36
class QueryProcessor {
37
38
  /**
39
   * The schema plugin manager.
40
   *
41
   * @var \Drupal\graphql\Plugin\SchemaPluginManager
42
   */
43
  protected $pluginManager;
44
45
  /**
46
   * The cache backend for caching query results.
47
   *
48
   * @var \Drupal\Core\Cache\CacheBackendInterface
49
   */
50
  protected $cacheBackend;
51
52
  /**
53
   * The cache contexts manager service.
54
   *
55
   * @var \Drupal\Core\Cache\Context\CacheContextsManager
56
   */
57
  protected $contextsManager;
58
59
  /**
60
   * The request stack.
61
   *
62
   * @var \Symfony\Component\HttpFoundation\RequestStack
63
   */
64
  protected $requestStack;
65
66
  /**
67
   * Processor constructor.
68
   *
69
   * @param \Drupal\Core\Cache\Context\CacheContextsManager $contextsManager
70
   *   The cache contexts manager service.
71
   * @param \Drupal\graphql\Plugin\SchemaPluginManager $pluginManager
72
   *   The schema plugin manager.
73
   * @param \Drupal\Core\Cache\CacheBackendInterface $cacheBackend
74
   *   The cache backend for caching query results.
75
   * @param \Symfony\Component\HttpFoundation\RequestStack $requestStack
76
   *   The request stack.
77
   */
78
  public function __construct(
79
    CacheContextsManager $contextsManager,
80
    SchemaPluginManager $pluginManager,
81
    CacheBackendInterface $cacheBackend,
82
    RequestStack $requestStack
83
  ) {
84
    $this->contextsManager = $contextsManager;
85
    $this->pluginManager = $pluginManager;
86
    $this->cacheBackend = $cacheBackend;
87
    $this->requestStack = $requestStack;
88
  }
89
90
  /**
91
   * Processes one or multiple graphql operations.
92
   *
93
   * @param string $schema
94
   *   The plugin id of the schema to use.
95
   * @param \GraphQL\Server\OperationParams|\GraphQL\Server\OperationParams[] $params
96
   *   The graphql operation(s) to execute.
97
   *
98
   * @return \Drupal\graphql\GraphQL\Execution\QueryResult|\Drupal\graphql\GraphQL\Execution\QueryResult[]
99
   *   The query result.
100
   *
101
   * @throws \Drupal\Component\Plugin\Exception\PluginException
102
   */
103
  public function processQuery($schema, $params) {
104
    // Load the plugin from the schema manager.
105
    $plugin = $this->pluginManager->createInstance($schema);
106
    $config = $plugin->getServer();
107
108
    if (is_array($params)) {
109
      return $this->executeBatch($config, $params);
110
    }
111
112
    return $this->executeSingle($config, $params);
113
  }
114
115
  /**
116
   * @param \GraphQL\Server\ServerConfig $config
117
   * @param \GraphQL\Server\OperationParams $params
118
   *
119
   * @return mixed
120
   */
121
  public function executeSingle(ServerConfig $config, OperationParams $params) {
122
    $adapter = new SyncPromiseAdapter();
123
    $result = $this->executeOperationWithReporting($adapter, $config, $params, FALSE);
124
    return $adapter->wait($result);
125
  }
126
127
  /**
128
   * @param \GraphQL\Server\ServerConfig $config
129
   * @param array $params
130
   *
131
   * @return mixed
132
   */
133
  public function executeBatch(ServerConfig $config, array $params) {
134
    $adapter = new SyncPromiseAdapter();
135
    $result = array_map(function ($params) use ($adapter, $config) {
136
      return $this->executeOperationWithReporting($adapter, $config, $params, TRUE);
137
    }, $params);
138
139
    $result = $adapter->all($result);
140
    return $adapter->wait($result);
141
  }
142
143
  /**
144
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
145
   * @param \GraphQL\Server\ServerConfig $config
146
   * @param \GraphQL\Server\OperationParams $params
147
   * @param bool $batching
148
   *
149
   * @return \GraphQL\Executor\Promise\Promise
150
   */
151
  protected function executeOperationWithReporting(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
152
    $result = $this->executeOperation($adapter, $config, $params, $batching);
153
154
    // Format and print errors.
155
    return $result->then(function(QueryResult $result) use ($config) {
156
      if ($config->getErrorsHandler()) {
157
        $result->setErrorsHandler($config->getErrorsHandler());
158
      }
159
160
      if ($config->getErrorFormatter() || $config->getDebug()) {
161
        $result->setErrorFormatter(FormattedError::prepareFormatter($config->getErrorFormatter(), $config->getDebug()));
162
      }
163
164
      return $result;
165
    });
166
  }
167
168
  /**
169
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
170
   * @param \GraphQL\Server\ServerConfig $config
171
   * @param \GraphQL\Server\OperationParams $params
172
   * @param bool $batching
173
   *
174
   * @return \GraphQL\Executor\Promise\Promise
175
   */
176
  protected function executeOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, $batching = FALSE) {
177
    try {
178
      if (!$config->getSchema()) {
179
        throw new \LogicException('Missing schema for query execution.');
180
      }
181
182
      if ($batching && !$config->getQueryBatching()) {
183
        throw new RequestError('Batched queries are not supported by this server.');
184
      }
185
186
      if ($errors = $this->validateOperationParams($params)) {
187
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
188
      }
189
190
      $document = $params->queryId ? $this->loadPersistedQuery($config, $params) : $params->query;
191
      if (!$document instanceof DocumentNode) {
192
        $document = Parser::parse($document);
193
      }
194
195
      // Read the operation type from the document. Subscriptions and mutations
196
      // only work through POST requests. One cannot have mutations and queries
197
      // in the same document, hence this check is sufficient.
198
      $operation = $params->operation;
199
      $type = AST::getOperation($document, $operation);
200
      if ($params->isReadOnly() && $type !== 'query') {
201
        throw new RequestError('GET requests are only supported for query operations.');
202
      }
203
204
      // If one of the validation rules found any problems, do not resolve the
205
      // query and bail out early instead.
206
      if ($errors = $this->validateOperation($config, $params, $document)) {
207
        return $adapter->createFulfilled(new QueryResult(NULL, $errors));
208
      }
209
210
      // Only queries can be cached (mutations and subscriptions can't).
211
      if ($type === 'query') {
212
        return $this->executeCacheableOperation($adapter, $config, $params, $document);
213
      }
214
215
      return $this->executeUncachableOperation($adapter, $config, $params, $document);
216
    }
217
    catch (CacheableRequestError $exception) {
218
      return $adapter->createFulfilled(
219
        new QueryResult(NULL, [Error::createLocatedError($exception)], [], $exception)
220
      );
221
    }
222
    catch (RequestError $exception) {
223
      return $adapter->createFulfilled(new QueryResult(NULL, [Error::createLocatedError($exception)]));
224
    }
225
    catch (Error $exception) {
226
      return $adapter->createFulfilled(new QueryResult(NULL, [$exception]));
227
    }
228
  }
229
230
  /**
231
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
232
   * @param \GraphQL\Server\ServerConfig $config
233
   * @param \GraphQL\Server\OperationParams $params
234
   * @param \GraphQL\Language\AST\DocumentNode $document
235
   *
236
   * @return \GraphQL\Executor\Promise\Promise|mixed
237
   */
238
  protected function executeCacheableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
239
    $inDebug = !!$config->getDebug();
240
    $contextCacheId = 'ccid:' . $this->cacheIdentifier($params, $document);
241
242
    if (empty($inDebug)) {
243
      if (($contextCache = $this->cacheBackend->get($contextCacheId))) {
244
        $contexts = $contextCache->data ?? [];
245
        $cid = 'cid:' . $this->cacheIdentifier($params, $document, $contexts);
246
        if (($cache = $this->cacheBackend->get($cid))) {
247
          return $adapter->createFulfilled($cache->data);
248
        }
249
      }
250
    }
251
252
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
253
    return $result->then(function (QueryResult $result) use ($contextCacheId, $params, $document) {
254
      // Write this query into the cache if it is cacheable.
255
      if ($result->getCacheMaxAge() !== 0) {
256
        $contexts = $result->getCacheContexts();
257
        $expire = $this->maxAgeToExpire($result->getCacheMaxAge());
258
        $tags = $result->getCacheTags();
259
        $cid = 'cid:' . $this->cacheIdentifier($params, $document, $contexts);
260
        $this->cacheBackend->set($contextCacheId, $contexts, $expire, $tags);
261
        $this->cacheBackend->set($cid, $result, $expire, $tags);
262
      }
263
      return $result;
264
    });
265
  }
266
267
  /**
268
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
269
   * @param \GraphQL\Server\ServerConfig $config
270
   * @param \GraphQL\Server\OperationParams $params
271
   * @param \GraphQL\Language\AST\DocumentNode $document
272
   *
273
   * @return \GraphQL\Executor\Promise\Promise
274
   */
275
  protected function executeUncachableOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
276
    $result = $this->doExecuteOperation($adapter, $config, $params, $document);
277
    return $result->then(function (QueryResult $result) {
278
      // Mark the query result as uncacheable.
279
      $result->mergeCacheMaxAge(0);
280
      return $result;
281
    });
282
  }
283
284
  /**
285
   * @param \GraphQL\Executor\Promise\PromiseAdapter $adapter
286
   * @param \GraphQL\Server\ServerConfig $config
287
   * @param \GraphQL\Server\OperationParams $params
288
   * @param \GraphQL\Language\AST\DocumentNode $document
289
   *
290
   * @return \GraphQL\Executor\Promise\Promise
291
   */
292
  protected function doExecuteOperation(PromiseAdapter $adapter, ServerConfig $config, OperationParams $params, DocumentNode $document) {
293
    $operation = $params->operation;
294
    $variables = $params->variables;
295
    $context = $this->resolveContextValue($config, $params, $document, $operation);
296
    $root = $this->resolveRootValue($config, $params, $document, $operation);
297
    $resolver = $config->getFieldResolver();
298
    $schema = $config->getSchema();
299
300
    $promise = Executor::promiseToExecute(
301
      $adapter,
302
      $schema,
303
      $document,
304
      $root,
305
      $context,
306
      $variables,
307
      $operation,
308
      $resolver
309
    );
310
311
    return $promise->then(function (ExecutionResult $result) use ($context) {
312
      $metadata = (new CacheableMetadata())
313
        ->addCacheContexts($this->filterCacheContexts($context->getCacheContexts()))
314
        ->addCacheTags($context->getCacheTags())
315
        ->setCacheMaxAge($context->getCacheMaxAge());
316
317
      // Do not cache in development mode or if there are any errors.
318
      if ($context->getGlobal('development') || !empty($result->errors)) {
319
        $metadata->setCacheMaxAge(0);
320
      }
321
322
      return new QueryResult($result->data, $result->errors, $result->extensions, $metadata);
323
    });
324
  }
325
326
  /**
327
   * @param \GraphQL\Server\OperationParams $params
328
   *
329
   * @return array
330
   */
331
  protected function validateOperationParams(OperationParams $params) {
332
    $errors = (new Helper())->validateOperationParams($params);
333
    return array_map(function (RequestError $error) {
334
      return Error::createLocatedError($error, NULL, NULL);
335
    }, $errors);
336
  }
337
338
  /**
339
   * @param \GraphQL\Server\ServerConfig $config
340
   * @param \GraphQL\Server\OperationParams $params
341
   * @param \GraphQL\Language\AST\DocumentNode $document
342
   *
343
   * @return \GraphQL\Error\Error[]
344
   * @throws \Exception
345
   */
346
  protected function validateOperation(ServerConfig $config, OperationParams $params, DocumentNode $document) {
347
    $operation = $params->operation;
348
    // Skip validation if there are no validation rules to be applied.
349
    if (!$rules = $this->resolveValidationRules($config, $params, $document, $operation)) {
350
      return [];
351
    }
352
353
    $schema = $config->getSchema();
354
    $info = new TypeInfo($schema);
355
    $validation = new ValidationContext($schema, $document, $info);
356
    $visitors = array_values(array_map(function (AbstractValidationRule $rule) use ($validation) {
357
      return $rule($validation);
358
    }, $rules));
359
360
    // Run the query visitor with the prepared validation rules and the cache
361
    // metadata collector and query complexity calculator.
362
    Visitor::visit($document, Visitor::visitWithTypeInfo($info, Visitor::visitInParallel($visitors)));
363
364
    // Return any possible errors collected during validation.
365
    return $validation->getErrors();
366
  }
367
368
  /**
369
   * @param \GraphQL\Server\ServerConfig $config
370
   * @param \GraphQL\Server\OperationParams $params
371
   * @param \GraphQL\Language\AST\DocumentNode $document
372
   * @param $operation
373
   *
374
   * @return mixed
375
   */
376 View Code Duplication
  protected function resolveRootValue(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
377
    $root = $config->getRootValue();
378
    if (is_callable($root)) {
379
      $root = $root($params, $document, $operation);
380
    }
381
382
    return $root;
383
  }
384
385
  /**
386
   * @param \GraphQL\Server\ServerConfig $config
387
   * @param \GraphQL\Server\OperationParams $params
388
   * @param \GraphQL\Language\AST\DocumentNode $document
389
   * @param $operation
390
   *
391
   * @return mixed
392
   */
393 View Code Duplication
  protected function resolveContextValue(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
394
    $context = $config->getContext();
395
    if (is_callable($context)) {
396
      $context = $context($params, $document, $operation);
397
    }
398
399
    return $context;
400
  }
401
402
  /**
403
   * @param \GraphQL\Server\ServerConfig $config
404
   * @param \GraphQL\Server\OperationParams $params
405
   * @param \GraphQL\Language\AST\DocumentNode $document
406
   * @param $operation
407
   *
408
   * @return array
409
   */
410
  protected function resolveValidationRules(ServerConfig $config, OperationParams $params, DocumentNode $document, $operation) {
411
    // Allow customizing validation rules per operation:
412
    $rules = $config->getValidationRules();
413
    if (is_callable($rules)) {
414
      $rules = $rules($params, $document, $operation);
415
      if (!is_array($rules)) {
416
        throw new \LogicException(sprintf("Expecting validation rules to be array or callable returning array, but got: %s", Utils::printSafe($rules)));
417
      }
418
    }
419
420
    return $rules;
421
  }
422
423
  /**
424
   * @param \GraphQL\Server\ServerConfig $config
425
   * @param \GraphQL\Server\OperationParams $params
426
   *
427
   * @return mixed
428
   * @throws \GraphQL\Server\RequestError
429
   */
430
  protected function loadPersistedQuery(ServerConfig $config, OperationParams $params) {
431
    if (!$loader = $config->getPersistentQueryLoader()) {
432
      throw new RequestError('Persisted queries are not supported by this server.');
433
    }
434
435
    $source = $loader($params->queryId, $params);
436
    if (!is_string($source) && !$source instanceof DocumentNode) {
437
      throw new \LogicException(sprintf('The persisted query loader must return query string or instance of %s but got: %s.', DocumentNode::class, Utils::printSafe($source)));
438
    }
439
440
    return $source;
441
  }
442
443
  /**
444
   * @param \GraphQL\Language\AST\DocumentNode $document
445
   *
446
   * @return array
447
   */
448
  protected function serializeDocument(DocumentNode $document) {
449
    return $this->sanitizeRecursive(AST::toArray($document));
450
  }
451
452
  /**
453
   * @param array $item
454
   *
455
   * @return array
456
   */
457
  protected function sanitizeRecursive(array $item) {
458
    unset($item['loc']);
459
460
    foreach ($item as &$value) {
461
      if (is_array($value)) {
462
        $value = $this->sanitizeRecursive($value);
463
      }
464
    }
465
466
    return $item;
467
  }
468
469
  /**
470
   * @param \GraphQL\Server\OperationParams $params
471
   * @param \GraphQL\Language\AST\DocumentNode $document
472
   * @param array $contexts
473
   *
474
   * @return string
475
   */
476
  protected function cacheIdentifier(OperationParams $params, DocumentNode $document, array $contexts = []) {
477
    // Ignore language contexts since they are handled by graphql internally.
478
    $contexts = $this->filterCacheContexts($contexts);
479
    $keys = $this->contextsManager->convertTokensToKeys($contexts)->getKeys();
480
481
    // Sorting the variables will cause fewer cache vectors.
482
    $variables = $params->variables ?: [];
483
    ksort($variables);
484
485
    // Prepend the hash of the serialized document to the cache contexts.
486
    $hash = hash('sha256', json_encode([
487
      'query' => $this->serializeDocument($document),
488
      'variables' => $variables,
489
    ]));
490
491
    return implode(':', array_values(array_merge([$hash], $keys)));
492
  }
493
494
  /**
495
   * Filter unused contexts.
496
   *
497
   * Removes the language contexts from a list of context ids.
498
   *
499
   * @param string[] $contexts
500
   *   The list of context id's.
501
   *
502
   * @return string[]
503
   *   The filtered list of context id's.
504
   */
505
  protected function filterCacheContexts(array $contexts) {
506
    return array_filter($contexts, function ($context) {
507
      return strpos($context, 'languages:') !== 0;
508
    });
509
  }
510
511
  /**
512
   * Maps a cache max age value to an "expire" value for the Cache API.
513
   *
514
   * @param int $maxAge
515
   *
516
   * @return int
517
   *   A corresponding "expire" value.
518
   *
519
   * @see \Drupal\Core\Cache\CacheBackendInterface::set()
520
   */
521
  protected function maxAgeToExpire($maxAge) {
522
    $time = $this->requestStack->getMasterRequest()->server->get('REQUEST_TIME');
523
    return ($maxAge === Cache::PERMANENT) ? Cache::PERMANENT : (int) $time + $maxAge;
524
  }
525
}
526