Skip to content

Commit 7b41bc9

Browse files
authored
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
1 parent d093249 commit 7b41bc9

12 files changed

+353
-1
lines changed

src/Http/Psr15GraphQLMiddlewareBuilder.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace TheCodingMachine\GraphQLite\Http;
66

7+
use DateInterval;
78
use GraphQL\Error\DebugFlag;
89
use GraphQL\Server\ServerConfig;
910
use GraphQL\Type\Schema;
@@ -12,14 +13,19 @@
1213
use Psr\Http\Message\ResponseFactoryInterface;
1314
use Psr\Http\Message\StreamFactoryInterface;
1415
use Psr\Http\Server\MiddlewareInterface;
16+
use Psr\SimpleCache\CacheInterface;
1517
use TheCodingMachine\GraphQLite\Context\Context;
1618
use TheCodingMachine\GraphQLite\Exceptions\WebonyxErrorHandler;
1719
use TheCodingMachine\GraphQLite\GraphQLRuntimeException;
20+
use TheCodingMachine\GraphQLite\Server\PersistedQuery\CachePersistedQueryLoader;
21+
use TheCodingMachine\GraphQLite\Server\PersistedQuery\NotSupportedPersistedQueryLoader;
1822

1923
use function class_exists;
2024

2125
/**
2226
* A factory generating a PSR-15 middleware tailored for GraphQLite.
27+
*
28+
* @phpstan-import-type PersistedQueryLoader from ServerConfig
2329
*/
2430
class Psr15GraphQLMiddlewareBuilder
2531
{
@@ -40,6 +46,7 @@ public function __construct(Schema $schema)
4046
$this->config->setErrorFormatter([WebonyxErrorHandler::class, 'errorFormatter']);
4147
$this->config->setErrorsHandler([WebonyxErrorHandler::class, 'errorHandler']);
4248
$this->config->setContext(new Context());
49+
$this->config->setPersistedQueryLoader(new NotSupportedPersistedQueryLoader());
4350
$this->httpCodeDecider = new HttpCodeDecider();
4451
}
4552

@@ -83,6 +90,13 @@ public function setHttpCodeDecider(HttpCodeDeciderInterface $httpCodeDecider): s
8390
return $this;
8491
}
8592

93+
public function useAutomaticPersistedQueries(CacheInterface $cache, DateInterval|null $ttl = null): self
94+
{
95+
$this->config->setPersistedQueryLoader(new CachePersistedQueryLoader($cache, $ttl));
96+
97+
return $this;
98+
}
99+
86100
public function createMiddleware(): MiddlewareInterface
87101
{
88102
if ($this->responseFactory === null && ! class_exists(ResponseFactory::class)) {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
6+
7+
use DateInterval;
8+
use GraphQL\Language\AST\DocumentNode;
9+
use GraphQL\Server\OperationParams;
10+
use Psr\SimpleCache\CacheInterface;
11+
12+
use function hash;
13+
use function mb_strtolower;
14+
15+
/**
16+
* Uses cache to automatically store persisted queries, a.k.a. Apollo automatic persisted queries.
17+
*/
18+
class CachePersistedQueryLoader
19+
{
20+
public function __construct(
21+
private readonly CacheInterface $cache,
22+
private readonly DateInterval|null $ttl = null,
23+
) {
24+
}
25+
26+
public function __invoke(string $queryId, OperationParams $operation): string|DocumentNode
27+
{
28+
$queryId = mb_strtolower($queryId);
29+
$query = $this->cache->get($queryId);
30+
31+
if ($query) {
32+
return $query;
33+
}
34+
35+
$query = $operation->query;
36+
37+
if (! $query) {
38+
throw new PersistedQueryNotFoundException();
39+
}
40+
41+
if (! $this->queryMatchesId($queryId, $query)) {
42+
throw new PersistedQueryIdInvalidException();
43+
}
44+
45+
$this->cache->set($queryId, $query, $this->ttl);
46+
47+
return $query;
48+
}
49+
50+
private function queryMatchesId(string $queryId, string $query): bool
51+
{
52+
return $queryId === hash('sha256', $query);
53+
}
54+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
6+
7+
use GraphQL\Language\AST\DocumentNode;
8+
use GraphQL\Server\OperationParams;
9+
10+
/**
11+
* Simply reports all attempts to load a persisted query as not supported so that clients don't continuously attempt to load them.
12+
*/
13+
class NotSupportedPersistedQueryLoader
14+
{
15+
public function __invoke(string $queryId, OperationParams $operation): string|DocumentNode
16+
{
17+
throw new PersistedQueryNotSupportedException();
18+
}
19+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
6+
7+
use TheCodingMachine\GraphQLite\Exceptions\GraphQLExceptionInterface;
8+
use Throwable;
9+
10+
interface PersistedQueryException extends Throwable, GraphQLExceptionInterface
11+
{
12+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
6+
7+
use GraphQL\Server\RequestError;
8+
use Throwable;
9+
10+
/**
11+
* This isn't part of an Apollo spec, but it's still nice to have.
12+
*/
13+
class PersistedQueryIdInvalidException extends RequestError implements PersistedQueryException
14+
{
15+
public function __construct(Throwable|null $previous = null)
16+
{
17+
parent::__construct('Persisted query by that ID doesnt match the provided query; you are likely incorrectly hashing your query.', previous: $previous);
18+
19+
$this->code = 'PERSISTED_QUERY_ID_INVALID';
20+
}
21+
22+
/** @return array<string, mixed> */
23+
public function getExtensions(): array
24+
{
25+
return [
26+
'code' => $this->code,
27+
];
28+
}
29+
30+
public function isClientSafe(): bool
31+
{
32+
return true;
33+
}
34+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
6+
7+
use GraphQL\Error\Error;
8+
use Throwable;
9+
10+
/**
11+
* See https://github.com/apollographql/apollo-client/blob/fc450f227522c5311375a6b59ec767ac45f151c7/src/link/persisted-queries/index.ts#L73
12+
*/
13+
class PersistedQueryNotFoundException extends Error implements PersistedQueryException
14+
{
15+
public function __construct(Throwable|null $previous = null)
16+
{
17+
parent::__construct('Persisted query by that ID was not found and "query" was omitted.', previous: $previous);
18+
19+
$this->code = 'PERSISTED_QUERY_NOT_FOUND';
20+
}
21+
22+
/** @return array<string, mixed> */
23+
public function getExtensions(): array
24+
{
25+
return [
26+
'code' => $this->code,
27+
];
28+
}
29+
30+
public function isClientSafe(): bool
31+
{
32+
return true;
33+
}
34+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
6+
7+
use GraphQL\Error\Error;
8+
use Throwable;
9+
10+
/**
11+
* See https://github.com/apollographql/apollo-client/blob/fc450f227522c5311375a6b59ec767ac45f151c7/src/link/persisted-queries/index.ts#L73
12+
*/
13+
class PersistedQueryNotSupportedException extends Error implements PersistedQueryException
14+
{
15+
public function __construct(Throwable|null $previous = null)
16+
{
17+
parent::__construct('Persisted queries are not supported by this server.', previous: $previous);
18+
19+
$this->code = 'PERSISTED_QUERY_NOT_SUPPORTED';
20+
}
21+
22+
/** @return array<string, mixed> */
23+
public function getExtensions(): array
24+
{
25+
return [
26+
'code' => $this->code,
27+
];
28+
}
29+
30+
public function isClientSafe(): bool
31+
{
32+
return true;
33+
}
34+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
<?php
2+
3+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
4+
5+
use GraphQL\Server\OperationParams;
6+
use PHPUnit\Framework\TestCase;
7+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
8+
use Symfony\Component\Cache\Psr16Cache;
9+
10+
class CachePersistedQueryLoaderTest extends TestCase
11+
{
12+
private const QUERY_STRING = 'query { field }';
13+
private const QUERY_HASH = '7b82cd908482825da2a4381cdda62a1384faa0c1b4c248e086aa44aa59fb9cd8';
14+
15+
private Psr16Cache $cache;
16+
17+
protected function setUp(): void
18+
{
19+
parent::setUp();
20+
21+
$this->cache = new Psr16Cache(new ArrayAdapter());
22+
}
23+
24+
public function testReturnsQueryFromCache(): void
25+
{
26+
$loader = new CachePersistedQueryLoader($this->cache);
27+
28+
$this->cache->set(self::QUERY_HASH, self::QUERY_STRING);
29+
30+
self::assertSame(self::QUERY_STRING, $loader(self::QUERY_HASH, OperationParams::create([])));
31+
self::assertSame(self::QUERY_STRING, $loader(strtoupper(self::QUERY_HASH), OperationParams::create([])));
32+
}
33+
34+
public function testSavesQueryIntoCache(): void
35+
{
36+
$loader = new CachePersistedQueryLoader($this->cache);
37+
38+
self::assertSame(self::QUERY_STRING, $loader(self::QUERY_HASH, OperationParams::create([
39+
'query' => self::QUERY_STRING,
40+
])));
41+
self::assertTrue($this->cache->has(self::QUERY_HASH));
42+
self::assertSame(self::QUERY_STRING, $this->cache->get(self::QUERY_HASH));
43+
}
44+
45+
public function testThrowsNotFoundExceptionWhenQueryNotFound(): void
46+
{
47+
$this->expectException(PersistedQueryNotFoundException::class);
48+
$this->expectExceptionMessage('Persisted query by that ID was not found and "query" was omitted.');
49+
50+
$loader = new CachePersistedQueryLoader($this->cache);
51+
52+
$loader('asd', OperationParams::create([]));
53+
}
54+
55+
public function testThrowsIdInvalidExceptionWhenQueryDoesNotMatchId(): void
56+
{
57+
$this->expectException(PersistedQueryIdInvalidException::class);
58+
$this->expectExceptionMessage('Persisted query by that ID doesnt match the provided query; you are likely incorrectly hashing your query.');
59+
60+
$loader = new CachePersistedQueryLoader($this->cache);
61+
62+
$loader('asd', OperationParams::create([
63+
'query' => self::QUERY_STRING
64+
]));
65+
}
66+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
namespace TheCodingMachine\GraphQLite\Server\PersistedQuery;
4+
5+
use GraphQL\Server\OperationParams;
6+
use PHPUnit\Framework\TestCase;
7+
8+
class NotSupportedPersistedQueryLoaderTest extends TestCase
9+
{
10+
public function testThrowsNotSupportedException(): void
11+
{
12+
$this->expectException(PersistedQueryNotSupportedException::class);
13+
$this->expectExceptionMessage('Persisted queries are not supported by this server.');
14+
15+
$loader = new NotSupportedPersistedQueryLoader();
16+
17+
$loader('asd', OperationParams::create([]));
18+
}
19+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
id: automatic-persisted-queries
3+
title: Automatic persisted queries
4+
sidebar_label: Automatic persisted queries
5+
---
6+
7+
## The problem
8+
9+
Clients send queries to GraphQLite as HTTP requests that include the GraphQL string of the query to execute.
10+
Depending on your graph's schema, the size of a valid query string might be arbitrarily large.
11+
As query strings become larger, increased latency and network usage can noticeably degrade client performance.
12+
13+
To combat this, GraphQL servers use a technique called "persisted queries". The basic idea is instead of
14+
sending the whole query string, clients only send it's unique identifier. The server then finds the actual
15+
query string by given identifier and use that as if the client sent the whole query in the first place.
16+
That helps improve GraphQL network performance with zero build-time configuration by sending smaller GraphQL HTTP requests.
17+
A smaller request payload reduces bandwidth utilization and speeds up GraphQL Client loading times.
18+
19+
## Apollo APQ
20+
21+
[Automatic persisted queries (APQ) is technique created by Apollo](https://www.apollographql.com/docs/apollo-server/performance/apq/)
22+
and is aimed to implement a simple automatic way of persisting queries. Queries are cached on the server side,
23+
along with its unique identifier (always its SHA-256 hash). Clients can send this identifier instead of the
24+
corresponding query string, thus reducing request sizes dramatically (response sizes are unaffected).
25+
26+
To persist a query string, GraphQLite server must first receive it from a requesting client.
27+
Consequently, each unique query string must be sent to Apollo Server at least once.
28+
After any client sends a query string to persist, every client that executes that query can then benefit from APQ.
29+
30+
```mermaid
31+
sequenceDiagram;
32+
Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute
33+
Note over GraphQL Server: Fails to find persisted query string
34+
GraphQL Server->>Client app: Responds with error
35+
Client app->>GraphQL Server: Sends both query string AND hash
36+
Note over GraphQL Server: Persists query string and hash
37+
GraphQL Server->>Client app: Executes query and returns result
38+
Note over Client app: Time passes
39+
Client app->>GraphQL Server: Sends SHA-256 hash of query string to execute
40+
Note over GraphQL Server: Finds persisted query string
41+
GraphQL Server->>Client app: Executes query and returns result
42+
```
43+
44+
Persisted queries are especially effective when clients send queries as GET requests.
45+
This enables clients to take advantage of the browser cache and integrate with a CDN.
46+
47+
Because query identifiers are deterministic hashes, clients can generate them at runtime. No additional build steps are required.
48+
49+
## Setup
50+
51+
To use Automatic persisted queries with GraphQLite, you may use
52+
`useAutomaticPersistedQueries` method when building your PSR-15 middleware:
53+
54+
```php
55+
$builder = new Psr15GraphQLMiddlewareBuilder($schema);
56+
57+
// You need to provide a PSR compatible cache and a TTL for queries. The best cache would be some kind
58+
// of in-memory cache with a limit on number of entries to make sure your cache can't be maliciously spammed with queries.
59+
$builder->useAutomaticPersistedQueries($cache, new DateInterval('PT1H'));
60+
```
61+

0 commit comments

Comments
 (0)