Skip to content

Commit 3fa40de

Browse files
committed
Improve Mercure Discovery spec and enhance component features
- Add full Mercure Discovery specification support with rel="self" and optional link attributes (last-event-id, content-type, key-set) - Add SubscriptionUrlBuilder utility for building subscription URLs with topics - Enhance MercureComponent::discover() with discoverWithTopics config option and optional parameters
1 parent 5b28e2f commit 3fa40de

File tree

14 files changed

+1117
-94
lines changed

14 files changed

+1117
-94
lines changed

README.md

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,49 @@ fetch('/api/resource')
642642
});
643643
```
644644

645+
### Discovery with Topics and Attributes
646+
647+
The `discover()` method supports optional parameters that align with the Mercure specification:
648+
649+
```php
650+
// Add discovery header with optional link attributes
651+
$this->Mercure->discover(
652+
lastEventId: '123',
653+
contentType: 'application/json',
654+
keySet: 'https://example.com/.well-known/jwks.json'
655+
);
656+
```
657+
658+
**Include subscription topics in discovery:**
659+
660+
When you want the `rel="self"` Link header to include the topics the user is authorized for, enable `discoverWithTopics`:
661+
662+
```php
663+
// Option 1: Enable per-call
664+
$this->Mercure
665+
->authorize(['/books/123', '/notifications/*'])
666+
->discover(includeTopics: true);
667+
668+
// Option 2: Enable in component config
669+
$this->loadComponent('Mercure.Mercure', [
670+
'discoverWithTopics' => true
671+
]);
672+
673+
// Then in your action:
674+
$this->Mercure
675+
->authorize(['/books/123'])
676+
->discover(); // Automatically includes topics in rel="self"
677+
```
678+
679+
This generates both headers:
680+
681+
```
682+
Link: <https://hub.example.com/.well-known/mercure?topic=%2Fbooks%2F123>; rel="self"
683+
Link: <https://hub.example.com/.well-known/mercure>; rel="mercure"
684+
```
685+
686+
The `rel="self"` header provides a ready-to-use subscription URL that clients can connect to directly.
687+
645688
### Using Middleware
646689

647690
This is an alternative approach to add the discovery header automatically to all responses by using middleware:
@@ -863,8 +906,9 @@ public function initialize(): void
863906
{
864907
parent::initialize();
865908
$this->loadComponent('Mercure.Mercure', [
866-
'autoDiscover' => true, // Optional: auto-add discovery headers
867-
'defaultTopics' => [ // Optional: topics available in all views
909+
'autoDiscover' => true, // Optional: auto-add discovery headers
910+
'discoverWithTopics' => false, // Optional: include topics in rel="self"
911+
'defaultTopics' => [ // Optional: topics available in all views
868912
'/notifications',
869913
'/global/alerts'
870914
]
@@ -888,7 +932,7 @@ public function initialize(): void
888932
| `resetAdditionalClaims()` | `$this` | Reset accumulated JWT claims |
889933
| `authorize(array $subscribe = [], array $additionalClaims = [])` | `$this` | Set authorization cookie (merges with accumulated state, then resets) |
890934
| `clearAuthorization()` | `$this` | Clear authorization cookie |
891-
| `discover()` | `$this` | Add Mercure discovery Link header |
935+
| `discover(?bool $includeTopics, ?string $lastEventId, ?string $contentType, ?string $keySet)` | `$this` | Add Mercure discovery Link headers with optional attributes and topics |
892936
| `publish(Update $update)` | `bool` | Publish an update to the Mercure hub |
893937
| `publishJson(string\|array $topics, mixed $data, ...)` | `bool` | Publish JSON data (auto-encodes) |
894938
| `publishSimple(string\|array $topics, string $data, ...)` | `bool` | Publish simple string data (no encoding) |

src/Authorization.php

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -130,20 +130,64 @@ public static function getCookieName(): string
130130
}
131131

132132
/**
133-
* Add the Mercure discovery header to the response
133+
* Add the Mercure discovery headers to the response
134134
*
135-
* Adds a Link header with rel="mercure" to advertise the Mercure hub URL.
136-
* This allows clients to discover the hub endpoint automatically.
135+
* Adds Link headers for Mercure discovery according to the Mercure specification:
136+
* - rel="mercure": The hub URL for subscriptions (required)
137+
* - rel="self": The canonical topic URL for this resource (optional)
138+
*
139+
* The rel="mercure" link may include optional attributes:
140+
* - last-event-id: The last event ID for reconciliation
141+
* - content-type: Content type hint for updates (e.g., for partial updates)
142+
* - key-set: URL to JWK key set for encrypted updates
137143
*
138144
* Skips CORS preflight requests to prevent conflicts with CORS middleware.
139145
*
146+
* Example usage:
147+
* ```
148+
* // Basic discovery
149+
* $response = Authorization::addDiscoveryHeader($response);
150+
*
151+
* // With canonical topic URL
152+
* $response = Authorization::addDiscoveryHeader(
153+
* response: $response,
154+
* selfUrl: '/books/123'
155+
* );
156+
*
157+
* // With all parameters
158+
* $response = Authorization::addDiscoveryHeader(
159+
* response: $response,
160+
* selfUrl: '/books/123.jsonld',
161+
* lastEventId: 'urn:uuid:abc-123',
162+
* contentType: 'application/ld+json',
163+
* keySet: 'https://example.com/.well-known/jwks.json'
164+
* );
165+
* ```
166+
*
140167
* @param \Cake\Http\Response $response The response object to modify
141168
* @param \Cake\Http\ServerRequest|null $request Optional request to check for preflight
142-
* @return \Cake\Http\Response Modified response with discovery header
169+
* @param string|null $selfUrl Canonical topic URL for rel="self" link
170+
* @param string|null $lastEventId Last event ID for state reconciliation
171+
* @param string|null $contentType Content type of updates
172+
* @param string|null $keySet URL to JWK key set for encryption
173+
* @return \Cake\Http\Response Modified response with discovery headers
143174
* @throws \Mercure\Exception\MercureException
144175
*/
145-
public static function addDiscoveryHeader(Response $response, ?ServerRequest $request = null): Response
146-
{
147-
return self::create()->addDiscoveryHeader($response, $request);
176+
public static function addDiscoveryHeader(
177+
Response $response,
178+
?ServerRequest $request = null,
179+
?string $selfUrl = null,
180+
?string $lastEventId = null,
181+
?string $contentType = null,
182+
?string $keySet = null,
183+
): Response {
184+
return self::create()->addDiscoveryHeader(
185+
$response,
186+
$request,
187+
$selfUrl,
188+
$lastEventId,
189+
$contentType,
190+
$keySet,
191+
);
148192
}
149193
}

src/Controller/Component/MercureComponent.php

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
use Cake\Controller\Component;
77
use Cake\Event\EventInterface;
88
use Mercure\Authorization;
9+
use Mercure\Internal\ConfigurationHelper;
10+
use Mercure\Internal\SubscriptionUrlBuilder;
911
use Mercure\Publisher;
1012
use Mercure\Service\AuthorizationInterface;
1113
use Mercure\Service\PublisherInterface;
@@ -88,8 +90,9 @@
8890
* Configuration:
8991
* ```
9092
* $this->loadComponent('Mercure.Mercure', [
91-
* 'autoDiscover' => true, // Automatically add discovery headers
92-
* 'defaultTopics' => [ // Topics automatically available in all views
93+
* 'autoDiscover' => true, // Automatically add discovery headers
94+
* 'discoverWithTopics' => true, // Include subscribe topics in discovery rel="self" link
95+
* 'defaultTopics' => [ // Topics automatically available in all views
9396
* '/notifications',
9497
* '/global/alerts'
9598
* ]
@@ -111,6 +114,13 @@ class MercureComponent extends Component
111114
*/
112115
protected array $subscribe = [];
113116

117+
/**
118+
* Last authorized subscribe topics (preserved for discovery)
119+
*
120+
* @var array<string>
121+
*/
122+
protected array $lastAuthorizedTopics = [];
123+
114124
/**
115125
* Additional JWT claims accumulated via addSubscribe()
116126
*
@@ -125,6 +135,7 @@ class MercureComponent extends Component
125135
*/
126136
protected array $_defaultConfig = [
127137
'autoDiscover' => false,
138+
'discoverWithTopics' => false,
128139
'defaultTopics' => [],
129140
];
130141

@@ -335,6 +346,9 @@ public function authorize(array $subscribe = [], array $additionalClaims = []):
335346
$response = $this->authorizationService->setCookie($response, $allSubscribe, $allClaims);
336347
$this->getController()->setResponse($response);
337348

349+
// Store topics for potential use in discovery
350+
$this->lastAuthorizedTopics = $allSubscribe;
351+
338352
// Reset accumulated state after authorization
339353
$this->resetSubscribe();
340354
$this->resetAdditionalClaims();
@@ -366,27 +380,75 @@ public function clearAuthorization(): static
366380
}
367381

368382
/**
369-
* Add the Mercure discovery header to the response
383+
* Add Mercure discovery headers to the response
370384
*
371-
* Adds a Link header with rel="mercure" to advertise the Mercure hub URL.
372-
* This allows clients to automatically discover the hub endpoint.
385+
* Adds Link headers for Mercure hub discovery. Optionally includes
386+
* a rel="self" link with subscription topics from the subscribe array.
373387
*
374-
* Example:
388+
* Priority for includeTopics parameter:
389+
* 1. Explicit method parameter (true/false)
390+
* 2. Component config 'discoverWithTopics'
391+
* 3. Default: false
392+
*
393+
* Examples:
375394
* ```
395+
* // Basic discovery
376396
* $this->Mercure->discover();
377397
*
378-
* // Or chained with authorize
379-
* $this->Mercure->authorize(['/feeds/123'])->discover();
398+
* // With topics from subscribe array
399+
* $this->Mercure
400+
* ->addSubscribe('/books/123')
401+
* ->authorize()
402+
* ->discover(includeTopics: true);
403+
*
404+
* // With all discovery parameters
405+
* $this->Mercure->discover(
406+
* includeTopics: true,
407+
* lastEventId: 'urn:uuid:abc-123',
408+
* contentType: 'application/ld+json',
409+
* keySet: 'https://example.com/.well-known/jwks.json'
410+
* );
411+
*
412+
* // Use config value (discoverWithTopics)
413+
* $this->Mercure->discover(); // Uses config setting
380414
* ```
381415
*
416+
* @param bool|null $includeTopics Include subscribe topics in rel="self" link (null uses config)
417+
* @param string|null $lastEventId Last event ID for state reconciliation
418+
* @param string|null $contentType Content type hint for updates
419+
* @param string|null $keySet URL to JWK key set for encryption
382420
* @return $this For method chaining
383421
* @throws \Mercure\Exception\MercureException
384422
*/
385-
public function discover(): static
386-
{
423+
public function discover(
424+
?bool $includeTopics = null,
425+
?string $lastEventId = null,
426+
?string $contentType = null,
427+
?string $keySet = null,
428+
): static {
387429
$request = $this->getController()->getRequest();
388430
$response = $this->getController()->getResponse();
389-
$response = $this->authorizationService->addDiscoveryHeader($response, $request);
431+
432+
// Determine if we should include topics
433+
// Priority: method param > config > default (false)
434+
$shouldIncludeTopics = $includeTopics ?? $this->getConfig('discoverWithTopics', false);
435+
436+
// Build selfUrl if topics should be included and we have authorized topics
437+
$selfUrl = null;
438+
if ($shouldIncludeTopics && $this->lastAuthorizedTopics !== []) {
439+
$hubUrl = ConfigurationHelper::getPublicUrl();
440+
$selfUrl = SubscriptionUrlBuilder::build($hubUrl, $this->lastAuthorizedTopics);
441+
}
442+
443+
$response = $this->authorizationService->addDiscoveryHeader(
444+
$response,
445+
$request,
446+
$selfUrl,
447+
$lastEventId,
448+
$contentType,
449+
$keySet,
450+
);
451+
390452
$this->getController()->setResponse($response);
391453

392454
return $this;
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
namespace Mercure\Internal;
55

66
/**
7-
* Query Builder
7+
* Publish Query Builder
88
*
9-
* Builds URL-encoded query strings for Mercure hub requests.
9+
* Builds URL-encoded query strings for Mercure hub publish requests.
1010
*
1111
* @internal
1212
*/
13-
class QueryBuilder
13+
class PublishQueryBuilder
1414
{
1515
/**
1616
* Build a URL-encoded query string from data
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
declare(strict_types=1);
3+
4+
namespace Mercure\Internal;
5+
6+
/**
7+
* Subscription URL Builder
8+
*
9+
* Builds Mercure subscription URLs with topic query parameters.
10+
* This utility is used by both MercureHelper and MercureComponent
11+
* to generate consistent subscription URLs.
12+
*
13+
* @internal
14+
*/
15+
class SubscriptionUrlBuilder
16+
{
17+
/**
18+
* Build a subscription URL with topics and optional query parameters
19+
*
20+
* Generates a complete Mercure hub subscription URL with topic query parameters.
21+
* Topics can be specified multiple times in the query string as per Mercure spec.
22+
*
23+
* Example:
24+
* ```
25+
* $url = SubscriptionUrlBuilder::build(
26+
* 'https://hub.example.com/.well-known/mercure',
27+
* ['/books/123', '/notifications/*']
28+
* );
29+
* // Result: https://hub.example.com/.well-known/mercure?topic=%2Fbooks%2F123&topic=%2Fnotifications%2F*
30+
* ```
31+
*
32+
* @param string $hubUrl Base hub URL
33+
* @param array<string> $topics Topics to subscribe to
34+
* @param array<string, mixed> $options Additional query parameters
35+
* @return string Complete subscription URL
36+
*/
37+
public static function build(string $hubUrl, array $topics, array $options = []): string
38+
{
39+
if ($topics === [] && $options === []) {
40+
return $hubUrl;
41+
}
42+
43+
$params = [];
44+
45+
// Add topic parameters (can be specified multiple times)
46+
foreach ($topics as $topic) {
47+
$params[] = 'topic=' . urlencode($topic);
48+
}
49+
50+
// Add additional options
51+
foreach ($options as $key => $value) {
52+
if (is_array($value)) {
53+
foreach ($value as $item) {
54+
$params[] = urlencode($key) . '=' . urlencode((string)$item);
55+
}
56+
} else {
57+
$params[] = urlencode($key) . '=' . urlencode((string)$value);
58+
}
59+
}
60+
61+
if ($params === []) {
62+
return $hubUrl;
63+
}
64+
65+
$separator = str_contains($hubUrl, '?') ? '&' : '?';
66+
67+
return $hubUrl . $separator . implode('&', $params);
68+
}
69+
}

0 commit comments

Comments
 (0)