├── .github
├── FUNDING.yml
└── workflows
│ ├── static.yml
│ └── ci.yml
├── phpstan.neon.dist
├── phpstan-baseline.neon
├── src
├── Hydrator
│ ├── Exception
│ │ ├── HydratorNotFoundException.php
│ │ ├── HydratorException.php
│ │ ├── ConvertToMessageFailedException.php
│ │ └── VersionNotSupportedException.php
│ ├── ArrayToMessageInterface.php
│ ├── HydratorInterface.php
│ └── Hydrator.php
├── Transformer
│ ├── Exception
│ │ ├── TransformerNotFoundException.php
│ │ ├── TransformerException.php
│ │ └── ConvertToArrayFailedException.php
│ ├── MessageToArrayInterface.php
│ ├── TransformerInterface.php
│ └── Transformer.php
├── SerializerRouter.php
└── Serializer.php
├── .php-cs-fixer.php
├── phpunit.xml.dist
├── composer.json
├── LICENSE
├── CHANGELOG.md
├── tests
├── Hydrator
│ └── HydratorTest.php
├── Transformer
│ └── TransformerTest.php
├── SerializerTest.php
└── SerializerRouterTest.php
└── Readme.md
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [Nyholm]
4 |
--------------------------------------------------------------------------------
/phpstan.neon.dist:
--------------------------------------------------------------------------------
1 | includes:
2 | - phpstan-baseline.neon
3 |
4 | parameters:
5 | level: 5
6 | reportUnmatchedIgnoredErrors: false
7 | paths:
8 | - src
9 |
--------------------------------------------------------------------------------
/phpstan-baseline.neon:
--------------------------------------------------------------------------------
1 | parameters:
2 | ignoreErrors:
3 | -
4 | message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#'
5 | identifier: function.alreadyNarrowedType
6 | count: 1
7 | path: src/Transformer/Transformer.php
8 |
9 |
--------------------------------------------------------------------------------
/src/Hydrator/Exception/HydratorNotFoundException.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | final class HydratorNotFoundException extends \RuntimeException implements HydratorException
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/.php-cs-fixer.php:
--------------------------------------------------------------------------------
1 | in(__DIR__.'/src')
5 | ->in(__DIR__.'/tests')
6 | ;
7 |
8 | $config = new PhpCsFixer\Config();
9 | return $config
10 | ->setRules([
11 | '@Symfony' => true,
12 | 'array_syntax' => ['syntax' => 'short'],
13 | ])
14 | ->setFinder($finder)
15 | ;
--------------------------------------------------------------------------------
/src/Transformer/Exception/TransformerNotFoundException.php:
--------------------------------------------------------------------------------
1 |
9 | */
10 | final class TransformerNotFoundException extends \RuntimeException implements TransformerException
11 | {
12 | }
13 |
--------------------------------------------------------------------------------
/src/Hydrator/Exception/HydratorException.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | interface HydratorException extends \Throwable
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Transformer/Exception/TransformerException.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | interface TransformerException extends \Throwable
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Hydrator/ArrayToMessageInterface.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | final class ConvertToMessageFailedException extends \RuntimeException implements HydratorException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Hydrator/Exception/VersionNotSupportedException.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | final class VersionNotSupportedException extends \RuntimeException implements HydratorException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Transformer/Exception/ConvertToArrayFailedException.php:
--------------------------------------------------------------------------------
1 |
11 | */
12 | final class ConvertToArrayFailedException extends \RuntimeException implements TransformerException
13 | {
14 | }
15 |
--------------------------------------------------------------------------------
/src/Transformer/MessageToArrayInterface.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | ./tests
21 |
22 |
23 |
24 |
25 |
26 | ./
27 |
28 | vendor
29 | tests
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "happyr/message-serializer",
3 | "description": "Serialize classes the good way. ",
4 | "type": "library",
5 | "license": "MIT",
6 | "authors": [
7 | {
8 | "name": "Nyholm",
9 | "email": "tobias.nyholm@gmail.com"
10 | }
11 | ],
12 | "require": {
13 | "php": "^7.3 || ^8.0",
14 | "ext-json": "*",
15 | "psr/log": "^1.0 || ^2.0 || ^3.0"
16 | },
17 | "require-dev": {
18 | "symfony/messenger": "^4.4 || ^5.4 || ^6.0",
19 | "symfony/phpunit-bridge": "^4.4 || ^5.4 || ^6.0"
20 | },
21 | "suggest": {
22 | "symfony/messenger": "if you want to use a Symfony messenger integration"
23 | },
24 | "autoload": {
25 | "psr-4": {
26 | "Happyr\\MessageSerializer\\": "src/"
27 | }
28 | },
29 | "autoload-dev": {
30 | "psr-4": {
31 | "Tests\\Happyr\\MessageSerializer\\": "tests/"
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Tobias Nyholm
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Change log
2 |
3 | ## 0.5.2
4 |
5 | - Added support for Symfony 6
6 | - Make sure `Hydrator` does not return null
7 | - Added LoggerInterface as optional third constructor argument for `Serializer`
8 |
9 | ## 0.5.1
10 |
11 | ### Added
12 |
13 | - Support for PHP8
14 |
15 | ## 0.5.0
16 |
17 | ### Added
18 |
19 | - Better exception message on `TransformerNotFoundException`
20 |
21 | ### Removed
22 |
23 | - Support Symfony Messenger 4.3
24 |
25 | ## 0.4.3
26 |
27 | ### Added
28 |
29 | - Support messenger retry strategy.
30 |
31 | ## 0.4.2
32 |
33 | ### Added
34 |
35 | - Properly handle Json decode errors.
36 |
37 | ### Removed
38 |
39 | - Support for PHP 7.2
40 |
41 | ## 0.4.1
42 |
43 | ### Added
44 |
45 | - `SerializerRouter` to choose the correct serializer.
46 |
47 | ## 0.4.0
48 |
49 | Allow `HydratorInterface::supportsHydrate` to throw `VersionNotSupportedException` when they support the message but not version.
50 |
51 | ### Added
52 |
53 | - `VersionNotSupportedException`
54 | - `ConvertToMessageFailedException`
55 | - `ConvertToArrayFailedException`
56 | - `HydratorException` interface
57 | - `TransformerException` interface
58 |
59 | ### Removed
60 |
61 | - `HydratorException`
62 | - `TransformerException`
63 |
--------------------------------------------------------------------------------
/tests/Hydrator/HydratorTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(HydratorInterface::class)
19 | ->setMethods(['toMessage', 'supportsHydrate'])
20 | ->getMock();
21 |
22 | $version = 2;
23 | $identifier = 'foobar';
24 | $payload = ['foo' => 'bar'];
25 | $time = \time();
26 | $data = [
27 | 'version' => $version,
28 | 'identifier' => $identifier,
29 | 'payload' => $payload,
30 | 'timestamp' => $time,
31 | ];
32 |
33 | $fooHydrator->expects(self::once())
34 | ->method('supportsHydrate')
35 | ->with($identifier, $version)
36 | ->willReturn(true);
37 |
38 | $fooHydrator->expects(self::once())
39 | ->method('toMessage')
40 | ->with($payload, $version)
41 | ->willReturn(new \stdClass());
42 |
43 | $hydrator = new Hydrator([$fooHydrator]);
44 | $output = $hydrator->toMessage($data);
45 |
46 | self::assertInstanceOf(\stdClass::class, $output);
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/Transformer/Transformer.php:
--------------------------------------------------------------------------------
1 | transformers = $transformers;
21 | }
22 |
23 | /**
24 | * @throws TransformerNotFoundException
25 | * @throws ConvertToArrayFailedException
26 | */
27 | public function toArray($message): array
28 | {
29 | foreach ($this->transformers as $transformer) {
30 | if (!$transformer->supportsTransform($message)) {
31 | continue;
32 | }
33 |
34 | try {
35 | return [
36 | 'version' => $transformer->getVersion(),
37 | 'identifier' => $transformer->getIdentifier(),
38 | 'timestamp' => time(),
39 | 'payload' => $transformer->getPayload($message),
40 | ];
41 | } catch (\Throwable $throwable) {
42 | throw new ConvertToArrayFailedException(sprintf('Transformer "%s" failed to transform a message.', get_class($transformer)), 0, $throwable);
43 | }
44 | }
45 |
46 | if ($message instanceof Envelope) {
47 | $type = sprintf('Envelope<%s>', get_class($message->getMessage()));
48 | } else {
49 | $type = is_object($message) ? get_class($message) : gettype($message);
50 | }
51 |
52 | throw new TransformerNotFoundException(sprintf('No transformer found for "%s"', $type));
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on: [push, pull_request]
3 |
4 | jobs:
5 |
6 | build:
7 | name: Build
8 | runs-on: ubuntu-latest
9 | strategy:
10 | max-parallel: 10
11 | matrix:
12 | include:
13 | - php-version: '7.3'
14 | symfony-version: '4.4.*'
15 | - php-version: '7.4'
16 | symfony-version: '5.4.*'
17 | - php-version: '8.0'
18 | symfony-version: '5.4.*'
19 | - php-version: '8.0'
20 | symfony-version: '6.0.*'
21 |
22 | steps:
23 | - name: Set up PHP
24 | uses: shivammathur/setup-php@2.16.0
25 | with:
26 | php-version: ${{ matrix.php-version }}
27 | coverage: pcov
28 | tools: flex
29 |
30 | - name: Checkout code
31 | uses: actions/checkout@v2
32 |
33 | - name: Download dependencies
34 | env:
35 | SYMFONY_REQUIRE: ${{ matrix.symfony-version }}
36 | run: |
37 | composer update --no-interaction --prefer-dist --optimize-autoloader --prefer-stable
38 |
39 | - name: Run tests
40 | run: ./vendor/bin/simple-phpunit
41 |
42 | lowest:
43 | name: Lowest deps
44 | runs-on: ubuntu-latest
45 | steps:
46 | - name: Set up PHP
47 | uses: shivammathur/setup-php@2.16.0
48 | with:
49 | php-version: 7.3
50 | coverage: pcov
51 |
52 | - name: Checkout code
53 | uses: actions/checkout@v2
54 |
55 | - name: Download dependencies
56 | run: |
57 | composer update --no-interaction --prefer-dist --optimize-autoloader --prefer-stable --prefer-lowest
58 |
59 | - name: Run tests
60 | env:
61 | SYMFONY_DEPRECATIONS_HELPER: "max[self]=0"
62 | run: |
63 | ./vendor/bin/simple-phpunit -v --coverage-text --coverage-clover=coverage.xml
64 | wget https://scrutinizer-ci.com/ocular.phar
65 | php ocular.phar code-coverage:upload --format=php-clover coverage.xml
--------------------------------------------------------------------------------
/src/Hydrator/Hydrator.php:
--------------------------------------------------------------------------------
1 | hydrators = $hydrators;
21 | }
22 |
23 | /**
24 | * @throws ConvertToMessageFailedException
25 | * @throws VersionNotSupportedException
26 | * @throws HydratorNotFoundException
27 | */
28 | public function toMessage(array $data)
29 | {
30 | // Default exception to be thrown.
31 | $exception = new HydratorNotFoundException();
32 |
33 | foreach ($this->hydrators as $hydrator) {
34 | try {
35 | $isSupported = $hydrator->supportsHydrate($data['identifier'] ?? '', $data['version'] ?? 0);
36 | } catch (VersionNotSupportedException $e) {
37 | $exception = $e;
38 | continue;
39 | }
40 |
41 | if (!$isSupported) {
42 | continue;
43 | }
44 |
45 | try {
46 | /** @var object|null $object */
47 | $object = $hydrator->toMessage($data['payload'] ?? [], $data['version'] ?? 0);
48 | } catch (\Throwable $throwable) {
49 | throw new ConvertToMessageFailedException(sprintf('Hydrator "%s" failed to hydrate string into object.', get_class($hydrator)), 0, $throwable);
50 | }
51 |
52 | if (null === $object) {
53 | throw new ConvertToMessageFailedException(sprintf('Hydrator "%s" failed to hydrate string into object, null returned.', get_class($hydrator)));
54 | }
55 |
56 | return $object;
57 | }
58 |
59 | throw $exception;
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/SerializerRouter.php:
--------------------------------------------------------------------------------
1 |
14 | */
15 | final class SerializerRouter implements SerializerInterface
16 | {
17 | private $happyrSerializer;
18 | private $symfonySerializer;
19 |
20 | public function __construct(SerializerInterface $happyrSerializer, SerializerInterface $symfonySerializer)
21 | {
22 | $this->happyrSerializer = $happyrSerializer;
23 | $this->symfonySerializer = $symfonySerializer;
24 | }
25 |
26 | public function decode(array $encodedEnvelope): Envelope
27 | {
28 | if (empty($encodedEnvelope['body'])) {
29 | throw new MessageDecodingFailedException('Encoded envelope should have at least a "body".');
30 | }
31 |
32 | try {
33 | $envelopeBodyArray = \json_decode($encodedEnvelope['body'], true, 512, JSON_THROW_ON_ERROR);
34 | } catch (\JsonException $exception) {
35 | return $this->symfonySerializer->decode($encodedEnvelope);
36 | }
37 |
38 | if (
39 | array_key_exists('version', $envelopeBodyArray)
40 | && array_key_exists('identifier', $envelopeBodyArray)
41 | && array_key_exists('timestamp', $envelopeBodyArray)
42 | && array_key_exists('payload', $envelopeBodyArray)
43 | ) {
44 | return $this->happyrSerializer->decode($encodedEnvelope);
45 | }
46 |
47 | return $this->symfonySerializer->decode($encodedEnvelope);
48 | }
49 |
50 | public function encode(Envelope $envelope): array
51 | {
52 | try {
53 | return $this->happyrSerializer->encode($envelope);
54 | } catch (TransformerNotFoundException $e) {
55 | return $this->symfonySerializer->encode($envelope);
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/tests/Transformer/TransformerTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(TransformerInterface::class)
18 | ->setMethods(['getVersion', 'getIdentifier', 'getPayload', 'supportsTransform'])
19 | ->getMock();
20 |
21 | $version = 2;
22 | $identifier = 'foobar';
23 | $payload = ['foo' => 'bar'];
24 |
25 | $fooTransformer->method('getVersion')->willReturn($version);
26 | $fooTransformer->method('getIdentifier')->willReturn($identifier);
27 | $fooTransformer->method('supportsTransform')->willReturn(true);
28 | $fooTransformer->method('getPayload')->willReturn($payload);
29 |
30 | $transformer = new Transformer([$fooTransformer]);
31 | $output = $transformer->toArray(new \stdClass());
32 |
33 | $this->assertArrayHasKey('version', $output);
34 | $this->assertArrayHasKey('identifier', $output);
35 | $this->assertArrayHasKey('timestamp', $output);
36 | $this->assertArrayHasKey('payload', $output);
37 |
38 | $this->assertEquals($output['version'], $version);
39 | $this->assertEquals($output['identifier'], $identifier);
40 | $this->assertEquals($output['payload'], $payload);
41 | $this->assertEqualsWithDelta($output['timestamp'], time(), 3);
42 | }
43 |
44 | public function testTransformerNotFoundExceptionClass()
45 | {
46 | $transformer = new Transformer([]);
47 | $this->expectException(TransformerNotFoundException::class);
48 | $this->expectExceptionMessage('No transformer found for "stdClass"');
49 | $output = $transformer->toArray(new \stdClass());
50 | }
51 |
52 | public function testTransformerNotFoundExceptionInteger()
53 | {
54 | $transformer = new Transformer([]);
55 | $this->expectException(TransformerNotFoundException::class);
56 | $this->expectExceptionMessage('No transformer found for "integer"');
57 | $output = $transformer->toArray(4711);
58 | }
59 |
60 | public function testTransformerNotFoundExceptionEnvelope()
61 | {
62 | $transformer = new Transformer([]);
63 | $this->expectException(TransformerNotFoundException::class);
64 | $this->expectExceptionMessage('No transformer found for "Envelope"');
65 | $output = $transformer->toArray(new Envelope(new \stdClass()));
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/Serializer.php:
--------------------------------------------------------------------------------
1 | transformer = $transformer;
27 | $this->hydrator = $hydrator;
28 | $this->logger = $logger ?? new NullLogger();
29 | }
30 |
31 | public function decode(array $encodedEnvelope): Envelope
32 | {
33 | if (empty($encodedEnvelope['body'])) {
34 | $this->logger->error('Failed to decode message with no body.');
35 | throw new MessageDecodingFailedException('Encoded envelope should have at least a "body".');
36 | }
37 |
38 | try {
39 | $array = \json_decode($encodedEnvelope['body'], true, 512, \JSON_THROW_ON_ERROR);
40 | } catch (\JsonException $e) {
41 | $this->logger->error('Failed to run json_decode on message.', ['exception' => $e]);
42 | throw new MessageDecodingFailedException(\sprintf('Error when trying to json_decode message: "%s"', $encodedEnvelope['body']), 0, $e);
43 | }
44 |
45 | $meta = $array['_meta'] ?? [];
46 | unset($array['_meta']);
47 |
48 | try {
49 | $message = $this->hydrator->toMessage($array);
50 | $envelope = $message instanceof Envelope ? $message : new Envelope($message);
51 | } catch (HydratorException $e) {
52 | $this->logger->error('Failed to run hydrate message to object.', ['exception' => $e, 'identifier' => $array['identifier'] ?? '(no identifier)', 'version' => $array['version'] ?? '(no version)']);
53 | throw new MessageDecodingFailedException('Failed to decode message', 0, $e);
54 | }
55 |
56 | return $this->addMetaToEnvelope($meta, $envelope);
57 | }
58 |
59 | public function encode(Envelope $envelope): array
60 | {
61 | $envelope = $envelope->withoutStampsOfType(NonSendableStampInterface::class);
62 |
63 | $message = $this->transformer->toArray($envelope);
64 | $message['_meta'] = $this->getMetaFromEnvelope($envelope);
65 |
66 | return [
67 | 'headers' => ['Content-Type' => 'application/json'],
68 | 'body' => \json_encode($message),
69 | ];
70 | }
71 |
72 | private function getMetaFromEnvelope(Envelope $envelope): array
73 | {
74 | $meta = [];
75 |
76 | $redeliveryStamp = $envelope->last(RedeliveryStamp::class);
77 | if ($redeliveryStamp instanceof RedeliveryStamp) {
78 | $meta['retry-count'] = $redeliveryStamp->getRetryCount();
79 | }
80 |
81 | return $meta;
82 | }
83 |
84 | private function addMetaToEnvelope(array $meta, Envelope $envelope): Envelope
85 | {
86 | if (isset($meta['retry-count'])) {
87 | $envelope = $envelope->with(new RedeliveryStamp((int) $meta['retry-count']));
88 | }
89 |
90 | return $envelope;
91 | }
92 | }
93 |
--------------------------------------------------------------------------------
/tests/SerializerTest.php:
--------------------------------------------------------------------------------
1 | getMockBuilder(MessageToArrayInterface::class)->getMock();
22 | $hydrator = $this->getMockBuilder(ArrayToMessageInterface::class)
23 | ->setMethods(['toMessage'])
24 | ->getMock();
25 |
26 | $payload = ['a' => 'b'];
27 | $data = [
28 | 'body' => \json_encode($payload),
29 | ];
30 |
31 | $hydrator->expects(self::once())
32 | ->method('toMessage')
33 | ->with($payload)
34 | ->willReturn(new \stdClass());
35 |
36 | $serializer = new Serializer($transformer, $hydrator);
37 | $output = $serializer->decode($data);
38 |
39 | self::assertInstanceOf(Envelope::class, $output);
40 | self::assertInstanceOf(\stdClass::class, $output->getMessage());
41 | }
42 |
43 | public function testDecodeWithRetryCount(): void
44 | {
45 | $transformer = $this->getMockBuilder(MessageToArrayInterface::class)->getMock();
46 | $hydrator = $this->getMockBuilder(ArrayToMessageInterface::class)
47 | ->getMock();
48 |
49 | $payload = [
50 | 'a' => 'b',
51 | ];
52 | $data = [
53 | 'body' => \json_encode(
54 | array_merge($payload, ['_meta' => ['retry-count' => 2]]),
55 | JSON_THROW_ON_ERROR, 512),
56 | ];
57 |
58 | $hydrator->expects(self::once())
59 | ->method('toMessage')
60 | ->with($payload)
61 | ->willReturn(new \stdClass());
62 |
63 | $serializer = new Serializer($transformer, $hydrator);
64 | $output = $serializer->decode($data);
65 |
66 | self::assertInstanceOf(\stdClass::class, $output->getMessage());
67 | /** @var RedeliveryStamp $redeliveryStamp */
68 | $redeliveryStamp = $output->last(RedeliveryStamp::class);
69 | self::assertEquals(2, $redeliveryStamp->getRetryCount());
70 | }
71 |
72 | public function testEncode()
73 | {
74 | $transformer = $this->getMockBuilder(MessageToArrayInterface::class)
75 | ->setMethods(['toArray'])
76 | ->getMock();
77 | $hydrator = $this->getMockBuilder(ArrayToMessageInterface::class)->getMock();
78 |
79 | $envelope = new Envelope(new \stdClass('foo'));
80 |
81 | $transformer->expects(self::once())
82 | ->method('toArray')
83 | ->with($envelope)
84 | ->willReturn(['foo' => 'bar']);
85 |
86 | $serializer = new Serializer($transformer, $hydrator);
87 | $output = $serializer->encode($envelope);
88 |
89 | self::assertArrayHasKey('headers', $output);
90 | self::assertArrayHasKey('Content-Type', $output['headers']);
91 | self::assertEquals('application/json', $output['headers']['Content-Type']);
92 |
93 | self::assertArrayHasKey('body', $output);
94 | self::assertEquals(\json_encode(['foo' => 'bar', '_meta' => []]), $output['body']);
95 | }
96 |
97 | public function testEncodeWithRedeliveryStamp()
98 | {
99 | $transformer = $this->getMockBuilder(MessageToArrayInterface::class)
100 | ->getMock();
101 | $hydrator = $this->getMockBuilder(ArrayToMessageInterface::class)->getMock();
102 |
103 | $envelope = new Envelope(new \stdClass('foo'), [new RedeliveryStamp(2)]);
104 |
105 | $transformer->expects(self::once())
106 | ->method('toArray')
107 | ->with($envelope)
108 | ->willReturn(['foo' => 'bar']);
109 |
110 | $serializer = new Serializer($transformer, $hydrator);
111 | $output = $serializer->encode($envelope);
112 |
113 | self::assertArrayHasKey('headers', $output);
114 | self::assertArrayHasKey('Content-Type', $output['headers']);
115 | self::assertEquals('application/json', $output['headers']['Content-Type']);
116 |
117 | self::assertArrayHasKey('body', $output);
118 | self::assertEquals(\json_encode(
119 | [
120 | 'foo' => 'bar',
121 | '_meta' => [
122 | 'retry-count' => 2,
123 | ],
124 | ], JSON_THROW_ON_ERROR, 512),
125 | $output['body']
126 | );
127 | }
128 | }
129 |
--------------------------------------------------------------------------------
/tests/SerializerRouterTest.php:
--------------------------------------------------------------------------------
1 | happyrSerializerMock = $this->getMockBuilder(SerializerInterface::class)
36 | ->disableOriginalConstructor()
37 | ->getMock();
38 | $this->symfonySerializerMock = $this->getMockBuilder(SerializerInterface::class)
39 | ->disableOriginalConstructor()
40 | ->getMock();
41 | $this->serializer = new SerializerRouter(
42 | $this->happyrSerializerMock,
43 | $this->symfonySerializerMock
44 | );
45 | }
46 |
47 | public function testDecodeThrowsExceptionIfNoBody(): void
48 | {
49 | $this->expectException(MessageDecodingFailedException::class);
50 |
51 | $this->serializer->decode([]);
52 | }
53 |
54 | public function testDecodeCallsSymfonySerializerIfEnvelopeBodyNotJson(): void
55 | {
56 | $envelope = [
57 | 'body' => serialize(new \stdClass()),
58 | ];
59 | $this->symfonySerializerMock->expects(self::once())
60 | ->method('decode')
61 | ->with($envelope)
62 | ->willReturn(new Envelope(new \stdClass()));
63 | $this->happyrSerializerMock->expects(self::exactly(0))
64 | ->method('decode')
65 | ->willReturn(new Envelope(new \stdClass()));
66 |
67 | $this->serializer->decode($envelope);
68 | }
69 |
70 | public function testDecodeCallHappyrSerializerForJsonWithHappyrSerializerStructure(): void
71 | {
72 | $envelope = [
73 | 'body' => json_encode([
74 | 'identifier' => 'some-identifier',
75 | 'version' => 1,
76 | 'timestamp' => time(),
77 | 'payload' => [
78 | 'message' => 'Some message',
79 | ],
80 | ]),
81 | ];
82 | $this->happyrSerializerMock->expects(self::once())
83 | ->method('decode')
84 | ->with($envelope)
85 | ->willReturn(new Envelope(new \stdClass()));
86 | $this->symfonySerializerMock->expects(self::exactly(0))
87 | ->method('decode')
88 | ->willReturn(new Envelope(new \stdClass()));
89 |
90 | $this->serializer->decode($envelope);
91 | }
92 |
93 | /**
94 | * @dataProvider getNonHappyrSerializerEncodedEnvelope
95 | */
96 | public function testDecodeCallsSymfonySerializerForJsonWithDifferentStructure(array $encodedEnvelope): void
97 | {
98 | $this->symfonySerializerMock->expects(self::once())
99 | ->method('decode')
100 | ->with($encodedEnvelope)
101 | ->willReturn(new Envelope(new \stdClass()));
102 | $this->happyrSerializerMock->expects(self::exactly(0))
103 | ->method('decode')
104 | ->willReturn(new Envelope(new \stdClass()));
105 |
106 | $this->serializer->decode($encodedEnvelope);
107 | }
108 |
109 | public function getNonHappyrSerializerEncodedEnvelope(): iterable
110 | {
111 | // missing identifier
112 | yield [[
113 | 'body' => json_encode([
114 | 'version' => 1,
115 | 'timestamp' => time(),
116 | 'payload' => [
117 | 'message' => 'Some message',
118 | ],
119 | ]),
120 | ]];
121 | // missing version
122 | yield [[
123 | 'body' => json_encode([
124 | 'identifier' => 'some-identifier',
125 | 'timestamp' => time(),
126 | 'payload' => [
127 | 'message' => 'Some message',
128 | ],
129 | ]),
130 | ]];
131 | // missing timestamp
132 | yield [[
133 | 'body' => json_encode([
134 | 'identifier' => 'some-identifier',
135 | 'version' => 1,
136 | 'payload' => [
137 | 'message' => 'Some message',
138 | ],
139 | ]),
140 | ]];
141 | // missing payload
142 | yield [[
143 | 'body' => json_encode([
144 | 'identifier' => 'some-identifier',
145 | 'version' => 1,
146 | 'timestamp' => time(),
147 | ]),
148 | ]];
149 | // missing all
150 | yield [[
151 | 'body' => json_encode([
152 | 'some-key' => 'some-value',
153 | ]),
154 | ]];
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # Message serializer
2 |
3 | [](https://github.com/Happyr/message-serializer/releases)
4 | [](LICENSE)
5 | [](https://scrutinizer-ci.com/g/Happyr/message-serializer)
6 | [](https://scrutinizer-ci.com/g/Happyr/message-serializer)
7 | [](https://packagist.org/packages/happyr/message-serializer)
8 |
9 | This package contains some interfaces and classes to help you serialize and deserialize
10 | a PHP class to an array. The package does not do any magic for you but rather help you
11 | to define your serialization rules yourself.
12 |
13 | ## Install
14 |
15 | ```
16 | composer require happyr/message-serializer
17 | ```
18 |
19 | See integration with [Symfony Messenger](#integration-with-symfony-messenger).
20 |
21 | ## The Problem
22 |
23 | When you serialize a PHP class to show the output for a different user or application there
24 | is one thing you should really keep in mind. That output is part of a public contract
25 | that you cannot change without possibly breaking other applications.
26 |
27 | Consider this example:
28 |
29 | ```php
30 | class Foo {
31 | private $bar;
32 |
33 | public function getBar()
34 | {
35 | return $this->bar;
36 | }
37 |
38 | public function setBar($bar)
39 | {
40 | $this->bar = $bar;
41 | }
42 | }
43 |
44 | $x = new Foo();
45 | $x->setBar('test string');
46 |
47 | $output = serialize($x);
48 | echo $output;
49 | ```
50 |
51 | This will output:
52 | ```
53 | O:3:"Foo":1:{s:8:"Foobar";s:11:"test string";}
54 | ```
55 |
56 | Even if you doing something smart with `json_encode` you will get:
57 |
58 | ```json
59 | {"bar":"test string"}
60 | ```
61 |
62 | This might seem fine at first. But if you change the `Foo` class slightly, say,
63 | rename the private property or add another property, then your output will differ
64 | and you have broken your contract with your users.
65 |
66 | ## The solution
67 |
68 | To avoid this problem we need to separate the class from the plain representation.
69 | The way we do that is to use a `Transformer` to take a class and produce an array.
70 |
71 | ```php
72 | use Happyr\MessageSerializer\Transformer\TransformerInterface;
73 |
74 | class FooTransformer implements TransformerInterface
75 | {
76 | public function getVersion(): int
77 | {
78 | return 1;
79 | }
80 |
81 | public function getIdentifier(): string
82 | {
83 | return 'foo';
84 | }
85 |
86 | public function getPayload($message): array
87 | {
88 | return [
89 | 'bar' => $message->getBar(),
90 | ];
91 | }
92 |
93 | public function supportsTransform($message): bool
94 | {
95 | return $message instanceof Foo;
96 | }
97 | }
98 | ```
99 |
100 | This transformer is only responsible to convert a `Foo` class to an array. The
101 | reverse operation is handled by a `Hydrator`:
102 |
103 | ```php
104 | use Happyr\MessageSerializer\Hydrator\HydratorInterface;
105 |
106 | class FooHydrator implements HydratorInterface
107 | {
108 | public function toMessage(array $payload, int $version)
109 | {
110 | $object = new Foo();
111 | $object->setBar($payload['bar']);
112 |
113 | return $object;
114 | }
115 |
116 | public function supportsHydrate(string $identifier, int $version): bool
117 | {
118 | return $identifier === 'foo' && $version === 1;
119 | }
120 | }
121 | ```
122 |
123 | With transformers and hydrators you are sure to never accidentally change the output
124 | to the user.
125 |
126 | The text representation of `Foo` when using the `Transformer` above will look like:
127 |
128 | ```json
129 | {
130 | "version": 1,
131 | "identifier": "foo",
132 | "timestamp": 1566491957,
133 | "payload": {
134 | "bar": "test string"
135 | },
136 | "_meta": []
137 | }
138 | ```
139 |
140 | ### Manage versions
141 |
142 | If you need to change the output you may do so with help of the version property.
143 | As an example, say you want to rename the key `bar` to something differently. Then
144 | you create a new `Hydrator` like:
145 |
146 | ```php
147 | use Happyr\MessageSerializer\Hydrator\HydratorInterface;
148 |
149 | class FooHydrator2 implements HydratorInterface
150 | {
151 | public function toMessage(array $payload, int $version)
152 | {
153 | $object = new Foo();
154 | $object->setBar($payload['new_bar']);
155 |
156 | return $object;
157 | }
158 |
159 | public function supportsHydrate(string $identifier, int $version): bool
160 | {
161 | return $identifier === 'foo' && $version === 2;
162 | }
163 | }
164 | ```
165 |
166 | Now you simply update the transformer to your new contract:
167 |
168 | ```php
169 | use Happyr\MessageSerializer\Transformer\TransformerInterface;
170 |
171 | class FooTransformer implements TransformerInterface
172 | {
173 | public function getVersion(): int
174 | {
175 | return 2;
176 | }
177 |
178 | public function getIdentifier(): string
179 | {
180 | return 'foo';
181 | }
182 |
183 | public function getPayload($message): array
184 | {
185 | return [
186 | 'new_bar' => $message->getBar(),
187 | ];
188 | }
189 |
190 | public function supportsTransform($message): bool
191 | {
192 | return $message instanceof Foo;
193 | }
194 | }
195 | ```
196 |
197 | ### Differentiate between "I cant hydrate message" and "Wrong version"
198 |
199 | Sometimes it is important to know the difference between "*I dont not want this message*"
200 | and "*I want this message, but not this version*". An example scenario would be when you
201 | have multiple applications that communicate with each other and you are using a retry
202 | mechanism when a message failed to be delivered/handled. You **do not want** to retry a
203 | message if the application is not interested but you **do want** to retry if the message
204 | has wrong version (like it would be when you updated the sender app but not the receiver app).
205 |
206 | So lets update `FooHydrator2` from previous example:
207 |
208 | ```php
209 | use Happyr\MessageSerializer\Hydrator\Exception\VersionNotSupportedException;
210 | use Happyr\MessageSerializer\Hydrator\HydratorInterface;
211 |
212 | class FooHydrator2 implements HydratorInterface
213 | {
214 | // ...
215 |
216 | public function supportsHydrate(string $identifier, int $version): bool
217 | {
218 | if ('foo' !== $identifier) {
219 | return false;
220 | }
221 |
222 | if (2 === $version) {
223 | return true;
224 | }
225 |
226 | // We do support the message, but not the version
227 | throw new VersionNotSupportedException();
228 | }
229 | }
230 | ```
231 |
232 | ## SerializerRouter
233 |
234 | If you dispatch/consume messages serialized with `Happyr\MessageSerializer\Serializer`
235 | and default Symfony messenger to same transport you might wanna use
236 | `Happyr\MessageSerializer\SerializerRouter`. This serializer will decide whether
237 | it will use `Happyr\MessageSerializer\Serializer` to decode/encode your message
238 | or the default one from Symfony messenger.
239 |
240 | ```php
241 | use Happyr\MessageSerializer\SerializerRouter;
242 |
243 | $serializerRouter = new SerializerRouter($happyrSerializer, $symfonySerializer);
244 | ```
245 |
246 |
247 | ## Integration with Symfony Messenger
248 |
249 | To make it work with Symfony Messenger, add the following service definition:
250 |
251 | ```yaml
252 | # config/packages/happyr_message_serializer.yaml
253 |
254 | services:
255 | Happyr\MessageSerializer\Serializer:
256 | autowire: true
257 |
258 | Happyr\MessageSerializer\Transformer\MessageToArrayInterface: '@happyr.message_serializer.transformer'
259 | happyr.message_serializer.transformer:
260 | class: Happyr\MessageSerializer\Transformer\Transformer
261 | arguments: [!tagged happyr.message_serializer.transformer]
262 |
263 |
264 | Happyr\MessageSerializer\Hydrator\ArrayToMessageInterface: '@happyr.message_serializer.hydrator'
265 | happyr.message_serializer.hydrator:
266 | class: Happyr\MessageSerializer\Hydrator\Hydrator
267 | arguments: [!tagged happyr.message_serializer.hydrator]
268 |
269 | # If you want to use SerializerRouter
270 | Happyr\MessageSerializer\SerializerRouter:
271 | arguments:
272 | - '@Happyr\MessageSerializer\Serializer'
273 | - '@Symfony\Component\Messenger\Transport\Serialization\SerializerInterface'
274 |
275 | ```
276 |
277 | If you automatically want to tag all your Transformers and Hydrators, add this to your
278 | main service file:
279 |
280 | ```yaml
281 | # config/services.yaml
282 | services:
283 | # ...
284 |
285 | _instanceof:
286 | Happyr\MessageSerializer\Transformer\TransformerInterface:
287 | tags:
288 | - 'happyr.message_serializer.transformer'
289 |
290 | Happyr\MessageSerializer\Hydrator\HydratorInterface:
291 | tags:
292 | - 'happyr.message_serializer.hydrator'
293 | ```
294 |
295 | Then finally, make sure you configure your transport to use this serializer:
296 |
297 | ```yaml
298 | # config/packages/messenger.yaml
299 |
300 | framework:
301 | messenger:
302 | transports:
303 | amqp: '%env(MESSENGER_TRANSPORT_DSN)%'
304 |
305 | to_foobar_application:
306 | dsn: '%env(MESSENGER_TRANSPORT_FOOBAR)%'
307 | serializer: 'Happyr\MessageSerializer\Serializer'
308 |
309 | # If you use SerializerRouter
310 | from_foobaz_application:
311 | dsn: '%env(MESSENGER_TRANSPORT_FOOBAZ)%'
312 | serializer: 'Happyr\MessageSerializer\SerializerRouter'
313 |
314 | ```
315 |
316 | ### Note about Envelopes
317 |
318 | When using Symfony Messenger you will get an `Envelope` passed to `TransformerInterface::getPayload()`. You need
319 | to handle this like:
320 |
321 | ```php
322 | use Happyr\MessageSerializer\Transformer\TransformerInterface;
323 |
324 | class FooTransformer implements TransformerInterface
325 | {
326 | // ...
327 |
328 | public function getPayload($message): array
329 | {
330 | if ($message instanceof Envelope) {
331 | $message = $message->getMessage();
332 | }
333 |
334 | return [
335 | 'bar' => $message->getBar(),
336 | ];
337 | }
338 |
339 | public function supportsTransform($message): bool
340 | {
341 | if ($message instanceof Envelope) {
342 | $message = $message->getMessage();
343 | }
344 |
345 | return $message instanceof Foo;
346 | }
347 | }
348 | ```
349 |
350 | ## Pro tip
351 |
352 | You can let your messages implement both `HydratorInterface` and `TransformerInterface`:
353 |
354 | ```php
355 | use Happyr\MessageSerializer\Hydrator\HydratorInterface;
356 | use Happyr\MessageSerializer\Transformer\TransformerInterface;
357 | use Ramsey\Uuid\Uuid;
358 | use Ramsey\Uuid\UuidInterface;
359 | use Symfony\Component\Messenger\Envelope;
360 |
361 | class CreateUser implements HydratorInterface, TransformerInterface
362 | {
363 | private $uuid;
364 | private $username;
365 |
366 | /** Constructor must be public and empty. */
367 | public function __construct() {}
368 |
369 | public static function create(UuidInterface $uuid, string $username): self
370 | {
371 | $message = new self();
372 | $message->uuid = $uuid;
373 | $message->username = $username;
374 |
375 | return $message;
376 | }
377 |
378 | public function getUuid(): UuidInterface
379 | {
380 | return $this->uuid;
381 | }
382 |
383 | public function getUsername(): string
384 | {
385 | return $this->username;
386 | }
387 |
388 | public function toMessage(array $payload, int $version): self
389 | {
390 | return self::create(Uuid::fromString($payload['id']), $payload['username']);
391 | }
392 |
393 | public function supportsHydrate(string $identifier, int $version): bool
394 | {
395 | return $identifier === 'create-user' && $version === 1;
396 | }
397 |
398 | public function getVersion(): int
399 | {
400 | return 1;
401 | }
402 |
403 | public function getIdentifier(): string
404 | {
405 | return 'create-user';
406 | }
407 |
408 | public function getPayload($message): array
409 | {
410 | if ($message instanceof Envelope) {
411 | $message = $message->getMessage();
412 | }
413 |
414 | return [
415 | 'id' => $message->getUuid()->toString(),
416 | 'username' => $message->getUsername(),
417 | ];
418 | }
419 |
420 | public function supportsTransform($message): bool
421 | {
422 | if ($message instanceof Envelope) {
423 | $message = $message->getMessage();
424 | }
425 |
426 | return $message instanceof self;
427 | }
428 | }
429 | ```
430 |
431 | Just note that we cannot use a constructor to this class since it will work both
432 | as a value object and a service.
433 |
--------------------------------------------------------------------------------