diff --git a/CHANGELOG.md b/CHANGELOG.md index 2420f29802..60158b2d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ You can find and compare releases at the [GitHub release page](https://github.co - Require implementations of `BatchedEntityResolver` to maintain the keys given in `array $representations` https://github.com/nuwave/lighthouse/pull/2286 - Use the strongest possible native types over PHPDocs - Require filter directives such as `@whereKey` in `@delete`, `@forceDelete` and `@restore` https://github.com/nuwave/lighthouse/pull/2289 +- Subscriptions can now be filtered via `$subscriber->socket_id` and `request()->header('X-Socket-ID')` https://github.com/nuwave/lighthouse/pull/2298 ### Fixed diff --git a/docs/master/subscriptions/filtering-subscriptions.md b/docs/master/subscriptions/filtering-subscriptions.md index fdccd33774..56f39ebbb3 100644 --- a/docs/master/subscriptions/filtering-subscriptions.md +++ b/docs/master/subscriptions/filtering-subscriptions.md @@ -40,3 +40,38 @@ class PostUpdatedSubscription extends GraphQLSubscription } } ``` + +## Only To Others + +When building an application that utilizes event broadcasting, you may occasionally need to broadcast an event to all subscribers of a channel except for the current user. +You may accomplish this using the filter function, this following snippet is equivalent to [the `toOthers()` method from Laravel's broadcast helper](https://laravel.com/docs/9.x/broadcasting#only-to-others). + +```php +namespace App\GraphQL\Subscriptions; + +use Nuwave\Lighthouse\Subscriptions\Subscriber; +use Nuwave\Lighthouse\Schema\Types\GraphQLSubscription; + +final class PostUpdatedSubscription extends GraphQLSubscription +{ + /** + * Filter which subscribers should receive the subscription. + */ + public function filter(Subscriber $subscriber, mixed $root): bool + { + // Filter out the sender + return $subscriber->socket_id !== request()->header('X-Socket-ID'); + } +} +``` + +When you initialize a Laravel Echo instance, a socket ID is assigned to the connection. +If you are using a global [Axios](https://github.com/mzabriskie/axios) instance to make HTTP requests from your JavaScript application, the socket ID will automatically be attached to every outgoing request in the `X-Socket-ID` header. +Then, you can access that in your filter function. + +If you are not using a global Axios instance, you will need to manually configure your JavaScript application to send the `X-Socket-ID` header with all outgoing requests. +You may retrieve the socket ID using the `Echo.socketId()` method: + +```js +const socketId = Echo.socketId(); +``` diff --git a/src/Subscriptions/Subscriber.php b/src/Subscriptions/Subscriber.php index 8de314817f..651eb5b8dc 100644 --- a/src/Subscriptions/Subscriber.php +++ b/src/Subscriptions/Subscriber.php @@ -24,6 +24,11 @@ class Subscriber */ public $channel; + /** + * X-Socket-ID header passed on the subscription query. + */ + public ?string $socket_id; + /** * The topic subscribed to. * @@ -91,6 +96,13 @@ public function __construct( $this->variables = $resolveInfo->variableValues; $this->context = $context; + $xSocketID = request()->header('X-Socket-ID'); + // @phpstan-ignore-next-line + if (is_array($xSocketID)) { + throw new \Exception('X-Socket-ID must be a string or null.'); + } + $this->socket_id = $xSocketID; + $operation = $resolveInfo->operation; assert($operation instanceof OperationDefinitionNode, 'Must be here, since webonyx/graphql-php validated the subscription.'); @@ -108,6 +120,7 @@ public function __construct( public function __serialize(): array { return [ + 'socket_id' => $this->socket_id, 'channel' => $this->channel, 'topic' => $this->topic, 'query' => serialize( @@ -133,6 +146,7 @@ public function __unserialize(array $data): void ); assert($documentNode instanceof DocumentNode, 'We know the type since it is set during construction and serialized.'); + $this->socket_id = $data['socket_id']; $this->query = $documentNode; $this->fieldName = $data['field_name']; $this->args = $data['args']; diff --git a/tests/Integration/Subscriptions/Storage/RedisStorageManagerTest.php b/tests/Integration/Subscriptions/Storage/RedisStorageManagerTest.php index 06846fd613..c18e3b5d1f 100644 --- a/tests/Integration/Subscriptions/Storage/RedisStorageManagerTest.php +++ b/tests/Integration/Subscriptions/Storage/RedisStorageManagerTest.php @@ -77,8 +77,8 @@ public function testDeleteSubscriber(): void public function testSubscribersByTopic(): void { - /** @var RedisStorageManager $storage */ $storage = $this->app->make(RedisStorageManager::class); + assert($storage instanceof RedisStorageManager); $this->querySubscription(); $this->querySubscription(); @@ -97,10 +97,26 @@ public function testSubscribersByTopic(): void $this->assertContainsOnlyInstancesOf(Subscriber::class, $createdSubscribers); } + public function testSocketIDStoredOnSubscribe(): void + { + $storage = $this->app->make(RedisStorageManager::class); + assert($storage instanceof RedisStorageManager); + + $this->querySubscription('taskCreated', [ + 'X-Socket-ID' => '1234.1234', + ]); + + $createdSubscriber = $storage->subscribersByTopic('TASK_CREATED')->first(); + + $this->assertSame('1234.1234', $createdSubscriber->socket_id); + } + /** + * @param array $headers + * * @return \Illuminate\Testing\TestResponse */ - protected function querySubscription(string $topic = /** @lang GraphQL */ 'taskUpdated(id: 123)') + protected function querySubscription(string $topic = /** @lang GraphQL */ 'taskUpdated(id: 123)', array $headers = []) { return $this->graphQL(/** @lang GraphQL */ " subscription { @@ -109,6 +125,6 @@ protected function querySubscription(string $topic = /** @lang GraphQL */ 'taskU name } } - "); + ", [], [], $headers); } }