-
Notifications
You must be signed in to change notification settings - Fork 97
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Apollo Automatic persisted queries (#611)
* Automatic persisted queries with docs * Docs for APQ * Make sure PersistedQueryError is not classified as RequestError so it's returned as code 200 due to Apollo * Fix response codes for persisted query errors * Make persisted query errors at least extend GraphQL errors so they are treated as such * Fix PHPStan errors * Code style
- Loading branch information
1 parent
d093249
commit 7b41bc9
Showing
12 changed files
with
353 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use DateInterval; | ||
use GraphQL\Language\AST\DocumentNode; | ||
use GraphQL\Server\OperationParams; | ||
use Psr\SimpleCache\CacheInterface; | ||
|
||
use function hash; | ||
use function mb_strtolower; | ||
|
||
/** | ||
* Uses cache to automatically store persisted queries, a.k.a. Apollo automatic persisted queries. | ||
*/ | ||
class CachePersistedQueryLoader | ||
{ | ||
public function __construct( | ||
private readonly CacheInterface $cache, | ||
private readonly DateInterval|null $ttl = null, | ||
) { | ||
} | ||
|
||
public function __invoke(string $queryId, OperationParams $operation): string|DocumentNode | ||
{ | ||
$queryId = mb_strtolower($queryId); | ||
$query = $this->cache->get($queryId); | ||
|
||
if ($query) { | ||
return $query; | ||
} | ||
|
||
$query = $operation->query; | ||
|
||
if (! $query) { | ||
throw new PersistedQueryNotFoundException(); | ||
} | ||
|
||
if (! $this->queryMatchesId($queryId, $query)) { | ||
throw new PersistedQueryIdInvalidException(); | ||
} | ||
|
||
$this->cache->set($queryId, $query, $this->ttl); | ||
|
||
return $query; | ||
} | ||
|
||
private function queryMatchesId(string $queryId, string $query): bool | ||
{ | ||
return $queryId === hash('sha256', $query); | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
src/Server/PersistedQuery/NotSupportedPersistedQueryLoader.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use GraphQL\Language\AST\DocumentNode; | ||
use GraphQL\Server\OperationParams; | ||
|
||
/** | ||
* Simply reports all attempts to load a persisted query as not supported so that clients don't continuously attempt to load them. | ||
*/ | ||
class NotSupportedPersistedQueryLoader | ||
{ | ||
public function __invoke(string $queryId, OperationParams $operation): string|DocumentNode | ||
{ | ||
throw new PersistedQueryNotSupportedException(); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use TheCodingMachine\GraphQLite\Exceptions\GraphQLExceptionInterface; | ||
use Throwable; | ||
|
||
interface PersistedQueryException extends Throwable, GraphQLExceptionInterface | ||
{ | ||
} |
34 changes: 34 additions & 0 deletions
34
src/Server/PersistedQuery/PersistedQueryIdInvalidException.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use GraphQL\Server\RequestError; | ||
use Throwable; | ||
|
||
/** | ||
* This isn't part of an Apollo spec, but it's still nice to have. | ||
*/ | ||
class PersistedQueryIdInvalidException extends RequestError implements PersistedQueryException | ||
{ | ||
public function __construct(Throwable|null $previous = null) | ||
{ | ||
parent::__construct('Persisted query by that ID doesnt match the provided query; you are likely incorrectly hashing your query.', previous: $previous); | ||
|
||
$this->code = 'PERSISTED_QUERY_ID_INVALID'; | ||
} | ||
|
||
/** @return array<string, mixed> */ | ||
public function getExtensions(): array | ||
{ | ||
return [ | ||
'code' => $this->code, | ||
]; | ||
} | ||
|
||
public function isClientSafe(): bool | ||
{ | ||
return true; | ||
} | ||
} |
34 changes: 34 additions & 0 deletions
34
src/Server/PersistedQuery/PersistedQueryNotFoundException.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use GraphQL\Error\Error; | ||
use Throwable; | ||
|
||
/** | ||
* See https://github.com/apollographql/apollo-client/blob/fc450f227522c5311375a6b59ec767ac45f151c7/src/link/persisted-queries/index.ts#L73 | ||
*/ | ||
class PersistedQueryNotFoundException extends Error implements PersistedQueryException | ||
{ | ||
public function __construct(Throwable|null $previous = null) | ||
{ | ||
parent::__construct('Persisted query by that ID was not found and "query" was omitted.', previous: $previous); | ||
|
||
$this->code = 'PERSISTED_QUERY_NOT_FOUND'; | ||
} | ||
|
||
/** @return array<string, mixed> */ | ||
public function getExtensions(): array | ||
{ | ||
return [ | ||
'code' => $this->code, | ||
]; | ||
} | ||
|
||
public function isClientSafe(): bool | ||
{ | ||
return true; | ||
} | ||
} |
34 changes: 34 additions & 0 deletions
34
src/Server/PersistedQuery/PersistedQueryNotSupportedException.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use GraphQL\Error\Error; | ||
use Throwable; | ||
|
||
/** | ||
* See https://github.com/apollographql/apollo-client/blob/fc450f227522c5311375a6b59ec767ac45f151c7/src/link/persisted-queries/index.ts#L73 | ||
*/ | ||
class PersistedQueryNotSupportedException extends Error implements PersistedQueryException | ||
{ | ||
public function __construct(Throwable|null $previous = null) | ||
{ | ||
parent::__construct('Persisted queries are not supported by this server.', previous: $previous); | ||
|
||
$this->code = 'PERSISTED_QUERY_NOT_SUPPORTED'; | ||
} | ||
|
||
/** @return array<string, mixed> */ | ||
public function getExtensions(): array | ||
{ | ||
return [ | ||
'code' => $this->code, | ||
]; | ||
} | ||
|
||
public function isClientSafe(): bool | ||
{ | ||
return true; | ||
} | ||
} |
66 changes: 66 additions & 0 deletions
66
tests/Server/PersistedQuery/CachePersistedQueryLoaderTest.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
<?php | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use GraphQL\Server\OperationParams; | ||
use PHPUnit\Framework\TestCase; | ||
use Symfony\Component\Cache\Adapter\ArrayAdapter; | ||
use Symfony\Component\Cache\Psr16Cache; | ||
|
||
class CachePersistedQueryLoaderTest extends TestCase | ||
{ | ||
private const QUERY_STRING = 'query { field }'; | ||
private const QUERY_HASH = '7b82cd908482825da2a4381cdda62a1384faa0c1b4c248e086aa44aa59fb9cd8'; | ||
|
||
private Psr16Cache $cache; | ||
|
||
protected function setUp(): void | ||
{ | ||
parent::setUp(); | ||
|
||
$this->cache = new Psr16Cache(new ArrayAdapter()); | ||
} | ||
|
||
public function testReturnsQueryFromCache(): void | ||
{ | ||
$loader = new CachePersistedQueryLoader($this->cache); | ||
|
||
$this->cache->set(self::QUERY_HASH, self::QUERY_STRING); | ||
|
||
self::assertSame(self::QUERY_STRING, $loader(self::QUERY_HASH, OperationParams::create([]))); | ||
self::assertSame(self::QUERY_STRING, $loader(strtoupper(self::QUERY_HASH), OperationParams::create([]))); | ||
} | ||
|
||
public function testSavesQueryIntoCache(): void | ||
{ | ||
$loader = new CachePersistedQueryLoader($this->cache); | ||
|
||
self::assertSame(self::QUERY_STRING, $loader(self::QUERY_HASH, OperationParams::create([ | ||
'query' => self::QUERY_STRING, | ||
]))); | ||
self::assertTrue($this->cache->has(self::QUERY_HASH)); | ||
self::assertSame(self::QUERY_STRING, $this->cache->get(self::QUERY_HASH)); | ||
} | ||
|
||
public function testThrowsNotFoundExceptionWhenQueryNotFound(): void | ||
{ | ||
$this->expectException(PersistedQueryNotFoundException::class); | ||
$this->expectExceptionMessage('Persisted query by that ID was not found and "query" was omitted.'); | ||
|
||
$loader = new CachePersistedQueryLoader($this->cache); | ||
|
||
$loader('asd', OperationParams::create([])); | ||
} | ||
|
||
public function testThrowsIdInvalidExceptionWhenQueryDoesNotMatchId(): void | ||
{ | ||
$this->expectException(PersistedQueryIdInvalidException::class); | ||
$this->expectExceptionMessage('Persisted query by that ID doesnt match the provided query; you are likely incorrectly hashing your query.'); | ||
|
||
$loader = new CachePersistedQueryLoader($this->cache); | ||
|
||
$loader('asd', OperationParams::create([ | ||
'query' => self::QUERY_STRING | ||
])); | ||
} | ||
} |
19 changes: 19 additions & 0 deletions
19
tests/Server/PersistedQuery/NotSupportedPersistedQueryLoaderTest.php
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
<?php | ||
|
||
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery; | ||
|
||
use GraphQL\Server\OperationParams; | ||
use PHPUnit\Framework\TestCase; | ||
|
||
class NotSupportedPersistedQueryLoaderTest extends TestCase | ||
{ | ||
public function testThrowsNotSupportedException(): void | ||
{ | ||
$this->expectException(PersistedQueryNotSupportedException::class); | ||
$this->expectExceptionMessage('Persisted queries are not supported by this server.'); | ||
|
||
$loader = new NotSupportedPersistedQueryLoader(); | ||
|
||
$loader('asd', OperationParams::create([])); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
--- | ||
id: automatic-persisted-queries | ||
title: Automatic persisted queries | ||
sidebar_label: Automatic persisted queries | ||
--- | ||
|
||
## The problem | ||
|
||
Clients send queries to GraphQLite as HTTP requests that include the GraphQL string of the query to execute. | ||
Depending on your graph's schema, the size of a valid query string might be arbitrarily large. | ||
As query strings become larger, increased latency and network usage can noticeably degrade client performance. | ||
|
||
To combat this, GraphQL servers use a technique called "persisted queries". The basic idea is instead of | ||
sending the whole query string, clients only send it's unique identifier. The server then finds the actual | ||
query string by given identifier and use that as if the client sent the whole query in the first place. | ||
That helps improve GraphQL network performance with zero build-time configuration by sending smaller GraphQL HTTP requests. | ||
A smaller request payload reduces bandwidth utilization and speeds up GraphQL Client loading times. | ||
|
||
## Apollo APQ | ||
|
||
[Automatic persisted queries (APQ) is technique created by Apollo](https://www.apollographql.com/docs/apollo-server/performance/apq/) | ||
and is aimed to implement a simple automatic way of persisting queries. Queries are cached on the server side, | ||
along with its unique identifier (always its SHA-256 hash). Clients can send this identifier instead of the | ||
corresponding query string, thus reducing request sizes dramatically (response sizes are unaffected). | ||
|
||
To persist a query string, GraphQLite server must first receive it from a requesting client. | ||
Consequently, each unique query string must be sent to Apollo Server at least once. | ||
After any client sends a query string to persist, every client that executes that query can then benefit from APQ. | ||
|
||
```mermaid | ||
sequenceDiagram; | ||
Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute | ||
Note over GraphQL Server: Fails to find persisted query string | ||
GraphQL Server->>Client app: Responds with error | ||
Client app->>GraphQL Server: Sends both query string AND hash | ||
Note over GraphQL Server: Persists query string and hash | ||
GraphQL Server->>Client app: Executes query and returns result | ||
Note over Client app: Time passes | ||
Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute | ||
Note over GraphQL Server: Finds persisted query string | ||
GraphQL Server->>Client app: Executes query and returns result | ||
``` | ||
|
||
Persisted queries are especially effective when clients send queries as GET requests. | ||
This enables clients to take advantage of the browser cache and integrate with a CDN. | ||
|
||
Because query identifiers are deterministic hashes, clients can generate them at runtime. No additional build steps are required. | ||
|
||
## Setup | ||
|
||
To use Automatic persisted queries with GraphQLite, you may use | ||
`useAutomaticPersistedQueries` method when building your PSR-15 middleware: | ||
|
||
```php | ||
$builder = new Psr15GraphQLMiddlewareBuilder($schema); | ||
|
||
// You need to provide a PSR compatible cache and a TTL for queries. The best cache would be some kind | ||
// of in-memory cache with a limit on number of entries to make sure your cache can't be maliciously spammed with queries. | ||
$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H')); | ||
``` | ||
|
Oops, something went wrong.