Skip to content

Commit 584f17b

Browse files
authored
Merge pull request #867 from patchlevel/dont-crash-if-header-missing
Add `MissingHeaders` if no header found
2 parents 9b9b482 + 65e7232 commit 584f17b

4 files changed

Lines changed: 200 additions & 6 deletions

File tree

docs/message.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,53 @@ use Patchlevel\EventSourcing\Message\Message;
9797
/** @var Message $message */
9898
$message->header(ApplicationHeader::class);
9999
```
100+
101+
## Missing headers
102+
103+
When a message is deserialized, every header name is resolved to its registered header class.
104+
If a header name cannot be resolved, for example because the header class was removed or renamed,
105+
the `DefaultHeadersSerializer` throws a `HeaderNameNotRegistered` exception by default.
106+
107+
In some cases you want to keep reading old messages that still contain such headers without losing
108+
their data. For this you can configure which header names should be handled gracefully. Those headers
109+
are collected into a single `MissingHeaders` object instead of crashing. The raw names and payloads are
110+
preserved, so you could still access them.
111+
112+
```php
113+
use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer;
114+
115+
$serializer = DefaultHeadersSerializer::createFromPaths(
116+
['src/Header'],
117+
['legacyApplication', 'legacyTenant'],
118+
);
119+
```
120+
121+
You can access the collected headers via the `MissingHeaders` object:
122+
123+
```php
124+
use Patchlevel\EventSourcing\Message\MissingHeaders;
125+
126+
/** @var Message $message */
127+
$missingHeaders = $message->header(MissingHeaders::class);
128+
$missingHeaders->headers; // ['legacyApplication' => [...], 'legacyTenant' => [...]]
129+
```
130+
131+
:::warning
132+
Only the header names you list are handled gracefully. If a message contains an unregistered header
133+
whose name is **not** in the list, deserialization still throws `HeaderNameNotRegistered`.
134+
:::
135+
136+
If you want to handle every unregistered header gracefully, you can use the `*` wildcard:
137+
138+
```php
139+
use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer;
140+
141+
$serializer = DefaultHeadersSerializer::createFromPaths(
142+
['src/Header'],
143+
['*'],
144+
);
145+
```
146+
100147
## Pipe
101148

102149
The `Pipe` is a construct that allows you to chain multiple translators.

src/Message/MissingHeaders.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Message;
6+
7+
/**
8+
* Collects all headers whose names could not be resolved to a registered class,
9+
* e.g. because the header class was removed. It keeps the raw names and payloads
10+
* so the data is preserved and can be written back without loss.
11+
*/
12+
final class MissingHeaders
13+
{
14+
/** @param array<string, array<mixed>> $headers */
15+
public function __construct(
16+
public readonly array $headers,
17+
) {
18+
}
19+
}

src/Message/Serializer/DefaultHeadersSerializer.php

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,30 @@
44

55
namespace Patchlevel\EventSourcing\Message\Serializer;
66

7+
use Patchlevel\EventSourcing\Message\MissingHeaders;
78
use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory;
9+
use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered;
810
use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry;
911
use Patchlevel\EventSourcing\Serializer\Encoder\Encoder;
1012
use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder;
1113
use Patchlevel\Hydrator\Hydrator;
1214
use Patchlevel\Hydrator\MetadataHydrator;
1315

16+
use function in_array;
1417
use function is_array;
1518

1619
final class DefaultHeadersSerializer implements HeadersSerializer
1720
{
21+
private bool $handleAllHeadersGraceful;
22+
23+
/** @param list<string> $gracefulMissingHeaders */
1824
public function __construct(
1925
private readonly MessageHeaderRegistry $messageHeaderRegistry,
2026
private readonly Hydrator $hydrator,
2127
private readonly Encoder $encoder,
28+
private readonly array $gracefulMissingHeaders = [],
2229
) {
30+
$this->handleAllHeadersGraceful = in_array('*', $this->gracefulMissingHeaders, true);
2331
}
2432

2533
/**
@@ -30,6 +38,14 @@ public function serialize(array $headers, array $options = []): string
3038
{
3139
$serializedHeaders = [];
3240
foreach ($headers as $header) {
41+
if ($header instanceof MissingHeaders) {
42+
foreach ($header->headers as $name => $payload) {
43+
$serializedHeaders[$name] = $payload;
44+
}
45+
46+
continue;
47+
}
48+
3349
$serializedHeaders[$this->messageHeaderRegistry->headerName($header::class)] = $this->hydrator->extract($header);
3450
}
3551

@@ -46,27 +62,45 @@ public function deserialize(string $string, array $options = []): array
4662
$serializedHeaders = $this->encoder->decode($string, $options);
4763

4864
$headers = [];
65+
$missingHeaders = [];
66+
4967
foreach ($serializedHeaders as $headerName => $headerPayload) {
5068
if (!is_array($headerPayload)) {
5169
throw new InvalidArgument('header payload must be an array');
5270
}
5371

54-
$headers[] = $this->hydrator->hydrate(
55-
$this->messageHeaderRegistry->headerClass($headerName),
56-
$headerPayload,
57-
);
72+
try {
73+
$headers[] = $this->hydrator->hydrate(
74+
$this->messageHeaderRegistry->headerClass($headerName),
75+
$headerPayload,
76+
);
77+
} catch (HeaderNameNotRegistered $exception) {
78+
if (!$this->handleAllHeadersGraceful && !in_array($headerName, $this->gracefulMissingHeaders, true)) {
79+
throw $exception;
80+
}
81+
82+
$missingHeaders[$headerName] = $headerPayload;
83+
}
84+
}
85+
86+
if ($missingHeaders !== []) {
87+
$headers[] = new MissingHeaders($missingHeaders);
5888
}
5989

6090
return $headers;
6191
}
6292

63-
/** @param list<string> $paths */
64-
public static function createFromPaths(array $paths): static
93+
/**
94+
* @param list<string> $paths
95+
* @param list<string> $gracefulMissingHeaders
96+
*/
97+
public static function createFromPaths(array $paths, array $gracefulMissingHeaders = []): static
6598
{
6699
return new self(
67100
(new AttributeMessageHeaderRegistryFactory())->create($paths),
68101
new MetadataHydrator(),
69102
new JsonEncoder(),
103+
$gracefulMissingHeaders,
70104
);
71105
}
72106

@@ -76,6 +110,7 @@ public static function createDefault(): static
76110
MessageHeaderRegistry::createWithInternalHeaders(),
77111
new MetadataHydrator(),
78112
new JsonEncoder(),
113+
[],
79114
);
80115
}
81116
}

tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66

77
use DateTimeImmutable;
88
use Patchlevel\EventSourcing\Aggregate\AggregateHeader;
9+
use Patchlevel\EventSourcing\Message\MissingHeaders;
910
use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer;
1011
use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory;
12+
use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered;
1113
use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder;
1214
use Patchlevel\EventSourcing\Store\ArchivedHeader;
15+
use Patchlevel\EventSourcing\Store\Header\StreamNameHeader;
1316
use Patchlevel\Hydrator\MetadataHydrator;
1417
use PHPUnit\Framework\Attributes\CoversClass;
1518
use PHPUnit\Framework\TestCase;
@@ -54,4 +57,94 @@ public function testDeserialize(): void
5457
$deserializedMessage,
5558
);
5659
}
60+
61+
public function testDeserializeUnknownHeadersAsMissingHeaders(): void
62+
{
63+
$serializer = new DefaultHeadersSerializer(
64+
(new AttributeMessageHeaderRegistryFactory())->create([
65+
__DIR__ . '/../../Fixture',
66+
]),
67+
new MetadataHydrator(),
68+
new JsonEncoder(),
69+
['removed', 'alsoRemoved'],
70+
);
71+
72+
$deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}');
73+
74+
self::assertEquals(
75+
[
76+
new StreamNameHeader('profile-1'),
77+
new MissingHeaders([
78+
'removed' => ['foo' => 'bar'],
79+
'alsoRemoved' => ['baz' => 1],
80+
]),
81+
],
82+
$deserializedMessage,
83+
);
84+
}
85+
86+
public function testDeserializeUnknownHeaderNotConfiguredCrashes(): void
87+
{
88+
$serializer = new DefaultHeadersSerializer(
89+
(new AttributeMessageHeaderRegistryFactory())->create([
90+
__DIR__ . '/../../Fixture',
91+
]),
92+
new MetadataHydrator(),
93+
new JsonEncoder(),
94+
['removed'],
95+
);
96+
97+
$this->expectException(HeaderNameNotRegistered::class);
98+
99+
$serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"notListed":{"baz":1}}');
100+
}
101+
102+
public function testDeserializeWildcardHandlesAllUnknownHeaders(): void
103+
{
104+
$serializer = new DefaultHeadersSerializer(
105+
(new AttributeMessageHeaderRegistryFactory())->create([
106+
__DIR__ . '/../../Fixture',
107+
]),
108+
new MetadataHydrator(),
109+
new JsonEncoder(),
110+
['*'],
111+
);
112+
113+
$deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}');
114+
115+
self::assertEquals(
116+
[
117+
new StreamNameHeader('profile-1'),
118+
new MissingHeaders([
119+
'removed' => ['foo' => 'bar'],
120+
'alsoRemoved' => ['baz' => 1],
121+
]),
122+
],
123+
$deserializedMessage,
124+
);
125+
}
126+
127+
public function testSerializeMissingHeadersRoundTrip(): void
128+
{
129+
$serializer = new DefaultHeadersSerializer(
130+
(new AttributeMessageHeaderRegistryFactory())->create([
131+
__DIR__ . '/../../Fixture',
132+
]),
133+
new MetadataHydrator(),
134+
new JsonEncoder(),
135+
);
136+
137+
$content = $serializer->serialize([
138+
new StreamNameHeader('profile-1'),
139+
new MissingHeaders([
140+
'removed' => ['foo' => 'bar'],
141+
'alsoRemoved' => ['baz' => 1],
142+
]),
143+
]);
144+
145+
self::assertEquals(
146+
'{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}',
147+
$content,
148+
);
149+
}
57150
}

0 commit comments

Comments
 (0)