Skip to content
Open
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
185 changes: 164 additions & 21 deletions core/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -804,17 +804,22 @@ to allow a client to receive pushed realtime data from the server.
In API Platform, the built-in subscription support is handled by using
[Mercure](https://mercure.rocks/) as its underlying protocol.

### Enable Update Subscriptions for a Resource
### Enable Subscriptions for a Resource

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

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

For instance, your resource should look like this:
API Platform provides two GraphQL subscription operation types:

- `Subscription`: subscribes to updates for a specific item.
- `SubscriptionCollection`: subscribes to future events affecting a collection.

For instance, your resource could expose both an item subscription and a collection subscription:

<code-selector>

Expand All @@ -826,10 +831,12 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GraphQl\Mutation;
use ApiPlatform\Metadata\GraphQl\Subscription;
use ApiPlatform\Metadata\GraphQl\SubscriptionCollection;

#[ApiResource(mercure: true, graphQlOperations: [
new Mutation(name: 'update'),
new Subscription()
new Subscription(name: 'update'),
new SubscriptionCollection(name: 'update_collection'),
])]
class Book
{
Expand All @@ -844,7 +851,10 @@ resources:
graphQlOperations:
ApiPlatform\Metadata\GraphQl\Mutation:
name: update
ApiPlatform\Metadata\GraphQl\Subscription: ~
ApiPlatform\Metadata\GraphQl\Subscription:
name: update
ApiPlatform\Metadata\GraphQl\SubscriptionCollection:
name: update_collection
```

```xml
Expand All @@ -856,29 +866,28 @@ resources:
<resource class="App\Entity\Book">
<graphQlOperations>
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\Mutation" name="update" />
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\Subscription" />
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\Subscription" name="update" />
<graphQlOperation class="ApiPlatform\Metadata\GraphQl\SubscriptionCollection" name="update_collection" />
</graphQlOperations>
</resource>
</resources>
```

</code-selector>

### Subscribe
### Subscribe to an Item

Doing a subscription is very similar to doing a query:
An item subscription is very similar to doing a query:

```graphql
{
subscription {
updateBookSubscribe(input: { id: "/books/1", clientSubscriptionId: "myId" }) {
book {
title
isbn
}
mercureUrl
clientSubscriptionId
subscription {
updateBookSubscribe(input: { id: "/books/1", clientSubscriptionId: "myId" }) {
book {
title
isbn
}
mercureUrl
clientSubscriptionId
}
}
```
Expand All @@ -889,18 +898,75 @@ As you can see, you need to pass the **IRI** of the resource as argument. See
You can also pass `clientSubscriptionId` as argument and can ask its value as a field.

In the payload of the subscription, the given fields of the resource will be the fields you
subscribe to: if any of these fields is updated, you will be pushed their updated values.
subscribe to.

An item subscription receives events for that specific item:

- `update` events for the item
- `delete` events for the item

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

### Receiving an Update
The initial registration response contains the current item payload together with the Mercure
metadata:

```json
{
"book": {
"title": "API Platform in Action",
"isbn": "978-6-6344-4051-1"
},
"mercureUrl": "https://localhost/.well-known/mercure",
"clientSubscriptionId": "myId"
}
```

### Subscribe to a Collection

A collection subscription registers interest in future events affecting a collection:

```graphql
subscription {
update_collectionBookSubscribe(input: { id: "/books" }) {
book {
title
isbn
}
mercureUrl
clientSubscriptionId
}
}
```

The initial registration response contains the Mercure metadata, but no item payload yet because no
specific item event has happened:

```json
{
"book": null,
"mercureUrl": "https://localhost/.well-known/mercure",
"clientSubscriptionId": null
}
```

Collection subscriptions receive events affecting items in the collection:

- `create` events for new items in the collection
- `update` events for existing items in the collection
- `delete` events for deleted items from the collection

For `create` and `update`, the pushed payload contains the affected item with the fields requested
in the subscription query.

### Receiving Updates

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

For instance, you could receive a JSON payload like this:
For item subscriptions receiving `update` events, and collection subscriptions receiving `create` or
`update` events, you could receive a JSON payload like this:

```json
{
Expand All @@ -911,6 +977,83 @@ For instance, you could receive a JSON payload like this:
}
```

When a subscribed item is deleted, or when a collection subscription receives a `delete` event, API
Platform sends a lightweight payload instead of a regular normalized resource. By that point, the
deleted object can no longer be normalized safely like a regular resource. This payload contains the
information needed by the client to identify and evict the deleted item.

The delete payload shape is the same for item subscriptions and collection subscriptions. Even
though an item subscription could infer the deleted item from the subscribed IRI, collection
subscriptions cannot. Using one explicit delete event shape keeps client handling uniform.

For example, a delete event payload looks like this:

```json
{
"type": "delete",
"payload": {
"id": "/books/1",
"iri": "https://example.com/books/1",
"type": "Book"
}
}
```

### Private Subscription Delivery

GraphQL subscriptions use Mercure for transport. The `mercure` option controls how the subscription
is delivered:

- no `private` option: the subscription is public
- `mercure: ['private' => true]`: the subscription is private and requires Mercure authorization
- `mercure: ['private' => true, 'private_fields' => [...]]`: the subscription is private and
additionally partitioned by explicit fields on the resource

The `private_fields` option is specific to GraphQL subscriptions. It tells API Platform which
resource fields define the delivery scope of the subscription registration.

Concretely, API Platform:

- reads the configured field values from the resource when the subscription is registered
- builds a private delivery partition from those values
- stores subscriptions having the same partition together
- only publishes updates to subscriptions matching that same partition later

`private_fields` does not change Mercure topics and it is not based on the authenticated user ID. It
is an application-defined partition key derived from resource data.

When a client subscribes, API Platform reads the values of these fields from the resource and uses
them to group subscriptions together. Two subscriptions sharing the same GraphQL field selection but
different `private_fields` values are stored separately and do not receive each other's updates.

This is useful when authorization alone is not precise enough for your application. For example, a
single authenticated user might belong to several organizations, accounts or workspaces, and you may
want subscriptions to be isolated by one of those scopes instead of by the user identity itself.

```php
new Subscription(
name: 'update',
mercure: ['private' => true, 'private_fields' => ['organizationId']]
)
```

In this example:

- clients subscribed to resources with `organizationId = 12` share one private subscription
partition
- clients subscribed to resources with `organizationId = 42` use another
- updates for one organization are not delivered to subscriptions registered for the other one

This is especially useful when Mercure authorization is necessary but not sufficient to describe the
actual business scope of the subscription. For example, a single authenticated client may be allowed
to access multiple organizations, projects, inboxes or workspaces, while each subscription should
still be isolated to only one of those scopes.

Use `private_fields` only together with `mercure.private = true`.

If you only need Mercure authorization and do not need this extra partitioning, use
`mercure: ['private' => true]` without `private_fields`.

### Subscriptions Cache

Internally, API Platform stores the subscriptions in a cache, using the
Expand Down
6 changes: 6 additions & 0 deletions core/mercure.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ In addition to `private`, the following options are available:
- `retry`: the `retry` field of the SSE, if not set this field is omitted
- `normalization_context`: the specific normalization context to use for the update.

> [!NOTE] GraphQL subscriptions can also use `private_fields` together with `mercure.private` to
> partition private subscription registrations by explicit resource fields. This partition is
> derived from resource field values, not from Mercure topics or from the authenticated user
> identity. This is specific to GraphQL subscriptions and is documented in
> [GraphQL subscriptions](graphql.md#private-subscription-delivery).

## Dispatching Restrictive Updates (Security Mode)

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