├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json └── src ├── Assert └── ZddMessageAsserter.php ├── Command ├── GenerateZddMessageCommand.php ├── ListZddMessageCommand.php └── ValidateZddMessageCommand.php ├── Config └── ZddMessageConfigInterface.php ├── Exceptions ├── InvalidTypeException.php └── MissingValueForTypeException.php ├── Factory ├── Property.php ├── PropertyList.php ├── ZddMessage.php ├── ZddMessageFactory.php └── ZddPropertyExtractor.php ├── Filesystem └── ZddMessageFilesystem.php ├── Listener └── Symfony │ └── MessengerListener.php ├── Serializer ├── SerializerInterface.php ├── UnableToDeserializeException.php └── ZddMessageMessengerSerializer.php └── ZddMessageBundle.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All releases have their changelog published in the [Releases section](https://github.com/Yousign/zdd-message-bundle/releases). 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 17 | ## Code of Conduct 18 | 19 | ### Our Pledge 20 | 21 | In the interest of fostering an open and welcoming environment, we as 22 | contributors and maintainers pledge to making participation in our project and 23 | our community a harassment-free experience for everyone, regardless of age, body 24 | size, disability, ethnicity, gender identity and expression, level of experience, 25 | nationality, personal appearance, race, religion, or sexual identity and 26 | orientation. 27 | 28 | ### Our Standards 29 | 30 | Examples of behavior that contributes to creating a positive environment 31 | include: 32 | 33 | * Using welcoming and inclusive language 34 | * Being respectful of differing viewpoints and experiences 35 | * Gracefully accepting constructive criticism 36 | * Focusing on what is best for the community 37 | * Showing empathy towards other community members 38 | 39 | Examples of unacceptable behavior by participants include: 40 | 41 | * The use of sexualized language or imagery and unwelcome sexual attention or 42 | advances 43 | * Trolling, insulting/derogatory comments, and personal or political attacks 44 | * Public or private harassment 45 | * Publishing others' private information, such as a physical or electronic 46 | address, without explicit permission 47 | * Other conduct which could reasonably be considered inappropriate in a 48 | professional setting 49 | 50 | ### Our Responsibilities 51 | 52 | Project maintainers are responsible for clarifying the standards of acceptable 53 | behavior and are expected to take appropriate and fair corrective action in 54 | response to any instances of unacceptable behavior. 55 | 56 | Project maintainers have the right and responsibility to remove, edit, or 57 | reject comments, commits, code, wiki edits, issues, and other contributions 58 | that are not aligned to this Code of Conduct, or to ban temporarily or 59 | permanently any contributor for other behaviors that they deem inappropriate, 60 | threatening, offensive, or harmful. 61 | 62 | ### Scope 63 | 64 | This Code of Conduct applies both within project spaces and in public spaces 65 | when an individual is representing the project or its community. Examples of 66 | representing a project or community include using an official project e-mail 67 | address, posting via an official social media account, or acting as an appointed 68 | representative at an online or offline event. Representation of a project may be 69 | further defined and clarified by project maintainers. 70 | 71 | ### Enforcement 72 | 73 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 74 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 75 | complaints will be reviewed and investigated and will result in a response that 76 | is deemed necessary and appropriate to the circumstances. The project team is 77 | obligated to maintain confidentiality with regard to the reporter of an incident. 78 | Further details of specific enforcement policies may be posted separately. 79 | 80 | Project maintainers who do not follow or enforce the Code of Conduct in good 81 | faith may face temporary or permanent repercussions as determined by other 82 | members of the project's leadership. 83 | 84 | ### Attribution 85 | 86 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 87 | available at [http://contributor-covenant.org/version/1/4][version] 88 | 89 | [homepage]: http://contributor-covenant.org 90 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yousign 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zero Downtime Deployment Message Bundle ✉️ ✅ 2 | 3 | A Symfony Bundle to use when you want to assert that messages used with Message brokers such like RabbitMQ are compliant with the Zero Downtime Deployment. 4 | 5 | ## Getting started 6 | ### Installation 7 | You can easily install Zdd Message bundle by composer 8 | ``` 9 | $ composer require yousign/zdd-message-bundle 10 | ``` 11 | Then, bundle should be registered. Just verify that `config\bundles.php` is containing : 12 | ```php 13 | Yousign\ZddMessageBundle\ZddMessageBundle::class => ['all' => true], 14 | ``` 15 | 16 | ### Configuration 17 | Once the bundle is installed, you should create a class to configure the messages to assert and how to create them: 18 | 19 | ```php 20 | new App\ValueObject\Email('dummy@email.fr'), 48 | 'App\Enum\MyEnum' => App\Enum\MyEnum::MY_VALUE, 49 | default => null, 50 | }; 51 | } 52 | } 53 | ``` 54 | 55 | When the class is created, you can register it as a service. 56 | 57 | ```yaml 58 | # config/services.yaml 59 | App\Message\MessageConfig: ~ 60 | ``` 61 | 62 | Then, you should register it in the configuration (`config/packages/zdd_message.yaml`) : 63 | ```yaml 64 | # config/packages/zdd_message.yaml 65 | zdd_message: 66 | serialized_messages_dir: 'var/serialized_messages' # The directory where the serialized messages will be stored (default: '%kernel.logs_dir%') 67 | ``` 68 | 69 | #### Optional configuration 70 | 71 | **Use a custom serializer** 72 | 73 | Option to use different serializer. 74 | Possible options : 75 | - `Yousign\ZddMessageBundle\Serializer\ZddMessageMessengerSerializer` (default, already configured for messenger serialization in messenger.yaml) 76 | - Define your own serializer 77 | - Create a service that implement `Yousign\ZddMessageBundle\Serializer\SerializerInterface` 78 | - Use it in the configuration 79 | ```yaml 80 | # config/packages/zdd_message.yaml 81 | zdd_message: 82 | serializer: '' 83 | ``` 84 | 85 | **Detect messages not tracked** 86 | 87 | Option to write a log message if an asynchronous message has been sent (using symfony messenger) and is not present in your configuration. 88 | 89 | ```yaml 90 | # config/packages/zdd_message.yaml 91 | zdd_message: 92 | # ... 93 | log_untracked_messages: 94 | messenger: 95 | enable: true # false by default 96 | level: 'error' # warning by default 97 | ``` 98 | 99 | ## Usage 100 | The bundle comes with commands to assert that your messages are compliant with the Zero Downtime Deployment: 101 | 102 | ```bash 103 | $ bin/console yousign:zdd-message:generate # Generate serialized messages in files. 104 | $ bin/console yousign:zdd-message:validate # Assert that the messages are compliant by deserializing them from files and call the properties. 105 | $ bin/console yousign:zdd-message:debug # Output all tracked messages. 106 | ``` 107 | 108 | 💡 You should run `bin/console yousign:zdd-message:generate` with the production version code and `bin/console yousign:zdd-message:validate` with the version code you want to merge. 109 | 110 | #### Example from the version you want to merge: 111 | ```bash 112 | $ git checkout [production_version] 113 | $ bin/console yousign:zdd-message:generate 114 | $ git checkout - # Go back to the version you want to merge 115 | $ bin/console yousign:zdd-message:validate 116 | ``` 117 | 118 | ## Contributing 119 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 120 | 121 | After writing your fix/feature, you can run following commands to make sure that everything is still ok. 122 | 123 | ```bash 124 | # Install dev dependencies 125 | $ composer install 126 | 127 | # Running tests and quality tools locally 128 | $ make all 129 | ``` 130 | 131 | If you want to use your local fork to develop in your projects, you can use the link command to replace the vendor installation by your local version. 132 | ```bash 133 | $ ./link /home/yousign/dev/my-project 134 | ``` 135 | 136 | ## Authors 137 | - Smaine Milianni - [ismail1432](https://github.com/ismail1432) - 138 | - Simon Mutricy - [Inkod](https://github.com/Inkod) - 139 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "library", 3 | "name": "yousign/zdd-message-bundle", 4 | "description": "Assert Zero Downtime Deployment compliance for messages", 5 | "license": "MIT", 6 | "keywords": ["dev", "zdd"], 7 | "minimum-stability": "stable", 8 | "prefer-stable": true, 9 | "require": { 10 | "php": "^8.1.0", 11 | "symfony/console": "^6.2|^7.0" 12 | }, 13 | "require-dev": { 14 | "phpstan/phpstan": "^1.10", 15 | "phpunit/phpunit": "^9.5", 16 | "friendsofphp/php-cs-fixer": "^3.1", 17 | "symfony/browser-kit": "^6.2|^7.0", 18 | "symfony/framework-bundle": "^6.2|^7.0", 19 | "symfony/yaml": "^6.2|^7.0", 20 | "symfony/messenger": "^6.2|^7.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { 24 | "Yousign\\ZddMessageBundle\\": "src/" 25 | } 26 | }, 27 | "autoload-dev": { 28 | "psr-4": { 29 | "Yousign\\ZddMessageBundle\\Tests\\": "tests/" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Assert/ZddMessageAsserter.php: -------------------------------------------------------------------------------- 1 | $messageFqcn 21 | * 22 | * @throws UnableToDeserializeException 23 | */ 24 | public function assert( 25 | string $messageFqcn, 26 | string $serializedMessage, 27 | PropertyList $propertyList 28 | ): void { 29 | // ✅ Assert message is unserializable 30 | $objectBefore = $this->serializer->deserialize($serializedMessage); 31 | 32 | if (!$objectBefore instanceof $messageFqcn) { 33 | throw new \LogicException(\sprintf('Class mismatch between $messageFqcn: "%s" and $serializedMessage: "%s". Please verify your integration.', $messageFqcn, $serializedMessage)); 34 | } 35 | 36 | $reflection = new \ReflectionClass($messageFqcn); 37 | $reflectionProperties = $reflection->getProperties(); 38 | 39 | // ✅ Assert property type hint has not changed and new property have a default value 40 | foreach ($reflectionProperties as $reflectionProperty) { 41 | // ✅ Assert error "Typed property Message::$theProperty must not be accessed before initialization". 42 | $reflectionProperty->getValue($objectBefore); // @phpstan-ignore-line ::: Call to method ReflectionProperty::getValue() on a separate line has no effect. 43 | 44 | // ✅ Assert property 45 | if ($propertyList->has($reflectionProperty->getName())) { 46 | self::assertProperty($reflectionProperty, $propertyList->get($reflectionProperty->getName()), $messageFqcn); 47 | $propertyList->remove($reflectionProperty->getName()); 48 | } 49 | } 50 | 51 | if (0 !== $propertyList->count()) { 52 | throw new \LogicException(\sprintf('⚠️ The properties "%s" in class "%s" seems to have been removed', implode(', ', $propertyList->getPropertiesName()), $messageFqcn)); 53 | } 54 | } 55 | 56 | private static function assertProperty(\ReflectionProperty $reflectionProperty, Property $property, string $messageFqcn): void 57 | { 58 | if (null === $reflectionProperty->getType()) { 59 | throw new \LogicException(\sprintf('$reflectionProperty::getType cannot be null')); 60 | } 61 | if (!$reflectionProperty->getType() instanceof \ReflectionNamedType) { 62 | throw new \LogicException(\sprintf('$reflectionProperty::getType must be an instance of ReflectionNamedType')); 63 | } 64 | if ($reflectionProperty->getType()->getName() !== $property->type) { 65 | throw new \LogicException(\sprintf('Error for property "%s" in class "%s", the type mismatch between the old and the new version of class. Please verify your integration.', $reflectionProperty->getName(), $messageFqcn)); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Command/GenerateZddMessageCommand.php: -------------------------------------------------------------------------------- 1 | zddMessageFactory = new ZddMessageFactory($zddMessageConfig, $serializer); 26 | $this->zddMessageFilesystem = new ZddMessageFilesystem($this->zddMessagePath); 27 | } 28 | 29 | public function execute(InputInterface $input, OutputInterface $output): int 30 | { 31 | $io = new SymfonyStyle($input, $output); 32 | 33 | $table = $io->createTable(); 34 | $table->setHeaders(['#', 'Message']); 35 | 36 | foreach ($this->zddMessageConfig->getMessageToAssert() as $key => $messageFqcn) { 37 | $zddMessage = $this->zddMessageFactory->create($messageFqcn); 38 | 39 | $this->zddMessageFilesystem->write($zddMessage); 40 | 41 | $table->addRow([$key + 1, $messageFqcn]); 42 | } 43 | 44 | $table->render(); 45 | 46 | return Command::SUCCESS; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Command/ListZddMessageCommand.php: -------------------------------------------------------------------------------- 1 | createTable(); 26 | $table->setHeaderTitle('List of tracked messages for the zdd'); 27 | $table->setHeaders(['#', 'Message']); 28 | 29 | foreach ($this->zddMessageConfig->getMessageToAssert() as $key => $message) { 30 | $table->addRow([$key + 1, $message]); 31 | } 32 | 33 | $table->render(); 34 | 35 | return self::SUCCESS; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Command/ValidateZddMessageCommand.php: -------------------------------------------------------------------------------- 1 | zddMessageFactory = new ZddMessageFactory($zddMessageConfig, $serializer); 28 | $this->zddMessageFilesystem = new ZddMessageFilesystem($this->zddMessagePath); 29 | $this->zddMessageAsserter = new ZddMessageAsserter($serializer); 30 | } 31 | 32 | public function execute(InputInterface $input, OutputInterface $output): int 33 | { 34 | $io = new SymfonyStyle($input, $output); 35 | 36 | $table = $io->createTable(); 37 | $table->setHeaders(['#', 'Message', 'ZDD Compliant?']); 38 | 39 | $errorCount = 0; 40 | foreach ($this->zddMessageConfig->getMessageToAssert() as $key => $messageFqcn) { 41 | if (false === $this->zddMessageFilesystem->exists($messageFqcn)) { 42 | // It happens on newly added message, the trade-off here is to validate itself on current version 43 | $zddMessage = $this->zddMessageFactory->create($messageFqcn); 44 | $this->zddMessageFilesystem->write($zddMessage); 45 | } 46 | 47 | $messageToAssert = $this->zddMessageFilesystem->read($messageFqcn); 48 | 49 | try { 50 | $this->zddMessageAsserter->assert($messageFqcn, $messageToAssert->serializedMessage(), $messageToAssert->propertyList()); 51 | 52 | $table->addRow([$key + 1, $messageFqcn, 'Yes ✅']); 53 | } catch (\Throwable $e) { 54 | $table->addRow([$key + 1, $messageFqcn, 'No ❌']); 55 | ++$errorCount; 56 | } 57 | } 58 | 59 | $table->render(); 60 | 61 | if (0 !== $errorCount) { 62 | $io->note(\sprintf('%d error(s) triggered.', $errorCount)); 63 | 64 | return Command::FAILURE; 65 | } 66 | 67 | return Command::SUCCESS; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Config/ZddMessageConfigInterface.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | public function getMessageToAssert(): array; 21 | 22 | /** 23 | * Provide a fake value for each custom property type used in your messages. 24 | * You can also override the fake value used for scalar types. 25 | * 26 | * @example 27 | * Suppose you have message which contains an object as property type: 28 | * 29 | * class MyMessage 30 | * { 31 | * private MyObject $object; 32 | * // ... 33 | * } 34 | * 35 | * class MyObject 36 | * { 37 | * private string $content; 38 | * // ... 39 | * } 40 | * 41 | * The implementation of generateValueForCustomPropertyType should be like this: 42 | * 43 | * public function generateValueForCustomPropertyType(string $type): array; 44 | * { 45 | * return match($type) { 46 | * 'Namespace\MyObject' => new MyObject("Hi!"), 47 | * default => null, 48 | * }; 49 | * } 50 | * 51 | * @see MessageConfig in ZddMessageFakerTest.php for a concret examples 52 | */ 53 | public function generateValueForCustomPropertyType(string $type): mixed; 54 | } 55 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidTypeException.php: -------------------------------------------------------------------------------- 1 | properties[$property->name] = $property; 22 | } 23 | } 24 | 25 | public function addProperty(Property $property): void 26 | { 27 | $this->properties[$property->name] = $property; 28 | } 29 | 30 | public static function fromJson(string $data): self 31 | { 32 | /** @var array> $decodedProperties */ 33 | $decodedProperties = \json_decode($data, true); 34 | $properties = []; 35 | foreach ($decodedProperties as $decodedProperty) { 36 | $name = $decodedProperty['name'] ?? null; 37 | $type = $decodedProperty['type'] ?? null; 38 | 39 | if (null === $name || null === $type) { 40 | throw new \LogicException(sprintf('Missing keys name and/or type in decoded properties from data: "%s"', $data)); 41 | } 42 | $properties[] = new Property($decodedProperty['name'], $decodedProperty['type'], null); 43 | } 44 | 45 | return new self($properties); 46 | } 47 | 48 | /** 49 | * @return array 50 | */ 51 | public function getPropertiesName(): array 52 | { 53 | return array_keys($this->properties); 54 | } 55 | 56 | /** 57 | * @return Property[] 58 | */ 59 | public function getProperties(): array 60 | { 61 | return $this->properties; 62 | } 63 | 64 | public function has(string $name): bool 65 | { 66 | return array_key_exists($name, $this->properties); 67 | } 68 | 69 | public function get(string $name): Property 70 | { 71 | $property = $this->properties[$name] ?? null; 72 | 73 | if (null === $property) { 74 | throw new \LogicException(sprintf('No property "%s" found in the properties list', $name)); 75 | } 76 | 77 | return $property; 78 | } 79 | 80 | public function remove(string $name): void 81 | { 82 | unset($this->properties[$name]); 83 | } 84 | 85 | public function count(): int 86 | { 87 | return count($this->properties); 88 | } 89 | 90 | public function toJson(): string 91 | { 92 | $data = []; 93 | foreach ($this->properties as $property) { 94 | $data[] = [ 95 | 'name' => $property->name, 96 | 'type' => $property->type, 97 | ]; 98 | } 99 | 100 | return json_encode($data, JSON_THROW_ON_ERROR); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Factory/ZddMessage.php: -------------------------------------------------------------------------------- 1 | message; 21 | } 22 | 23 | public function serializedMessage(): string 24 | { 25 | return $this->serializedMessage; 26 | } 27 | 28 | public function propertyList(): PropertyList 29 | { 30 | return $this->propertyList; 31 | } 32 | 33 | public function messageFqcn(): string 34 | { 35 | return $this->messageFqcn; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Factory/ZddMessageFactory.php: -------------------------------------------------------------------------------- 1 | propertyExtractor = new ZddPropertyExtractor($config); 20 | } 21 | 22 | /** 23 | * @param class-string $className 24 | */ 25 | public function create(string $className): ZddMessage 26 | { 27 | $propertyList = $this->propertyExtractor->extractPropertiesFromClass($className); 28 | 29 | $message = (new \ReflectionClass($className))->newInstanceWithoutConstructor(); 30 | foreach ($propertyList->getProperties() as $property) { 31 | $this->forcePropertyValue($message, $property->name, $property->value); 32 | } 33 | 34 | $serializedMessage = $this->serializer->serialize($message); 35 | 36 | return new ZddMessage($className, $serializedMessage, $propertyList, $message); 37 | } 38 | 39 | private function forcePropertyValue(object $object, string $property, mixed $value): void 40 | { 41 | $reflectionClass = new \ReflectionClass($object); 42 | $reflectionProperty = $reflectionClass->getProperty($property); 43 | 44 | $reflectionProperty->setAccessible(true); 45 | $reflectionProperty->setValue($object, $value); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Factory/ZddPropertyExtractor.php: -------------------------------------------------------------------------------- 1 | getProperties() as $property) { 32 | $propertyName = $property->getName(); 33 | $propertyType = $property->getType(); 34 | 35 | if (null === $propertyType) { 36 | throw InvalidTypeException::typeMissing($propertyName, $className); 37 | } 38 | 39 | if (!$propertyType instanceof \ReflectionNamedType) { 40 | throw InvalidTypeException::typeNotSupported(); 41 | } 42 | 43 | $typeHint = $propertyType->getName(); 44 | $value = $propertyType->allowsNull() ? null : $this->generateFakeValueFromType($typeHint); 45 | $propertyList->addProperty(new Property($propertyName, $typeHint, $value)); 46 | } 47 | 48 | return $propertyList; 49 | } 50 | 51 | /** 52 | * @throws MissingValueForTypeException 53 | */ 54 | private function generateFakeValueFromType(string $typeHint): mixed 55 | { 56 | $value = $this->config->generateValueForCustomPropertyType($typeHint); 57 | if (null !== $value) { 58 | return $value; 59 | } 60 | 61 | return match ($typeHint) { 62 | 'string' => 'Hello World!', 63 | 'int' => 42, 64 | 'float' => 42.42, 65 | 'bool' => true, 66 | 'array' => ['PHP', 'For The Win'], 67 | default => throw MissingValueForTypeException::missingValue($typeHint, $this->config), 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Filesystem/ZddMessageFilesystem.php: -------------------------------------------------------------------------------- 1 | getBasePath($zddMessage->messageFqcn()); 22 | if (false === file_exists($basePath)) { 23 | if (!mkdir($basePath, recursive: true) && !is_dir($basePath)) { 24 | throw new \RuntimeException(\sprintf('Unable to create directory "%s"', $basePath)); 25 | } 26 | } 27 | 28 | $serializedMessagePath = $this->getPathToSerializedMessage($zddMessage->messageFqcn()); 29 | $byteWrittenInTxt = \file_put_contents($serializedMessagePath, $zddMessage->serializedMessage()); 30 | if (false === $byteWrittenInTxt || 0 === $byteWrittenInTxt) { 31 | throw new \RuntimeException(\sprintf('Unable to write file "%s"', $serializedMessagePath)); 32 | } 33 | 34 | $propertiesPath = $this->getPathToProperties($zddMessage->messageFqcn()); 35 | $byteWrittenInJson = \file_put_contents($propertiesPath, $zddMessage->propertyList()->toJson()); 36 | if (false === $byteWrittenInJson || 0 === $byteWrittenInJson) { 37 | throw new \RuntimeException(\sprintf('Unable to write file "%s"', $propertiesPath)); 38 | } 39 | } 40 | 41 | public function read(string $messageFqcn): ZddMessage 42 | { 43 | $serializedMessagePath = $this->getPathToSerializedMessage($messageFqcn); 44 | if (false === $serializedMessage = \file_get_contents($serializedMessagePath)) { 45 | throw new \RuntimeException(\sprintf('Unable to read file "%s"', $serializedMessagePath)); 46 | } 47 | 48 | $propertiesPath = $this->getPathToProperties($messageFqcn); 49 | if (false === $properties = \file_get_contents($propertiesPath)) { 50 | throw new \RuntimeException(\sprintf('Unable to read file "%s"', $propertiesPath)); 51 | } 52 | 53 | $propertyList = PropertyList::fromJson($properties); 54 | 55 | return new ZddMessage($messageFqcn, $serializedMessage, $propertyList); 56 | } 57 | 58 | public function exists(string $messageFqcn): bool 59 | { 60 | $serializedMessagePath = $this->getPathToSerializedMessage($messageFqcn); 61 | 62 | return file_exists($serializedMessagePath); 63 | } 64 | 65 | /** 66 | * @return array 67 | */ 68 | private function getDirectoryAndShortname(string $classFqcn): array 69 | { 70 | $path = explode('\\', $classFqcn); 71 | $shortName = end($path); 72 | array_pop($path); 73 | $directory = implode('/', $path); 74 | 75 | return [$directory, $shortName]; 76 | } 77 | 78 | private function getPathToSerializedMessage(string $messageFqcn): string 79 | { 80 | [$directory, $shortName] = $this->getDirectoryAndShortname($messageFqcn); 81 | 82 | return $this->zddPath.'/'.$directory.'/'.$shortName.'.txt'; 83 | } 84 | 85 | private function getPathToProperties(string $messageFqcn): string 86 | { 87 | [$directory, $shortName] = $this->getDirectoryAndShortname($messageFqcn); 88 | 89 | return $this->zddPath.'/'.$directory.'/'.$shortName.'.properties.json'; 90 | } 91 | 92 | private function getBasePath(string $messageFqcn): string 93 | { 94 | [$directory] = $this->getDirectoryAndShortname($messageFqcn); 95 | 96 | return $this->zddPath.'/'.$directory; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Listener/Symfony/MessengerListener.php: -------------------------------------------------------------------------------- 1 | getEnvelope()->getMessage(); 23 | // In case of $message act like an envelope. 24 | if (method_exists($message, 'getMessage')) { 25 | $message = $message->getMessage(); 26 | } 27 | 28 | if (is_object($message) && !in_array($class = get_class($message), $this->config->getMessageToAssert(), true)) { 29 | $this->logger->log( 30 | $this->logLevel, 31 | 'Untracked {class} has been detected, add it in your configuration to ensure ZDD compliance.', 32 | [ 33 | 'class' => $class, 34 | ], 35 | ); 36 | } 37 | } catch (\Throwable $throwable) { 38 | // The listener should not throw an exception. 39 | $this->logger->log( 40 | 'warning', 41 | 'An error occurred when comparing the consumed message to the messages in `ZddMessageConfigInterface::getMessageToAssert`', [ 42 | $throwable->getMessage(), 43 | ]); 44 | } 45 | } 46 | 47 | public static function getSubscribedEvents(): array 48 | { 49 | return [ 50 | WorkerMessageReceivedEvent::class => 'onMessageReceived', 51 | ]; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Serializer/SerializerInterface.php: -------------------------------------------------------------------------------- 1 | serializer->encode(Envelope::wrap($data)); 18 | 19 | return \json_encode($encodedEnvelope, \JSON_THROW_ON_ERROR); 20 | } 21 | 22 | public function deserialize(string $data): object 23 | { 24 | $dataArray = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); 25 | if (!\is_array($dataArray)) { 26 | throw new \InvalidArgumentException(sprintf('Array expected, %s provided', \gettype($data))); 27 | } 28 | 29 | try { 30 | $envelope = $this->serializer->decode($dataArray); 31 | if (!$envelope instanceof Envelope) { 32 | throw new \InvalidArgumentException(sprintf('%s expected, %s provided', Envelope::class, \gettype($data))); 33 | } 34 | 35 | return $envelope->getMessage(); 36 | } catch (MessageDecodingFailedException $e) { 37 | throw new UnableToDeserializeException(previous: $e); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/ZddMessageBundle.php: -------------------------------------------------------------------------------- 1 | rootNode() 27 | ->children() 28 | ->scalarNode('message_config_service')->defaultNull()->end() 29 | ->scalarNode('serialized_messages_dir')->defaultNull()->end() 30 | ->scalarNode('serializer')->defaultValue(ZddMessageMessengerSerializer::class)->end() 31 | ->arrayNode('log_untracked_messages') 32 | ->children() 33 | ->arrayNode('messenger') 34 | ->children() 35 | ->booleanNode('enable')->defaultFalse()->end() 36 | ->scalarNode('level')->defaultValue('warning')->end() 37 | ->end() 38 | ->end() // messenger 39 | ->end() 40 | ->end() // log_untracked_messages 41 | ->end() 42 | ->end() 43 | ; 44 | } 45 | 46 | /** @phpstan-ignore-next-line */ 47 | public function loadExtension(array $config, ContainerConfigurator $container, ContainerBuilder $builder): void 48 | { 49 | $serviceConfigurator = $container 50 | ->services() 51 | ->defaults() 52 | ->autowire() 53 | ->autoconfigure() 54 | ; 55 | 56 | $messageConfigServiceId = $config['message_config_service']; 57 | if (!$messageConfigServiceId) { 58 | throw new \LogicException(sprintf('You should configure zdd_message.message_config_service with a service that implements %s', ZddMessageConfigInterface::class)); 59 | } 60 | 61 | $serviceConfigurator->bind('$zddMessageConfig', service($messageConfigServiceId)); 62 | $serviceConfigurator->bind('$zddMessagePath', $config['serialized_messages_dir'] ?? $this->getDefaultPath($builder)); 63 | 64 | $messengerEnable = $config['log_untracked_messages']['messenger']['enable'] ?? false; 65 | if ($messengerEnable) { 66 | $messengerLevel = $config['log_untracked_messages']['messenger']['level'] ?? 'warning'; 67 | $serviceConfigurator 68 | ->set(MessengerListener::class) 69 | ->autowire() 70 | ->tag('kernel.event_subscriber') 71 | ->args([ 72 | service('logger'), 73 | service($messageConfigServiceId), 74 | $messengerLevel, 75 | ]) 76 | ; 77 | } 78 | 79 | $serviceConfigurator 80 | ->set(SerializerInterface::class, $config['serializer']) 81 | ->set(GenerateZddMessageCommand::class) 82 | ->set(ValidateZddMessageCommand::class) 83 | ->set(ListZddMessageCommand::class) 84 | ; 85 | } 86 | 87 | private function getDefaultPath(ContainerBuilder $containerBuilder): string 88 | { 89 | $projectDir = $containerBuilder->getParameter('kernel.project_dir'); 90 | 91 | if (!is_string($projectDir)) { 92 | throw new \InvalidArgumentException('The project directory should be a string.'); 93 | } 94 | 95 | return $projectDir.'/var/zdd-message'; 96 | } 97 | } 98 | --------------------------------------------------------------------------------