Skip to content
Merged
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
47 changes: 47 additions & 0 deletions docs/message.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,53 @@ use Patchlevel\EventSourcing\Message\Message;
/** @var Message $message */
$message->header(ApplicationHeader::class);
```

## Missing headers

When a message is deserialized, every header name is resolved to its registered header class.
If a header name cannot be resolved, for example because the header class was removed or renamed,
the `DefaultHeadersSerializer` throws a `HeaderNameNotRegistered` exception by default.

In some cases you want to keep reading old messages that still contain such headers without losing
their data. For this you can configure which header names should be handled gracefully. Those headers
are collected into a single `MissingHeaders` object instead of crashing. The raw names and payloads are
preserved, so you could still access them.

```php
use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer;

$serializer = DefaultHeadersSerializer::createFromPaths(
['src/Header'],
['legacyApplication', 'legacyTenant'],
);
```

You can access the collected headers via the `MissingHeaders` object:

```php
use Patchlevel\EventSourcing\Message\MissingHeaders;

/** @var Message $message */
$missingHeaders = $message->header(MissingHeaders::class);
$missingHeaders->headers; // ['legacyApplication' => [...], 'legacyTenant' => [...]]
```

:::warning
Only the header names you list are handled gracefully. If a message contains an unregistered header
whose name is **not** in the list, deserialization still throws `HeaderNameNotRegistered`.
:::

If you want to handle every unregistered header gracefully, you can use the `*` wildcard:

```php
use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer;

$serializer = DefaultHeadersSerializer::createFromPaths(
['src/Header'],
['*'],
);
```

## Pipe

The `Pipe` is a construct that allows you to chain multiple translators.
Expand Down
19 changes: 19 additions & 0 deletions src/Message/MissingHeaders.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Message;

/**
* Collects all headers whose names could not be resolved to a registered class,
* e.g. because the header class was removed. It keeps the raw names and payloads
* so the data is preserved and can be written back without loss.
*/
final class MissingHeaders
{
/** @param array<string, array<mixed>> $headers */
public function __construct(
public readonly array $headers,
) {
}
}
47 changes: 41 additions & 6 deletions src/Message/Serializer/DefaultHeadersSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,30 @@

namespace Patchlevel\EventSourcing\Message\Serializer;

use Patchlevel\EventSourcing\Message\MissingHeaders;
use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory;
use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered;
use Patchlevel\EventSourcing\Metadata\Message\MessageHeaderRegistry;
use Patchlevel\EventSourcing\Serializer\Encoder\Encoder;
use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder;
use Patchlevel\Hydrator\Hydrator;
use Patchlevel\Hydrator\MetadataHydrator;

use function in_array;
use function is_array;

final class DefaultHeadersSerializer implements HeadersSerializer
{
private bool $handleAllHeadersGraceful;

/** @param list<string> $gracefulMissingHeaders */
public function __construct(
private readonly MessageHeaderRegistry $messageHeaderRegistry,
private readonly Hydrator $hydrator,
private readonly Encoder $encoder,
private readonly array $gracefulMissingHeaders = [],
) {
$this->handleAllHeadersGraceful = in_array('*', $this->gracefulMissingHeaders, true);
}

/**
Expand All @@ -30,6 +38,14 @@
{
$serializedHeaders = [];
foreach ($headers as $header) {
if ($header instanceof MissingHeaders) {
foreach ($header->headers as $name => $payload) {
$serializedHeaders[$name] = $payload;
}

continue;

Check warning on line 46 in src/Message/Serializer/DefaultHeadersSerializer.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Continue_": @@ @@ $serializedHeaders[$name] = $payload; } - continue; + break; } $serializedHeaders[$this->messageHeaderRegistry->headerName($header::class)] = $this->hydrator->extract($header);
}

$serializedHeaders[$this->messageHeaderRegistry->headerName($header::class)] = $this->hydrator->extract($header);
}

Expand All @@ -46,27 +62,45 @@
$serializedHeaders = $this->encoder->decode($string, $options);

$headers = [];
$missingHeaders = [];

foreach ($serializedHeaders as $headerName => $headerPayload) {
if (!is_array($headerPayload)) {
throw new InvalidArgument('header payload must be an array');
}

$headers[] = $this->hydrator->hydrate(
$this->messageHeaderRegistry->headerClass($headerName),
$headerPayload,
);
try {
$headers[] = $this->hydrator->hydrate(
$this->messageHeaderRegistry->headerClass($headerName),
$headerPayload,
);
} catch (HeaderNameNotRegistered $exception) {
if (!$this->handleAllHeadersGraceful && !in_array($headerName, $this->gracefulMissingHeaders, true)) {
throw $exception;
}

$missingHeaders[$headerName] = $headerPayload;
}
}

if ($missingHeaders !== []) {
$headers[] = new MissingHeaders($missingHeaders);
}

return $headers;
}

/** @param list<string> $paths */
public static function createFromPaths(array $paths): static
/**
* @param list<string> $paths
* @param list<string> $gracefulMissingHeaders
*/
public static function createFromPaths(array $paths, array $gracefulMissingHeaders = []): static
{
return new self(
(new AttributeMessageHeaderRegistryFactory())->create($paths),
new MetadataHydrator(),
new JsonEncoder(),
$gracefulMissingHeaders,
);
}

Expand All @@ -76,6 +110,7 @@
MessageHeaderRegistry::createWithInternalHeaders(),
new MetadataHydrator(),
new JsonEncoder(),
[],
);
}
}
93 changes: 93 additions & 0 deletions tests/Unit/Message/Serializer/DefaultHeadersSerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

use DateTimeImmutable;
use Patchlevel\EventSourcing\Aggregate\AggregateHeader;
use Patchlevel\EventSourcing\Message\MissingHeaders;
use Patchlevel\EventSourcing\Message\Serializer\DefaultHeadersSerializer;
use Patchlevel\EventSourcing\Metadata\Message\AttributeMessageHeaderRegistryFactory;
use Patchlevel\EventSourcing\Metadata\Message\HeaderNameNotRegistered;
use Patchlevel\EventSourcing\Serializer\Encoder\JsonEncoder;
use Patchlevel\EventSourcing\Store\ArchivedHeader;
use Patchlevel\EventSourcing\Store\Header\StreamNameHeader;
use Patchlevel\Hydrator\MetadataHydrator;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -54,4 +57,94 @@ public function testDeserialize(): void
$deserializedMessage,
);
}

public function testDeserializeUnknownHeadersAsMissingHeaders(): void
{
$serializer = new DefaultHeadersSerializer(
(new AttributeMessageHeaderRegistryFactory())->create([
__DIR__ . '/../../Fixture',
]),
new MetadataHydrator(),
new JsonEncoder(),
['removed', 'alsoRemoved'],
);

$deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}');

self::assertEquals(
[
new StreamNameHeader('profile-1'),
new MissingHeaders([
'removed' => ['foo' => 'bar'],
'alsoRemoved' => ['baz' => 1],
]),
],
$deserializedMessage,
);
}

public function testDeserializeUnknownHeaderNotConfiguredCrashes(): void
{
$serializer = new DefaultHeadersSerializer(
(new AttributeMessageHeaderRegistryFactory())->create([
__DIR__ . '/../../Fixture',
]),
new MetadataHydrator(),
new JsonEncoder(),
['removed'],
);

$this->expectException(HeaderNameNotRegistered::class);

$serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"notListed":{"baz":1}}');
}

public function testDeserializeWildcardHandlesAllUnknownHeaders(): void
{
$serializer = new DefaultHeadersSerializer(
(new AttributeMessageHeaderRegistryFactory())->create([
__DIR__ . '/../../Fixture',
]),
new MetadataHydrator(),
new JsonEncoder(),
['*'],
);

$deserializedMessage = $serializer->deserialize('{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}');

self::assertEquals(
[
new StreamNameHeader('profile-1'),
new MissingHeaders([
'removed' => ['foo' => 'bar'],
'alsoRemoved' => ['baz' => 1],
]),
],
$deserializedMessage,
);
}

public function testSerializeMissingHeadersRoundTrip(): void
{
$serializer = new DefaultHeadersSerializer(
(new AttributeMessageHeaderRegistryFactory())->create([
__DIR__ . '/../../Fixture',
]),
new MetadataHydrator(),
new JsonEncoder(),
);

$content = $serializer->serialize([
new StreamNameHeader('profile-1'),
new MissingHeaders([
'removed' => ['foo' => 'bar'],
'alsoRemoved' => ['baz' => 1],
]),
]);

self::assertEquals(
'{"streamName":{"streamName":"profile-1"},"removed":{"foo":"bar"},"alsoRemoved":{"baz":1}}',
$content,
);
}
}
Loading