Skip to content

Basic Automatic Persisted Queries (APQ) support #701

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

Merged
merged 27 commits into from
Apr 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
203f430
basic APQ support
illambo Dec 4, 2020
a469010
- error handling for invalid query hash
illambo Dec 5, 2020
78087de
add APQ tests
illambo Dec 6, 2020
6783dd5
edit changelog
illambo Dec 7, 2020
dc875b0
code and comments improvement
illambo Dec 7, 2020
bf36208
phpstan adjustments
illambo Dec 7, 2020
b592f95
Update README.md
illambo Dec 11, 2020
408c877
Merge branch 'master' into feature/apq
illambo Apr 4, 2021
7b57dee
- remove empty line in README.md
illambo Apr 4, 2021
f41ec59
fix-style
illambo Apr 4, 2021
6e20660
add test persisted query found with upload
illambo Apr 5, 2021
6f6cfca
Merge branch 'master' into feature/apq
illambo Apr 5, 2021
0ae6cc2
revisions updates
illambo Apr 7, 2021
0e88471
edit readme
illambo Apr 7, 2021
44e04e3
phpstan adjustments
illambo Apr 7, 2021
645fc05
edit readme
illambo Apr 7, 2021
691523e
edit cache prefix like review
illambo Apr 7, 2021
7ac360c
revisions updates
illambo Apr 7, 2021
69c8750
readme: adapt grammar/wording for APQ a bit
mfn Apr 7, 2021
093c65a
Remove interface, it's already on Error
mfn Apr 7, 2021
198c763
Move decorating the ExecutionResult into separate method so it can be…
mfn Apr 7, 2021
7aa1fa6
Revert var name change in this PR
mfn Apr 7, 2021
d11a998
Move back APQ handling into dedicated method, unwind nested logic and…
mfn Apr 7, 2021
650e3a8
Add test in case the persistedQuery is something invalid
mfn Apr 7, 2021
561aa31
Add test in case showing we don't check the 'version' of APQ
mfn Apr 7, 2021
3b3d5a1
Fix typo
mfn Apr 7, 2021
9b870e3
readme: mention the cache more explicitly and point out the TrimStrin…
mfn Apr 8, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ CHANGELOG
[Next release](https://github.com/rebing/graphql-laravel/compare/7.0.1...master)
--------------

### Added
- Basic Automatic Persisted Queries (APQ) support [\#701 / illambo](https://github.com/rebing/graphql-laravel/pull/701)

2021-04-03, 7.0.1
-----------------
### Added
Expand Down
100 changes: 100 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ To work this around:
- [Field deprecation](#field-deprecation)
- [Default field resolver](#default-field-resolver)
- [Macros](#macros)
- [Basic Automatic Persisted Queries support](#basic-automatic-persisted-queries-support)
- [Guides](#guides)
- [Upgrading from v1 to v2](#upgrading-from-v1-to-v2)
- [Migrating from Folklore](#migrating-from-folklore)
Expand Down Expand Up @@ -2394,6 +2395,105 @@ class AppServiceProvider extends ServiceProvider

The `macro` function accepts a name as its first argument, and a `Closure` as its second.

### Basic Automatic Persisted Queries support

Automatic Persisted Queries (APQ) improve network performance by sending smaller requests, with zero build-time configuration.

APQ is disabled by default and can be enabled in the config via `apc.enabled=true` or by setting the environment variable `GRAPHQL_APQ_ENABLE=true`.

A persisted query is an ID or hash that can be generated on the client sent to the server instead of the entire GraphQL query string.
This smaller signature reduces bandwidth utilization and speeds up client loading times.
Persisted queries pair especially with GET requests, enabling the browser cache and integration with a CDN.

Behind the scenes, APQ uses Laravels cache for storing / retrieving queries.
Please see the various options there for which cache, prefix, TTL, etc. to use.

For more information see:
- [Apollo - Automatic persisted queries](https://www.apollographql.com/docs/apollo-server/performance/apq/)
- [Apollo link persisted queries - protocol](https://github.com/apollographql/apollo-link-persisted-queries#protocol)

> Note: the APQ protocol requires the hash sent by the client being compared
> with the computed hash on the server. In case a mutating middleware like
> `TrimStrings` is active and the query sent contains leading/trailing
> whitespaces, these hashes can never match resulting in an error.
>
> In such case either disable the middleware or trim the query on the client
> before hashing.

#### Why "basic support" ?

Currently, only the GraphQL query string representation will be cached and still
needs to be re-parsed after retrieving form the cache.

#### Notes
- The error descriptions are aligned with [apollo-server](https://github.com/apollographql/apollo-server).

#### Client example

Below a simple integration example with Vue/Apollo, the `createPersistedQueryLink`
automatically manages the APQ flow.

```js
// [example app.js]

require('./bootstrap');

window.Vue = require('vue');

Vue.component('example-component', require('./components/ExampleComponent.vue').default);

import { ApolloClient } from 'apollo-client';
import { ApolloLink } from 'apollo-link';
import { createHttpLink } from 'apollo-link-http';
import { createPersistedQueryLink } from 'apollo-link-persisted-queries';
import { InMemoryCache } from 'apollo-cache-inmemory';
import VueApollo from 'vue-apollo';

const httpLinkWithPersistedQuery = createPersistedQueryLink().concat(createHttpLink({
uri: '/graphql',
}));

// Create the apollo client
const apolloClient = new ApolloClient({
link: ApolloLink.from([httpLinkWithPersistedQuery]),
cache: new InMemoryCache(),
connectToDevTools: true,
})

const apolloProvider = new VueApollo({
defaultClient: apolloClient,
});

Vue.use(VueApollo);

const app = new Vue({
el: '#app',
apolloProvider,
});
```
```vue
<!-- [example TestComponent.vue] -->

<template>
<div>
<p>Test APQ</p>
<p>-> <span v-if="$apollo.queries.hello.loading">Loading...</span>{{ hello }}</p>
</div>
</template>

<script>
import gql from 'graphql-tag';
export default {
apollo: {
hello: gql`query{hello}`,
},
mounted() {
console.log('Component mounted.')
}
}
</script>
```

## Guides

### Upgrading from v1 to v2
Expand Down
18 changes: 18 additions & 0 deletions config/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,22 @@
* See http://php.net/manual/function.json-encode.php for the full list of options
*/
'json_encoding_options' => 0,

/*
* Automatic Persisted Queries (APQ)
* See https://www.apollographql.com/docs/apollo-server/performance/apq/
*/
'apq' => [
// Enable/Disable APQ - See https://www.apollographql.com/docs/apollo-server/performance/apq/#disabling-apq
'enable' => env('GRAPHQL_APQ_ENABLE', false),

// The cache driver used for APQ
'cache_driver' => env('GRAPHQL_APQ_CACHE_DRIVER', config('cache.default')),

// The cache prefix
'cache_prefix' => config('cache.prefix') . ':graphql.apq',

// The cache ttl in minutes - See https://www.apollographql.com/docs/apollo-server/performance/apq/#adjusting-cache-time-to-live-ttl
'cache_ttl' => 300,
],
];
81 changes: 81 additions & 0 deletions src/Error/AutomaticPersistedQueriesError.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

declare(strict_types=1);

namespace Rebing\GraphQL\Error;

use GraphQL\Error\Error;

class AutomaticPersistedQueriesError extends Error
{
public const CODE_PERSISTED_QUERY_NOT_SUPPORTED = 'PERSISTED_QUERY_NOT_SUPPORTED';
public const CODE_PERSISTED_QUERY_NOT_FOUND = 'PERSISTED_QUERY_NOT_FOUND';
public const CODE_INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR';
public const MESSAGE_PERSISTED_QUERY_NOT_SUPPORTED = 'PersistedQueryNotSupported';
public const MESSAGE_PERSISTED_QUERY_NOT_FOUND = 'PersistedQueryNotFound';
public const MESSAGE_INVALID_HASH = 'provided sha does not match query';
public const CATEGORY_APQ = 'apq';

public static function persistedQueriesNotSupported(): self
{
return new self(
self::MESSAGE_PERSISTED_QUERY_NOT_SUPPORTED,
$nodes = null,
$source = null,
$positions = [],
$path = null,
$previous = null,
$extensions = [
'code' => self::CODE_PERSISTED_QUERY_NOT_SUPPORTED,
]
);
}

public static function persistedQueriesNotFound(): self
{
return new self(
self::MESSAGE_PERSISTED_QUERY_NOT_FOUND,
$nodes = null,
$source = null,
$positions = [],
$path = null,
$previous = null,
$extensions = [
'code' => self::CODE_PERSISTED_QUERY_NOT_FOUND,
]
);
}

/**
* @param string|null $message
*/
public static function internalServerError($message = null): self
{
return new self(
$message ?? '',
$nodes = null,
$source = null,
$positions = [],
$path = null,
$previous = null,
$extensions = [
'code' => self::CODE_INTERNAL_SERVER_ERROR,
]
);
}

public static function invalidHash(): self
{
return self::internalServerError(self::MESSAGE_INVALID_HASH);
}

public function isClientSafe(): bool
{
return true;
}

public function getCategory(): string
{
return self::CATEGORY_APQ;
}
}
18 changes: 12 additions & 6 deletions src/GraphQL.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,11 @@ public function queryAndReturnResult(string $query, ?array $params = [], array $

$schema = $this->schema($schemaName);

$errorFormatter = config('graphql.error_formatter', [static::class, 'formatError']);
$errorsHandler = config('graphql.errors_handler', [static::class, 'handleErrors']);
$defaultFieldResolver = config('graphql.defaultFieldResolver', null);

$result = GraphQLBase::executeQuery($schema, $query, $rootValue, $context, $params, $operationName, $defaultFieldResolver)
->setErrorsHandler($errorsHandler)
->setErrorFormatter($errorFormatter);
$result = GraphQLBase::executeQuery($schema, $query, $rootValue, $context, $params, $operationName, $defaultFieldResolver);

return $result;
return $this->decorateExecutionResult($result);
}

public function addTypes(array $types): void
Expand Down Expand Up @@ -556,4 +552,14 @@ public static function getNormalizedSchemaConfiguration($schema)

return $instance->toConfig();
}

public function decorateExecutionResult(ExecutionResult $executionResult): ExecutionResult
{
$errorFormatter = config('graphql.error_formatter', [static::class, 'formatError']);
$errorsHandler = config('graphql.errors_handler', [static::class, 'handleErrors']);

return $executionResult
->setErrorsHandler($errorsHandler)
->setErrorFormatter($errorFormatter);
}
}
75 changes: 71 additions & 4 deletions src/GraphQLController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
namespace Rebing\GraphQL;

use Exception;
use GraphQL\Executor\ExecutionResult;
use Illuminate\Container\Container;
use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Arr;
use Rebing\GraphQL\Error\AutomaticPersistedQueriesError;

class GraphQLController extends Controller
{
Expand Down Expand Up @@ -37,8 +40,8 @@ public function query(Request $request, string $schema = null): JsonResponse
$schema = config('graphql.default_schema');
}

// If a singular query was not found, it means the queries are in batch
$isBatch = ! $request->has('query');
// check if is batch (check if the array is associative)
$isBatch = ! Arr::isAssoc($request->input());
$inputs = $isBatch ? $request->input() : [$request->input()];

$completedQueries = [];
Expand All @@ -58,15 +61,24 @@ public function query(Request $request, string $schema = null): JsonResponse

protected function executeQuery(string $schema, array $input): array
{
$query = $input['query'] ?? '';
/** @var GraphQL $graphql */
$graphql = $this->app->make('graphql');

try {
$query = $this->handleAutomaticPersistQueries($schema, $input);
} catch (AutomaticPersistedQueriesError $e) {
return $graphql
->decorateExecutionResult(new ExecutionResult(null, [$e]))
->toArray();
}

$paramsKey = config('graphql.params_key', 'variables');
$params = $input[$paramsKey] ?? null;
if (is_string($params)) {
$params = json_decode($params, true);
}

return $this->app->make('graphql')->query(
return $graphql->query(
$query,
$params,
[
Expand All @@ -86,6 +98,61 @@ protected function queryContext(string $query, ?array $params, string $schema)
}
}

/**
* Note: it's expected this is called even when APQ is disabled to adhere
* to the negotiation protocol.
*
* @param string $schemaName
* @param array<string,mixed> $input
* @return string
*/
protected function handleAutomaticPersistQueries(string $schemaName, array $input): string
{
$query = $input['query'] ?? '';
$apqEnabled = config('graphql.apq.enable', false);

// Even if APQ is disabled, we keep this logic for the negotiation protocol
$persistedQuery = $input['extensions']['persistedQuery'] ?? null;
if ($persistedQuery && !$apqEnabled) {
throw AutomaticPersistedQueriesError::persistedQueriesNotSupported();
}

// APQ disabled? Nothing to be done
if (!$apqEnabled) {
return $query;
}

// No hash? Nothing to be done
$hash = $persistedQuery['sha256Hash'] ?? null;
if (null === $hash) {
return $query;
}

$apqCacheDriver = config('graphql.apq.cache_driver');
$apqCachePrefix = config('graphql.apq.cache_prefix');
$apqCacheIdentifier = "$apqCachePrefix:$schemaName:$hash";

$cache = cache();

// store in cache
if ($query) {
if ($hash !== hash('sha256', $query)) {
throw AutomaticPersistedQueriesError::invalidHash();
}
$ttl = config('graphql.apq.cache_ttl', 300);
$cache->driver($apqCacheDriver)->set($apqCacheIdentifier, $query, $ttl);

return $query;
}

// retrieve from cache
if (!$cache->has($apqCacheIdentifier)) {
throw AutomaticPersistedQueriesError::persistedQueriesNotFound();
}

return $cache->driver($apqCacheDriver)->get($apqCacheIdentifier);
}

public function graphiql(Request $request, string $schema = null): View
{
$graphqlPath = '/'.config('graphql.prefix');
Expand Down
Loading