├── .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 | [![Latest Version](https://img.shields.io/github/release/Happyr/message-serializer.svg?style=flat-square)](https://github.com/Happyr/message-serializer/releases) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) 5 | [![Code Coverage](https://img.shields.io/scrutinizer/coverage/g/Happyr/message-serializer.svg?style=flat-square)](https://scrutinizer-ci.com/g/Happyr/message-serializer) 6 | [![Quality Score](https://img.shields.io/scrutinizer/g/Happyr/message-serializer.svg?style=flat-square)](https://scrutinizer-ci.com/g/Happyr/message-serializer) 7 | [![Total Downloads](https://img.shields.io/packagist/dt/happyr/message-serializer.svg?style=flat-square)](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 | --------------------------------------------------------------------------------