Skip to content

Make the encoder for the cursors of the edges of a connection customizable #678

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 3 commits into from
May 23, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
32 changes: 32 additions & 0 deletions UPGRADE-1.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
UPGRADE FROM 0.13 to 1.0
=======================

# Table of Contents

- [Customize the cursor encoder of the edges of a connection](#customize-the-cursor-encoder-of-the-edges-of-a-connection)

### Customize the cursor encoder of the edges of a connection

The connection builder now accepts an optional custom cursor encoder as first argument of the constructor.

```diff
$connectionBuilder = new ConnectionBuilder(
+ new class implements CursorEncoderInterface {
+ public function encode($value): string
+ {
+ ...
+ }
+
+ public function decode(string $cursor)
+ {
+ ...
+ }
+ }
static function (iterable $edges, PageInfoInterface $pageInfo) {
...
},
static function (string $cursor, $value, int $index) {
...
}
);
```
12 changes: 8 additions & 4 deletions docs/helpers/relay-paginator.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,18 +269,22 @@ Sometimes, you want to add fields to your Connection or Edges. In order to do so

```php
use Overblog\GraphQLBundle\Relay\Connection\ConnectionBuilder;
use Overblog\GraphQLBundle\Relay\Connection\Cursor\Base64CursorEncoder;

public function resolveSomething(Argument $args)
{
$connectionBuilder = new ConnectionBuilder(
$connectionBuilder = new ConnectionBuilder(
new Base64CursorEncoder(),
function(iterable $edges, PageInfo $pageInfo) : FriendsConnection {
$connection = new FriendsConnection($edges, $pageInfo)
$connection = new FriendsConnection($edges, $pageInfo);
$connection->setAverageAge(calculateAverage($edges));

return $connection;
},
function(string $cursor, UserFriend $entity, int $index):FriendEdge {
function(string $cursor, UserFriend $entity, int $index): FriendEdge {
$edge = new FriendEdge($cursor, $entity->getUser());
$edge->setFriendshipTime($entity->getCreatedAt());

return $edge;
}
);
Expand All @@ -291,7 +295,7 @@ public function resolveSomething(Argument $args)
}
```

The `ConnectionBuilder` constructor accepts two parameters. The first one is a callback to build the Connection object, and the second one is a callback to build an Edge object.
The `ConnectionBuilder` constructor accepts three parameters. The first one is an encoder that will be used to encode the cursor of the edges, the second is a callback to build the Connection object and the last one is a callback to build an Edge object.

The connection callback will be call with the following parameters :

Expand Down
19 changes: 12 additions & 7 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -625,11 +625,6 @@ parameters:
count: 1
path: src/Relay/Connection/ConnectionBuilder.php

-
message: "#^Parameter \\#3 \\$subject of function str_replace expects array\\|string, string\\|false given\\.$#"
count: 1
path: src/Relay/Connection/ConnectionBuilder.php

-
message: "#^If condition is always true\\.$#"
count: 2
Expand Down Expand Up @@ -1132,12 +1127,22 @@ parameters:

-
message: "#^Cannot access offset 0 on iterable\\<Overblog\\\\GraphQLBundle\\\\Relay\\\\Connection\\\\EdgeInterface\\>\\.$#"
count: 3
count: 4
path: tests/Relay/Connection/ConnectionBuilderTest.php

-
message: "#^Cannot access offset 1 on iterable\\<Overblog\\\\GraphQLBundle\\\\Relay\\\\Connection\\\\EdgeInterface\\>\\.$#"
count: 3
count: 4
path: tests/Relay/Connection/ConnectionBuilderTest.php

-
message: "#^Cannot access offset 2 on iterable\\<Overblog\\\\GraphQLBundle\\\\Relay\\\\Connection\\\\EdgeInterface\\>\\.$#"
count: 1
path: tests/Relay/Connection/ConnectionBuilderTest.php

-
message: "#^Cannot access offset 3 on iterable\\<Overblog\\\\GraphQLBundle\\\\Relay\\\\Connection\\\\EdgeInterface\\>\\.$#"
count: 1
path: tests/Relay/Connection/ConnectionBuilderTest.php

-
Expand Down
17 changes: 14 additions & 3 deletions src/Relay/Connection/ConnectionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
namespace Overblog\GraphQLBundle\Relay\Connection;

use Overblog\GraphQLBundle\Definition\ArgumentInterface;
use Overblog\GraphQLBundle\Relay\Connection\Cursor\Base64CursorEncoder;
use Overblog\GraphQLBundle\Relay\Connection\Cursor\CursorEncoderInterface;
use Overblog\GraphQLBundle\Relay\Connection\Output\Connection;
use Overblog\GraphQLBundle\Relay\Connection\Output\Edge;
use Overblog\GraphQLBundle\Relay\Connection\Output\PageInfo;
Expand All @@ -18,6 +20,11 @@ class ConnectionBuilder
{
public const PREFIX = 'arrayconnection:';

/**
* @var CursorEncoderInterface
*/
protected $cursorEncoder;

/**
* If set, used to generate the connection object.
*
Expand All @@ -32,8 +39,9 @@ class ConnectionBuilder
*/
protected $edgeCallback;

public function __construct(callable $connectionCallback = null, callable $edgeCallback = null)
public function __construct(?CursorEncoderInterface $cursorEncoder = null, callable $connectionCallback = null, callable $edgeCallback = null)
{
$this->cursorEncoder = $cursorEncoder ?? new Base64CursorEncoder();
$this->connectionCallback = $connectionCallback;
$this->edgeCallback = $edgeCallback;
}
Expand Down Expand Up @@ -245,7 +253,7 @@ public function getOffsetWithDefault(?string $cursor, int $defaultOffset): int
*/
public function offsetToCursor($offset): string
{
return \base64_encode(static::PREFIX.$offset);
return $this->cursorEncoder->encode(self::PREFIX.$offset);
}

/**
Expand All @@ -257,11 +265,14 @@ public function offsetToCursor($offset): string
*/
public function cursorToOffset($cursor): string
{
// Returning an empty string is required to not break the Paginator
// class. Ideally, we should throw an exception or not call this
// method if $cursor is empty
if (null === $cursor) {
return '';
}

return \str_replace(static::PREFIX, '', \base64_decode($cursor, true));
return \str_replace(static::PREFIX, '', $this->cursorEncoder->decode($cursor));
}

private function createEdges(iterable $slice, int $startOffset): array
Expand Down
29 changes: 29 additions & 0 deletions src/Relay/Connection/Cursor/Base64CursorEncoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Overblog\GraphQLBundle\Relay\Connection\Cursor;

use Overblog\GraphQLBundle\Util\Base64Encoder;

/**
* @phpstan-implements CursorEncoderInterface<string>
*/
final class Base64CursorEncoder implements CursorEncoderInterface
{
/**
* {@inheritdoc}
*/
public function encode($value): string
{
return Base64Encoder::encode($value);
}

/**
* {@inheritdoc}
*/
public function decode(string $cursor)
{
return Base64Encoder::decode($cursor);
}
}
29 changes: 29 additions & 0 deletions src/Relay/Connection/Cursor/Base64UrlSafeCursorEncoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace Overblog\GraphQLBundle\Relay\Connection\Cursor;

use Overblog\GraphQLBundle\Util\Base64Encoder;

/**
* @phpstan-implements CursorEncoderInterface<string>
*/
final class Base64UrlSafeCursorEncoder implements CursorEncoderInterface
{
/**
* {@inheritdoc}
*/
public function encode($value): string
{
return Base64Encoder::encodeUrlSafe($value);
}

/**
* {@inheritdoc}
*/
public function decode(string $cursor)
{
return Base64Encoder::decodeUrlSafe($cursor);
}
}
25 changes: 25 additions & 0 deletions src/Relay/Connection/Cursor/CursorEncoderInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Overblog\GraphQLBundle\Relay\Connection\Cursor;

/**
* @phpstan-template T
*/
interface CursorEncoderInterface
{
/**
* @param mixed $value
*
* @phpstan-param T $value
*/
public function encode($value): string;

/**
* @return mixed
*
* @phpstan-return T
*/
public function decode(string $cursor);
}
27 changes: 27 additions & 0 deletions src/Relay/Connection/Cursor/PlainCursorEncoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace Overblog\GraphQLBundle\Relay\Connection\Cursor;

/**
* @phpstan-implements CursorEncoderInterface<string>
*/
final class PlainCursorEncoder implements CursorEncoderInterface
{
/**
* {@inheritdoc}
*/
public function encode($value): string
{
return $value;
}

/**
* {@inheritdoc}
*/
public function decode(string $cursor)
{
return $cursor;
}
}
53 changes: 53 additions & 0 deletions src/Util/Base64Encoder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Overblog\GraphQLBundle\Util;

final class Base64Encoder
{
public static function encode(string $value): string
{
return \base64_encode($value);
}

public static function decode(string $value, bool $strict = true): string
{
$result = \base64_decode($value, $strict);

if (false === $result) {
throw new \InvalidArgumentException(\sprintf('The "%s" value failed to be decoded from base64 format.', $value));
}

return $result;
}

public static function encodeUrlSafe(string $value, bool $padding = false): string
{
$result = \base64_encode($value);
$result = \str_replace(['+', '/'], ['-', '_'], $result);

if (!$padding) {
$result = \str_replace('=', '', $result);
}

return $result;
}

public static function decodeUrlSafe(string $value, bool $strict = true): string
{
$value = \str_replace(['-', '_'], ['+', '/'], $value);

if (0 === \substr_compare($value, '=', -1) && 0 !== \strlen($value) % 4) {
$value = \str_pad($value, (\strlen($value) + 3) & ~3, '=');
}

$result = \base64_decode($value, $strict);

if (false === $result) {
throw new \InvalidArgumentException(\sprintf('The "%s" value failed to be decoded from base64 format.', $value));
}

return $result;
}
}
25 changes: 23 additions & 2 deletions tests/Relay/Connection/ConnectionBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Overblog\GraphQLBundle\Tests\Relay\Connection;

use Overblog\GraphQLBundle\Relay\Connection\ConnectionBuilder;
use Overblog\GraphQLBundle\Relay\Connection\Cursor\CursorEncoderInterface;
use Overblog\GraphQLBundle\Relay\Connection\Output\Connection;
use Overblog\GraphQLBundle\Relay\Connection\Output\PageInfo;

Expand Down Expand Up @@ -376,9 +377,29 @@ public function testReturnsAnEdgesCursorGivenAnArrayAndANonMemberObject(): void
$this->assertNull($letterCursor);
}

public function testCursorEncoder(): void
{
$cursorEncoder = $this->createMock(CursorEncoderInterface::class);
$cursorEncoder->expects($this->exactly(4))
->method('encode')
->willReturnArgument(0);

$cursorEncoder->expects($this->exactly(1))
->method('decode')
->willReturnArgument(0);

$connectionBuilder = new ConnectionBuilder($cursorEncoder);
$edges = $connectionBuilder->connectionFromArray($this->letters, ['after' => 'arrayconnection:0'])->getEdges();

$this->assertSame($edges[0]->getCursor(), 'arrayconnection:1');
$this->assertSame($edges[1]->getCursor(), 'arrayconnection:2');
$this->assertSame($edges[2]->getCursor(), 'arrayconnection:3');
$this->assertSame($edges[3]->getCursor(), 'arrayconnection:4');
}

public function testConnectionCallback(): void
{
$connectionBuilder = new ConnectionBuilder(function ($edges, $pageInfo) {
$connectionBuilder = new ConnectionBuilder(null, function ($edges, $pageInfo) {
$connection = new fixtures\CustomConnection($edges, $pageInfo);
$connection->averageAge = 10;

Expand All @@ -392,7 +413,7 @@ public function testConnectionCallback(): void

public function testEdgeCallback(): void
{
$connectionBuilder = new ConnectionBuilder(null, function ($cursor, $value, $index) {
$connectionBuilder = new ConnectionBuilder(null, null, function ($cursor, $value, $index) {
$edge = new fixtures\CustomEdge($cursor, $value);
$edge->customProperty = 'edge'.$index;

Expand Down
Loading