Skip to content

Commit

Permalink
Apollo Automatic persisted queries (#611)
Browse files Browse the repository at this point in the history
* 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
oprypkhantc authored Aug 4, 2023
1 parent d093249 commit 7b41bc9
Show file tree
Hide file tree
Showing 12 changed files with 353 additions and 1 deletion.
14 changes: 14 additions & 0 deletions src/Http/Psr15GraphQLMiddlewareBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace TheCodingMachine\GraphQLite\Http;

use DateInterval;
use GraphQL\Error\DebugFlag;
use GraphQL\Server\ServerConfig;
use GraphQL\Type\Schema;
Expand All @@ -12,14 +13,19 @@
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\SimpleCache\CacheInterface;
use TheCodingMachine\GraphQLite\Context\Context;
use TheCodingMachine\GraphQLite\Exceptions\WebonyxErrorHandler;
use TheCodingMachine\GraphQLite\GraphQLRuntimeException;
use TheCodingMachine\GraphQLite\Server\PersistedQuery\CachePersistedQueryLoader;
use TheCodingMachine\GraphQLite\Server\PersistedQuery\NotSupportedPersistedQueryLoader;

use function class_exists;

/**
* A factory generating a PSR-15 middleware tailored for GraphQLite.
*
* @phpstan-import-type PersistedQueryLoader from ServerConfig
*/
class Psr15GraphQLMiddlewareBuilder
{
Expand All @@ -40,6 +46,7 @@ public function __construct(Schema $schema)
$this->config->setErrorFormatter([WebonyxErrorHandler::class, 'errorFormatter']);
$this->config->setErrorsHandler([WebonyxErrorHandler::class, 'errorHandler']);
$this->config->setContext(new Context());
$this->config->setPersistedQueryLoader(new NotSupportedPersistedQueryLoader());
$this->httpCodeDecider = new HttpCodeDecider();
}

Expand Down Expand Up @@ -83,6 +90,13 @@ public function setHttpCodeDecider(HttpCodeDeciderInterface $httpCodeDecider): s
return $this;
}

public function useAutomaticPersistedQueries(CacheInterface $cache, DateInterval|null $ttl = null): self
{
$this->config->setPersistedQueryLoader(new CachePersistedQueryLoader($cache, $ttl));

return $this;
}

public function createMiddleware(): MiddlewareInterface
{
if ($this->responseFactory === null && ! class_exists(ResponseFactory::class)) {
Expand Down
54 changes: 54 additions & 0 deletions src/Server/PersistedQuery/CachePersistedQueryLoader.php
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 src/Server/PersistedQuery/NotSupportedPersistedQueryLoader.php
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();
}
}
12 changes: 12 additions & 0 deletions src/Server/PersistedQuery/PersistedQueryException.php
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 src/Server/PersistedQuery/PersistedQueryIdInvalidException.php
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 src/Server/PersistedQuery/PersistedQueryNotFoundException.php
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 src/Server/PersistedQuery/PersistedQueryNotSupportedException.php
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 tests/Server/PersistedQuery/CachePersistedQueryLoaderTest.php
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
]));
}
}
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([]));
}
}
61 changes: 61 additions & 0 deletions website/docs/automatic-persisted-queries.mdx
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'));
```

Loading

0 comments on commit 7b41bc9

Please sign in to comment.