Skip to content

feat: Stored subscriptions #13

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

Open
wants to merge 24 commits into
base: alpha
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
229bbcf
feat: GraphQLDocument scalar type
oprypkhantc May 29, 2025
48da62a
refactor: Dynamic context
oprypkhantc May 29, 2025
540a17a
feat: GraphQL validation constraints
oprypkhantc May 29, 2025
8f4cf2e
feat: Subscriptions
oprypkhantc Jun 2, 2025
6ac4f18
reformat: Code style
oprypkhantc Jun 2, 2025
76ec2a5
refactor: Rename method that no longer includes owner
oprypkhantc Jun 3, 2025
7ce4efa
fix: Don't treat expires_at null as expired
oprypkhantc Jun 3, 2025
22671d6
refactor: Add shared method to execute GraphQL queries from code with…
oprypkhantc Jun 3, 2025
853afa2
fix: Use subscription transport name instead of instance to make sure…
oprypkhantc Jun 3, 2025
bf1bc24
feat: Add ValidationExceptions special helper class
oprypkhantc Jun 3, 2025
c1c388b
fix: ModelID with new ID type
oprypkhantc Jun 3, 2025
58e3f9e
fix: Broken prefetch
oprypkhantc Jun 4, 2025
eba4c60
fix: PHPStan
oprypkhantc Jun 4, 2025
040c619
refactor: Prettier code style
oprypkhantc Jun 4, 2025
79ac66e
refactor: Update php-cs-fixer to fix problems with generics
oprypkhantc Jun 4, 2025
961a045
refactor: Stricter #[Covers], more tests
oprypkhantc Jun 4, 2025
d9eea72
refactor: Add more tests and move things around to improve coverage
oprypkhantc Jun 5, 2025
6fd4a3e
fix: PHPStan
oprypkhantc Jun 5, 2025
ad9b067
fix: Add index for graphql subscription channel
oprypkhantc Jun 5, 2025
f6f4d6e
refactor: Rename cancel to disable - a more fitting name
oprypkhantc Jun 5, 2025
b9a8fcd
fix: Case where a subscription is disabled should not delete the grap…
oprypkhantc Jun 5, 2025
c0af0d2
fix: Failing PHPStan
oprypkhantc Jun 6, 2025
51cbfbe
refactor: Code style
oprypkhantc Jun 6, 2025
68bf24f
refactor: Remove unused dep
oprypkhantc Jun 6, 2025
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
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
},
"require-dev": {
"pestphp/pest": "^2.8",
"php-cs-fixer/shim": "~3.19.2",
"tenantcloud/php-cs-fixer-rule-sets": "~3.0.0",
"php-cs-fixer/shim": "^3.54",
"tenantcloud/php-cs-fixer-rule-sets": "~3.4.1",
"phpstan/phpstan": "~1.12.16",
"phpstan/phpstan-phpunit": "^1.3",
"phpstan/phpstan-webmozart-assert": "^1.2",
Expand Down
76 changes: 76 additions & 0 deletions docs/usage/subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Subscriptions

### Backend usage

To define a subscription, you use a `#[Subscription]` attribute. The return type must
be `ChannelSubscription`, and it's generic parameter must specify the output type of the
subscription. It may accept parameters, just like queries and mutations, apply additional
attributes, middlewares. It's also a good idea to check authorization:

```php
class ThreadController {
use AuthorizesRequests;

/** @return ChannelSubscription<Message> */
#[Subscription]
public function newThreadMessage(
#[InjectUser] User $auth,
#[ModelID] Thread $thread
): ChannelSubscription {
$this->authorizeForUser($auth, 'view', $thread);

return new ChannelSubscription("threads.{$thread->id}.messages");
}
}
```

As you can see, all we did is some checks - ones you would usually do when defining a
[Laravel broadcast channel](https://laravel.com/docs/11.x/broadcasting#example-application-authorizing-channels).
Then the method returns a `ChannelSubscription` with a channel name. That channel name
is what we would then use to publish events for that subscription through
[Laravel notifications](https://laravel.com/docs/11.x/notifications#custom-channels):

```php
readonly class NewThreadMessageNotification
{
public function __construct(
public Message $message,
) {}

public function via(): array
{
return [
GraphQLChannel::class,
// MailChannel::class,
];
}

public function toGraphQL(): GraphQLMessage
{
return (new GraphQLMessage($this->message))
->on("threads.{$this->message->thread->id}.messages");
}
}
```

### Client usage

Other GraphQL implementations usually implement subscriptions directly, by adding a
SSE endpoint or a built-in websocket server. However, to be able to handle many thousands
of connections and keep them alive (which is generally a requirement for subscriptions),
we'd need some way of async execution - which, sadly, isn't possible in Laravel at the moment.

Instead, we'll be delegating the SSE/WS part to an external server, one that is completely
separate from our backend. This way, we could use a different technology that is more fit
to the task. The same thing was done in [graphql-ruby](https://graphql-ruby.org/subscriptions/pusher_implementation)
due to the limitations that Ruby has, similar to PHP. They've done a good job describing
how it works, so you should go and read their docs.

In our case, we're delegating the keep-alive connections part to Laravel broadcasting mechanism.
When you execute a `subscription` operation on the backend, instead of switching protocols
or keeping the connection open, like GraphQL servers usually do, it closes the connection
with a specific error code: `SUBSCRIPTION_REDIRECTED`. The error also includes
a channel name that the client should use to subscribe to that channel on the
broadcasting side, as well as an authorization signature so save a request to
`/broadcasting/auth`. The subscription will expire within 5 minutes if client
doesn't subscribe.
12 changes: 12 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,26 @@ parameters:
- src
- tests

excludePaths:
- tests/Fixtures/Invalid

ignoreErrors:
# PHPStan is wrong here.
- '#Class TenantCloud\\GraphQLPlatform\\Discovery\\Composer\\ComposerClassFinder has an uninitialized readonly property \$\w+\. Assign it in the constructor\.#'
- '#Readonly property TenantCloud\\GraphQLPlatform\\Discovery\\Composer\\ComposerClassFinder::\$\w+ is assigned outside of the constructor\.#'
- '#Using nullsafe property access "\?->attributes" on left side of \?\? is unnecessary\. Use -> instead\.#'
- '#Access to an undefined property TenantCloud\\GraphQLPlatform\\Subscription\\Subscription::\$(id|channel|transport|schema_name|document|variables|resolve|filter)#'
- '#Parameter \#1 \$field of method TenantCloud\\GraphQLPlatform\\Context\\Context::getPrefetchBuffer\(\) expects TheCodingMachine\\GraphQLite\\Parameters\\ParameterInterface, Mockery\\MockInterface given#'
- '#Call to static method PHPUnit\\Framework\\Assert::assertSame\(\) with .* and .* will always evaluate#'
- '#Parameter \$schemaRegistry of class TenantCloud\\GraphQLPlatform\\Validation\\Constraints\\ValidGraphQLValidator constructor expects TenantCloud\\GraphQLPlatform\\Schema\\SchemaRegistry, Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\|Mockery\\MockInterface given#'
- '#Parameter \#1 \$transport of method Tests\\Integration\\SubscriptionsTest::createSubscription\(\) expects TenantCloud\\GraphQLPlatform\\Subscription\\Transport\\SubscriptionTransport, Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\|Mockery\\MockInterface given.#'
- '#Parameter \#1 \$schemaFactory of class TenantCloud\\GraphQLPlatform\\Schema\\SchemaRegistry constructor expects TenantCloud\\GraphQLPlatform\\Schema\\SchemaFactory, Mockery\\MockInterface given.#'
- '#Parameter \#1 \$users of class Tests\\Fixtures\\Valid\\Models\\SelectionResponse constructor expects TenantCloud\\GraphQLPlatform\\Connection\\Offset\\OffsetConnection<Tests\\Fixtures\\Valid\\Models\\User>, TenantCloud\\GraphQLPlatform\\Laravel\\Pagination\\LengthAwarePaginatorOffsetConnectionAdapter<mixed> given.#'
# Vendor wrong return type
- '#Method TenantCloud\\GraphQLPlatform\\Testing\\TestExecutionResult::fromExecutionResult\(\) should return TenantCloud\\GraphQLPlatform\\Testing\\TestExecutionResult but returns GraphQL\\Executor\\ExecutionResult\.#i'
- '#Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\ExpectsHigherOrderMessage::#'
# Broken in the stupid phpDocumentor library
- '#Generic type TenantCloud\\GraphQLPlatform\\Connection\\Offset\\OffsetConnection<Tests\\Fixtures\\Valid\\Models\\User> in PHPDoc type for property Tests\\Fixtures\\Valid\\Models\\SelectionResponse::\$users does not specify all template types of interface TenantCloud\\GraphQLPlatform\\Connection\\Offset\\OffsetConnection: NodeType, EdgeType#'
# Vendor inherited without types
- '#Method TenantCloud\\GraphQLPlatform\\Validation\\LaravelCompositeTranslatorAdapter::trans\(\) has parameter \$parameters with no value type specified in iterable type array\.#i'
-
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('graphql_subscriptions', function (Blueprint $table) {
$table->uuid('id')->primary();

$table->boolean('active')
->default(true)
->index();
$table->string('channel')
->index();
$table->string('transport');
$table->string('schema_name');
$table->json('document');
$table->json('variables');
$table->text('resolve')->nullable();
$table->text('filter')->nullable();
$table->timestamp('expires_at')->nullable();

$table->timestamps();

$table->index(['active', 'channel']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::drop('graphql_subscriptions');
}
};
4 changes: 1 addition & 3 deletions src/Connection/Connectable.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,4 @@
* @template-extends CursorConnectable<NodeType, CursorConnectionEdgeType>
* @template-extends OffsetConnectable<NodeType, OffsetConnectionEdgeType>
*/
interface Connectable extends CursorConnectable, OffsetConnectable
{
}
interface Connectable extends CursorConnectable, OffsetConnectable {}
7 changes: 4 additions & 3 deletions src/Connection/ConnectionFieldMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use phpDocumentor\Reflection\DocBlock;
use phpDocumentor\Reflection\DocBlock\Tags\Return_;
use phpDocumentor\Reflection\DocBlock\Tags\Var_;
use phpDocumentor\Reflection\Type;
use phpDocumentor\Reflection\Types\Collection;
use phpDocumentor\Reflection\Types\Object_;
use ReflectionMethod;
Expand Down Expand Up @@ -179,7 +180,7 @@ private function mapOffsetConnectable(
});
}

private function getDocBlocReturnType(DocBlock $docBlock, ReflectionMethod $refMethod): \phpDocumentor\Reflection\Type|null
private function getDocBlocReturnType(DocBlock $docBlock, ReflectionMethod $refMethod): ?Type
{
/** @var array<int, Return_> $returnTypeTags */
$returnTypeTags = $docBlock->getTagsByName('return');
Expand All @@ -196,9 +197,9 @@ private function getDocBlocReturnType(DocBlock $docBlock, ReflectionMethod $refM
return $docBlockReturnType;
}

private function getDocBlockPropertyType(DocBlock $docBlock, ReflectionProperty $refProperty): \phpDocumentor\Reflection\Type|null
private function getDocBlockPropertyType(DocBlock $docBlock, ReflectionProperty $refProperty): ?Type
{
/** @var Var_[] $varTags */
/** @var list<Var_> $varTags */
$varTags = $docBlock->getTagsByName('var');

if (!$varTags) {
Expand Down
17 changes: 0 additions & 17 deletions src/Connection/ConnectionMissingParameterException.php

This file was deleted.

2 changes: 1 addition & 1 deletion src/Connection/ConnectionTypeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
use TenantCloud\GraphQLPlatform\Connection\Offset\OffsetConnectable;
use TenantCloud\GraphQLPlatform\Connection\Offset\OffsetConnection;
use TenantCloud\GraphQLPlatform\Connection\Offset\OffsetConnectionEdge;
use TenantCloud\GraphQLPlatform\Internal\PhpDocTypes;
use TenantCloud\GraphQLPlatform\Utility\PhpDocTypes;
use TheCodingMachine\GraphQLite\AnnotationReader;
use TheCodingMachine\GraphQLite\Mappers\Root\RootTypeMapperInterface;
use Webmozart\Assert\Assert;
Expand Down
4 changes: 2 additions & 2 deletions src/Connection/Cursor/CursorConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
interface CursorConnection
{
/**
* @return NodeType[]
* @return list<NodeType>
*/
public function nodes(): array;

/**
* @return EdgeType[]
* @return list<EdgeType>
*/
public function edges(): array;

Expand Down
4 changes: 2 additions & 2 deletions src/Connection/Offset/OffsetConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@
interface OffsetConnection
{
/**
* @return NodeType[]
* @return list<NodeType>
*/
public function nodes(): array;

/**
* @return EdgeType[]
* @return list<EdgeType>
*/
public function edges(): array;
}
85 changes: 85 additions & 0 deletions src/Context/Context.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

namespace TenantCloud\GraphQLPlatform\Context;

use SplObjectStorage;
use Tests\Unit\Context\ContextTest;
use TheCodingMachine\GraphQLite\Context\ContextInterface;
use TheCodingMachine\GraphQLite\Context\ResetableContextInterface;
use TheCodingMachine\GraphQLite\Parameters\ParameterInterface;
use TheCodingMachine\GraphQLite\PrefetchBuffer;

/**
* @see ContextTest
*/
final class Context implements ContextInterface, ResetableContextInterface
{
/** @var SplObjectStorage<object, mixed> */
private SplObjectStorage $data;

/** @var SplObjectStorage<ParameterInterface, PrefetchBuffer> */
private SplObjectStorage $prefetchBuffers;

public function __construct()
{
$this->data = new SplObjectStorage();
$this->prefetchBuffers = new SplObjectStorage();
}

/**
* @param ContextToken<*> $token
*/
public function has(ContextToken $token): bool
{
return isset($this->data[$token]);
}

/**
* @template T
*
* @param ContextToken<T> $token
*
* @return T
*/
public function get(ContextToken $token): mixed
{
if ($this->has($token)) {
return $this->data[$token];
}

$value = ($token->default)();

$this->set($token, $value);

return $value;
}

/**
* @template T
*
* @param ContextToken<T> $token
* @param T $value
*/
public function set(ContextToken $token, mixed $value): mixed
{
return $this->data[$token] = $value;
}

public function getPrefetchBuffer(ParameterInterface $field): PrefetchBuffer
{
if ($this->prefetchBuffers->offsetExists($field)) {
$prefetchBuffer = $this->prefetchBuffers->offsetGet($field);
} else {
$prefetchBuffer = new PrefetchBuffer();
$this->prefetchBuffers->offsetSet($field, $prefetchBuffer);
}

return $prefetchBuffer;
}

public function reset(): void
{
$this->data = new SplObjectStorage();
$this->prefetchBuffers = new SplObjectStorage();
}
}
18 changes: 18 additions & 0 deletions src/Context/ContextToken.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

namespace TenantCloud\GraphQLPlatform\Context;

use Closure;

/**
* @template-covariant T
*/
final class ContextToken
{
/**
* @param Closure(): T $default
*/
public function __construct(
public readonly Closure $default,
) {}
}
8 changes: 4 additions & 4 deletions src/Discovery/Composer/ComposerClassFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ public function __construct(
private readonly ClassLoader $classLoader,
private readonly FileFinder $fileFinder,
private readonly ReflectionFactory $reflectionFactory,
private readonly array|null $namespaces,
private readonly ?array $namespaces,
private array $pathFilters = [],
) {}

Expand All @@ -49,7 +49,7 @@ public function __construct(
* @param list<callable(string): bool> $pathFilters
*/
public static function default(
array|null $namespaces,
?array $namespaces,
array $pathFilters = []
): self {
static $loader, $fileFinder, $reflectionFactory;
Expand Down Expand Up @@ -104,7 +104,7 @@ public function hash(): string
return $this->hash ??= md5(implode(',', $this->namespaces ?? '__ALL__'));
}

private static function findClassLoader(): ClassLoader
public static function findClassLoader(): ClassLoader
{
foreach (spl_autoload_functions() as $autoloadFn) {
if (is_array($autoloadFn) && class_exists(DebugClassLoader::class) && $autoloadFn[0] instanceof DebugClassLoader) {
Expand Down Expand Up @@ -221,7 +221,7 @@ private function searchInPsrMap(): Generator
}

/**
* @param array<string, string[]|string> $prefixes
* @param array<string, list<string>|string> $prefixes
*
* @return Generator<string, string>
*/
Expand Down
Loading