Skip to content

Commit 3886138

Browse files
committed
docs(graphql): document subscription event semantics
1 parent 8dc8c8d commit 3886138

File tree

2 files changed

+175
-26
lines changed

2 files changed

+175
-26
lines changed

core/graphql.md

Lines changed: 168 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -804,17 +804,22 @@ to allow a client to receive pushed realtime data from the server.
804804
In API Platform, the built-in subscription support is handled by using
805805
[Mercure](https://mercure.rocks/) as its underlying protocol.
806806

807-
### Enable Update Subscriptions for a Resource
807+
### Enable Subscriptions for a Resource
808808

809-
To enable update subscriptions for a resource, these conditions have to be met:
809+
To enable GraphQL subscriptions for a resource, these conditions have to be met:
810810

811811
- the
812812
[Mercure hub and bundle need to be installed and configured](mercure.md#installing-mercure-support).
813813
- Mercure needs to be enabled for the resource.
814814
- the `update` mutation needs to be enabled for the resource.
815-
- the subscription needs to be enabled for the resource.
815+
- at least one subscription operation needs to be enabled for the resource.
816816

817-
For instance, your resource should look like this:
817+
API Platform provides two GraphQL subscription operation types:
818+
819+
- `Subscription`: subscribes to updates for a specific item.
820+
- `SubscriptionCollection`: subscribes to future events affecting a collection.
821+
822+
For instance, your resource could expose both an item subscription and a collection subscription:
818823

819824
<code-selector>
820825

@@ -826,10 +831,12 @@ namespace App\Entity;
826831
use ApiPlatform\Metadata\ApiResource;
827832
use ApiPlatform\Metadata\GraphQl\Mutation;
828833
use ApiPlatform\Metadata\GraphQl\Subscription;
834+
use ApiPlatform\Metadata\GraphQl\SubscriptionCollection;
829835
830836
#[ApiResource(mercure: true, graphQlOperations: [
831837
new Mutation(name: 'update'),
832-
new Subscription()
838+
new Subscription(name: 'update'),
839+
new SubscriptionCollection(name: 'update_collection'),
833840
])]
834841
class Book
835842
{
@@ -844,7 +851,10 @@ resources:
844851
graphQlOperations:
845852
ApiPlatform\Metadata\GraphQl\Mutation:
846853
name: update
847-
ApiPlatform\Metadata\GraphQl\Subscription: ~
854+
ApiPlatform\Metadata\GraphQl\Subscription:
855+
name: update
856+
ApiPlatform\Metadata\GraphQl\SubscriptionCollection:
857+
name: update_collection
848858
```
849859

850860
```xml
@@ -856,30 +866,29 @@ resources:
856866
<resource class="App\Entity\Book">
857867
<graphQlOperations>
858868
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\Mutation" name="update" />
859-
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\Subscription" />
869+
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\Subscription" name="update" />
870+
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\SubscriptionCollection" name="update_collection" />
860871
</graphQlOperations>
861872
</resource>
862873
</resources>
863874
```
864875

865876
</code-selector>
866877

867-
### Subscribe
878+
### Subscribe to an Item
868879

869-
Doing a subscription is very similar to doing a query:
880+
An item subscription is very similar to doing a query:
870881

871882
```graphql
872-
{
873-
subscription {
874-
updateBookSubscribe(input: { id: "/books/1", clientSubscriptionId: "myId" }) {
875-
book {
876-
title
877-
isbn
878-
}
879-
mercureUrl
880-
clientSubscriptionId
881-
}
883+
subscription {
884+
updateBookSubscribe(input: {id: "/books/1", clientSubscriptionId: "myId"}) {
885+
book {
886+
title
887+
isbn
882888
}
889+
mercureUrl
890+
clientSubscriptionId
891+
}
883892
}
884893
```
885894

@@ -889,28 +898,161 @@ As you can see, you need to pass the **IRI** of the resource as argument. See
889898
You can also pass `clientSubscriptionId` as argument and can ask its value as a field.
890899

891900
In the payload of the subscription, the given fields of the resource will be the fields you
892-
subscribe to: if any of these fields is updated, you will be pushed their updated values.
901+
subscribe to.
902+
903+
An item subscription receives events for that specific item:
904+
905+
- `update` events for the item
906+
- `delete` events for the item
893907

894908
The `mercureUrl` field is the Mercure URL you need to use to
895909
[subscribe to the updates](https://mercure.rocks/docs/getting-started#subscribing) on the
896910
client-side.
897911

898-
### Receiving an Update
912+
The initial registration response contains the current item payload together with the Mercure
913+
metadata:
914+
915+
```json
916+
{
917+
"book": {
918+
"title": "API Platform in Action",
919+
"isbn": "978-6-6344-4051-1"
920+
},
921+
"mercureUrl": "https://localhost/.well-known/mercure",
922+
"clientSubscriptionId": "myId"
923+
}
924+
```
925+
926+
### Subscribe to a Collection
927+
928+
A collection subscription registers interest in future events affecting a collection:
929+
930+
```graphql
931+
subscription {
932+
update_collectionBookSubscribe(input: {id: "/books"}) {
933+
book {
934+
title
935+
isbn
936+
}
937+
mercureUrl
938+
clientSubscriptionId
939+
}
940+
}
941+
```
942+
943+
The initial registration response contains the Mercure metadata, but no item payload yet because no
944+
specific item event has happened:
945+
946+
```json
947+
{
948+
"book": null,
949+
"mercureUrl": "https://localhost/.well-known/mercure",
950+
"clientSubscriptionId": null
951+
}
952+
```
953+
954+
Collection subscriptions receive events affecting items in the collection:
955+
956+
- `create` events for new items in the collection
957+
- `update` events for existing items in the collection
958+
- `delete` events for deleted items from the collection
959+
960+
For `create` and `update`, the pushed payload contains the affected item with the fields requested
961+
in the subscription query.
962+
963+
### Receiving Updates
899964

900965
On the client-side, you will receive the pushed updated data like you would receive the updated data
901966
if you did an `update` mutation.
902967

903-
For instance, you could receive a JSON payload like this:
968+
For item subscriptions receiving `update` events, and collection subscriptions receiving `create`
969+
or `update` events, you could receive a JSON payload like this:
904970

905971
```json
906972
{
907-
"book": {
908-
"title": "Updated title",
909-
"isbn": "978-6-6344-4051-1"
910-
}
973+
"book": {
974+
"title": "Updated title",
975+
"isbn": "978-6-6344-4051-1"
976+
}
977+
}
978+
```
979+
980+
When a subscribed item is deleted, or when a collection subscription receives a `delete` event,
981+
API Platform sends a lightweight payload instead of a regular normalized resource. By that point,
982+
the deleted object can no longer be normalized safely like a regular resource. This payload
983+
contains the information needed by the client to identify and evict the deleted item.
984+
985+
The delete payload shape is the same for item subscriptions and collection subscriptions. Even
986+
though an item subscription could infer the deleted item from the subscribed IRI, collection
987+
subscriptions cannot. Using one explicit delete event shape keeps client handling uniform.
988+
989+
For example, a delete event payload looks like this:
990+
991+
```json
992+
{
993+
"type": "delete",
994+
"payload": {
995+
"id": "/books/1",
996+
"iri": "https://example.com/books/1",
997+
"type": "Book"
998+
}
911999
}
9121000
```
9131001

1002+
### Private Subscription Delivery
1003+
1004+
GraphQL subscriptions use Mercure for transport. The `mercure` option controls how the subscription
1005+
is delivered:
1006+
1007+
- no `private` option: the subscription is public
1008+
- `mercure: ['private' => true]`: the subscription is private and requires Mercure authorization
1009+
- `mercure: ['private' => true, 'private_fields' => [...]]`: the subscription is private and
1010+
additionally partitioned by explicit fields on the resource
1011+
1012+
The `private_fields` option is specific to GraphQL subscriptions. It tells API Platform which
1013+
resource fields define the delivery scope of the subscription registration.
1014+
1015+
Concretely, API Platform:
1016+
1017+
- reads the configured field values from the resource when the subscription is registered
1018+
- builds a private delivery partition from those values
1019+
- stores subscriptions having the same partition together
1020+
- only publishes updates to subscriptions matching that same partition later
1021+
1022+
`private_fields` does not change Mercure topics and it is not based on the authenticated user ID.
1023+
It is an application-defined partition key derived from resource data.
1024+
1025+
When a client subscribes, API Platform reads the values of these fields from the resource and uses
1026+
them to group subscriptions together. Two subscriptions sharing the same GraphQL field selection but
1027+
different `private_fields` values are stored separately and do not receive each other's updates.
1028+
1029+
This is useful when authorization alone is not precise enough for your application. For example, a
1030+
single authenticated user might belong to several organizations, accounts or workspaces, and you may
1031+
want subscriptions to be isolated by one of those scopes instead of by the user identity itself.
1032+
1033+
```php
1034+
new Subscription(
1035+
name: 'update',
1036+
mercure: ['private' => true, 'private_fields' => ['organizationId']]
1037+
)
1038+
```
1039+
1040+
In this example:
1041+
1042+
- clients subscribed to resources with `organizationId = 12` share one private subscription partition
1043+
- clients subscribed to resources with `organizationId = 42` use another
1044+
- updates for one organization are not delivered to subscriptions registered for the other one
1045+
1046+
This is especially useful when Mercure authorization is necessary but not sufficient to describe the
1047+
actual business scope of the subscription. For example, a single authenticated client may be allowed
1048+
to access multiple organizations, projects, inboxes or workspaces, while each subscription should
1049+
still be isolated to only one of those scopes.
1050+
1051+
Use `private_fields` only together with `mercure.private = true`.
1052+
1053+
If you only need Mercure authorization and do not need this extra partitioning, use
1054+
`mercure: ['private' => true]` without `private_fields`.
1055+
9141056
### Subscriptions Cache
9151057

9161058
Internally, API Platform stores the subscriptions in a cache, using the

core/mercure.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,13 @@ In addition to `private`, the following options are available:
122122
- `retry`: the `retry` field of the SSE, if not set this field is omitted
123123
- `normalization_context`: the specific normalization context to use for the update.
124124

125+
> [!NOTE]
126+
> GraphQL subscriptions can also use `private_fields` together with `mercure.private` to partition
127+
> private subscription registrations by explicit resource fields. This partition is derived from
128+
> resource field values, not from Mercure topics or from the authenticated user identity. This is
129+
> specific to GraphQL subscriptions and is documented in
130+
> [GraphQL subscriptions](graphql.md#private-subscription-delivery).
131+
125132
## Dispatching Restrictive Updates (Security Mode)
126133

127134
Use `iri` (iriConverter) and `escape` (rawurlencode) functions to add an alternative topic, in order

0 commit comments

Comments
 (0)