From 9a166cf7fd7c3d807b91138c3f261acbbb5f22ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20L=C3=B8vgaard?= Date: Tue, 14 May 2024 14:54:57 +0200 Subject: [PATCH] Introduce serializer and introduce json serialization instead of php serialization --- src/Serializer/CompositeSerializer.php | 51 +++++++++++ src/Serializer/JsonSerializer.php | 50 +++++++++++ src/Serializer/PhpSerializer.php | 75 ++++++++++++++++ src/Serializer/SerializerInterface.php | 25 ++++++ src/Tag/RenderedTag.php | 36 +++++++- src/TagBag.php | 75 +++------------- .../Serializer/AbstractSerializerTestCase.php | 62 +++++++++++++ tests/Serializer/CompositeSerializerTest.php | 89 +++++++++++++++++++ tests/Serializer/JsonSerializerTest.php | 18 ++++ tests/Serializer/PhpSerializerTest.php | 19 ++++ 10 files changed, 436 insertions(+), 64 deletions(-) create mode 100644 src/Serializer/CompositeSerializer.php create mode 100644 src/Serializer/JsonSerializer.php create mode 100644 src/Serializer/PhpSerializer.php create mode 100644 src/Serializer/SerializerInterface.php create mode 100644 tests/Serializer/AbstractSerializerTestCase.php create mode 100644 tests/Serializer/CompositeSerializerTest.php create mode 100644 tests/Serializer/JsonSerializerTest.php create mode 100644 tests/Serializer/PhpSerializerTest.php diff --git a/src/Serializer/CompositeSerializer.php b/src/Serializer/CompositeSerializer.php new file mode 100644 index 0000000..ebfa6c6 --- /dev/null +++ b/src/Serializer/CompositeSerializer.php @@ -0,0 +1,51 @@ + */ + private array $serializers = []; + + public function __construct(SerializerInterface ...$serializers) + { + foreach ($serializers as $serializer) { + $this->add($serializer); + } + } + + public function add(SerializerInterface $serializer): void + { + $this->serializers[] = $serializer; + } + + public function serialize(array $tags): string + { + foreach ($this->serializers as $serializer) { + try { + return $serializer->serialize($tags); + } catch (SerializationException) { + continue; + } + } + + throw new SerializationException('No serializer was able to serialize the tags'); + } + + public function deserialize(string $data): array + { + foreach ($this->serializers as $serializer) { + try { + return $serializer->deserialize($data); + } catch (SerializationException) { + continue; + } + } + + throw new SerializationException('No serializer was able to deserialize the data'); + } +} diff --git a/src/Serializer/JsonSerializer.php b/src/Serializer/JsonSerializer.php new file mode 100644 index 0000000..f55f42c --- /dev/null +++ b/src/Serializer/JsonSerializer.php @@ -0,0 +1,50 @@ +getMessage(), previous: $e); + } + } + + public function deserialize(string $data): array + { + try { + /** @var mixed $data */ + $data = json_decode(json: $data, associative: true, flags: \JSON_THROW_ON_ERROR); + Assert::isArray($data); + + $tags = []; + + foreach ($data as $section => $sectionTags) { + Assert::string($section); + Assert::isArray($sectionTags); + + /** @var mixed $tag */ + foreach ($sectionTags as $tag) { + Assert::isArray($tag); + + $tags[$section][] = RenderedTag::createFromArray($tag); + } + } + + return $tags; + } catch (Throwable $e) { + throw new SerializationException(message: $e->getMessage(), previous: $e); + } + } +} diff --git a/src/Serializer/PhpSerializer.php b/src/Serializer/PhpSerializer.php new file mode 100644 index 0000000..02e5b99 --- /dev/null +++ b/src/Serializer/PhpSerializer.php @@ -0,0 +1,75 @@ +> $result */ + $result = unserialize($data, [ + 'allowed_classes' => [RenderedTag::class], + ]); + /** @psalm-suppress RedundantConditionGivenDocblockType */ + Assert::isArray($result); + foreach ($result as $section => $tags) { + /** @psalm-suppress RedundantConditionGivenDocblockType */ + Assert::string($section); + + /** @psalm-suppress RedundantConditionGivenDocblockType */ + Assert::isArray($tags); + + Assert::allIsInstanceOf($tags, RenderedTag::class); + } + } catch (\InvalidArgumentException) { + throw new SerializationException(sprintf('The unserialized data was incorrect. Here is the original data: %s', $data)); + } finally { + restore_error_handler(); + ini_set('unserialize_callback_func', $prevUnserializeHandler); + } + + return $result; + } + + /** + * @internal + */ + public static function handleUnserializeCallback(string $class): never + { + throw new SerializationException(sprintf('Message class "%s" not found during decoding.', $class)); + } +} diff --git a/src/Serializer/SerializerInterface.php b/src/Serializer/SerializerInterface.php new file mode 100644 index 0000000..6cbd328 --- /dev/null +++ b/src/Serializer/SerializerInterface.php @@ -0,0 +1,25 @@ +> $tags + * + * @throws SerializationException if the serialization fails + */ + public function serialize(array $tags): string; + + /** + * @return array> + * + * @throws SerializationException if the deserialization fails + */ + public function deserialize(string $data): array; +} diff --git a/src/Tag/RenderedTag.php b/src/Tag/RenderedTag.php index a55d6a5..949dac6 100644 --- a/src/Tag/RenderedTag.php +++ b/src/Tag/RenderedTag.php @@ -4,6 +4,10 @@ namespace Setono\TagBag\Tag; +use InvalidArgumentException; +use JsonSerializable; +use Stringable; + /** * This class is not intended to be used outside the Setono\TagBag\TagBag class. * @@ -12,7 +16,7 @@ * * @internal */ -final class RenderedTag implements \Stringable +final class RenderedTag implements Stringable, JsonSerializable { private function __construct( public readonly string $value, @@ -34,8 +38,38 @@ public static function createFromTag(TagInterface $tag, string $value, string $f ); } + public static function createFromArray(array $data): self + { + if (!isset($data['value'], $data['section'], $data['priority'], $data['unique'], $data['fingerprint'])) { + throw new InvalidArgumentException('The data array must contain the keys "value", "section", "priority", "unique", and "fingerprint"'); + } + + if (!is_string($data['value']) || !is_string($data['section']) || !is_int($data['priority']) || !is_bool($data['unique']) || !is_string($data['fingerprint'])) { + throw new InvalidArgumentException('The data array must have the correct types for the keys "value" (string), "section" (string), "priority" (int), "unique" (bool), and "fingerprint" (string)'); + } + + return new self( + $data['value'], + $data['section'], + $data['priority'], + $data['unique'], + $data['fingerprint'], + ); + } + public function __toString(): string { return $this->value; } + + public function jsonSerialize(): array + { + return [ + 'value' => $this->value, + 'section' => $this->section, + 'priority' => $this->priority, + 'unique' => $this->unique, + 'fingerprint' => $this->fingerprint, + ]; + } } diff --git a/src/TagBag.php b/src/TagBag.php index c63413b..dba06ac 100644 --- a/src/TagBag.php +++ b/src/TagBag.php @@ -4,7 +4,6 @@ namespace Setono\TagBag; -use InvalidArgumentException; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerInterface; @@ -20,11 +19,14 @@ use Setono\TagBag\Renderer\ContentAwareRenderer; use Setono\TagBag\Renderer\ElementRenderer; use Setono\TagBag\Renderer\RendererInterface; +use Setono\TagBag\Serializer\CompositeSerializer; +use Setono\TagBag\Serializer\JsonSerializer; +use Setono\TagBag\Serializer\PhpSerializer; +use Setono\TagBag\Serializer\SerializerInterface; use Setono\TagBag\Storage\StorageInterface; use Setono\TagBag\Tag\RenderedTag; use Setono\TagBag\Tag\TagInterface; use Throwable; -use Webmozart\Assert\Assert; final class TagBag implements TagBagInterface, LoggerAwareInterface { @@ -41,13 +43,19 @@ final class TagBag implements TagBagInterface, LoggerAwareInterface private readonly FingerprintGeneratorInterface $fingerprintGenerator; + private readonly SerializerInterface $serializer; + public function __construct( RendererInterface $renderer = null, FingerprintGeneratorInterface $fingerprintGenerator = null, + SerializerInterface $serializer = null, ) { $this->logger = new NullLogger(); $this->renderer = $renderer ?? new CompositeRenderer(new ElementRenderer(), new ContentAwareRenderer()); $this->fingerprintGenerator = $fingerprintGenerator ?? new ValueBasedFingerprintGenerator(); + + /** @psalm-suppress DeprecatedClass */ + $this->serializer = $serializer ?? new CompositeSerializer(new JsonSerializer(), new PhpSerializer()); } public function add(TagInterface $tag): void @@ -176,7 +184,7 @@ public function store(): void if (count($this->tags) === 0) { $this->storage->remove(); } else { - $this->storage->store(serialize($this->tags)); + $this->storage->store($this->serializer->serialize($this->tags)); } } catch (StorageException $e) { $this->logger->error($e->getMessage()); @@ -202,7 +210,7 @@ public function restore(): void $this->tags = []; if (null !== $data) { try { - $this->tags = $this->unserialize($data); + $this->tags = $this->serializer->deserialize($data); } catch (SerializationException $e) { $this->logger->error(sprintf('Exception thrown when trying to unserialize data. Error was: %s. Data was: %s', $e->getMessage(), $data)); } @@ -248,63 +256,4 @@ private function findTagByFingerprint(string $fingerprint): ?array return null; } - - /** - * @throws SerializationException if the data cannot be unserialized - * - * @return array> - * - * Most of this method is taken from here: https://github.com/symfony/symfony/blob/6.2/src/Symfony/Component/Messenger/Transport/Serialization/PhpSerializer.php - */ - private function unserialize(string $data): array - { - if ('' === $data) { - throw SerializationException::emptyData(); - } - - $serializationException = new SerializationException(sprintf('Could not unserialize data: %s.', $data)); - $prevUnserializeHandler = ini_set('unserialize_callback_func', self::class . '::handleUnserializeCallback'); - /** @psalm-suppress MixedArgumentTypeCoercion,UndefinedVariable */ - $prevErrorHandler = set_error_handler(static function ($type, $msg, $file, $line, $context = []) use (&$prevErrorHandler, $serializationException) { - if (__FILE__ === $file) { - throw $serializationException; - } - - /** @psalm-suppress MixedFunctionCall */ - return $prevErrorHandler ? $prevErrorHandler($type, $msg, $file, $line, $context) : false; - }); - - try { - /** @var array> $result */ - $result = unserialize($data, [ - 'allowed_classes' => [RenderedTag::class], - ]); - /** @psalm-suppress RedundantConditionGivenDocblockType */ - Assert::isArray($result); - foreach ($result as $section => $tags) { - /** @psalm-suppress RedundantConditionGivenDocblockType */ - Assert::string($section); - - /** @psalm-suppress RedundantConditionGivenDocblockType */ - Assert::isArray($tags); - - Assert::allIsInstanceOf($tags, RenderedTag::class); - } - } catch (InvalidArgumentException) { - throw new SerializationException(sprintf('The unserialized data was incorrect. Here is the original data: %s', $data)); - } finally { - restore_error_handler(); - ini_set('unserialize_callback_func', $prevUnserializeHandler); - } - - return $result; - } - - /** - * @internal - */ - public static function handleUnserializeCallback(string $class): never - { - throw new SerializationException(sprintf('Message class "%s" not found during decoding.', $class)); - } } diff --git a/tests/Serializer/AbstractSerializerTestCase.php b/tests/Serializer/AbstractSerializerTestCase.php new file mode 100644 index 0000000..109dca3 --- /dev/null +++ b/tests/Serializer/AbstractSerializerTestCase.php @@ -0,0 +1,62 @@ +serialize(static::getTags()); + + self::assertSame(static::getSerializedString(), $data); + } + + /** + * @test + */ + public function it_deserializes(): void + { + $serializer = static::getSerializer(); + $tags = $serializer->deserialize(static::getSerializedString()); + + self::assertEquals(static::getTags(), $tags); + } + + abstract protected static function getSerializer(): SerializerInterface; + + abstract protected static function getSerializedString(): string; + + /** + * @return array> + */ + protected static function getTags(): array + { + return [ + 'section1' => [ + RenderedTag::createFromArray([ + 'value' => 'value1', + 'section' => 'section1', + 'priority' => 1, + 'unique' => true, + 'fingerprint' => 'fingerprint1', + ]), + RenderedTag::createFromArray([ + 'value' => 'value2', + 'section' => 'section1', + 'priority' => 2, + 'unique' => true, + 'fingerprint' => 'fingerprint2', + ]), + ], + ]; + } +} diff --git a/tests/Serializer/CompositeSerializerTest.php b/tests/Serializer/CompositeSerializerTest.php new file mode 100644 index 0000000..0a3e496 --- /dev/null +++ b/tests/Serializer/CompositeSerializerTest.php @@ -0,0 +1,89 @@ +serialize(self::getTags()); + + self::assertSame(self::getSerializedJson(), $data); + } + + /** + * @test + */ + public function it_deserializes_json(): void + { + $serializer = self::getSerializer(); + $tags = $serializer->deserialize(self::getSerializedJson()); + + self::assertEquals(self::getTags(), $tags); + } + + /** + * @test + */ + public function it_deserializes_php(): void + { + $serializer = self::getSerializer(); + $tags = $serializer->deserialize(self::getSerializedPhp()); + + self::assertEquals(self::getTags(), $tags); + } + + private static function getSerializer(): SerializerInterface + { + $serializer = new CompositeSerializer(new JsonSerializer()); + + /** @psalm-suppress DeprecatedClass */ + $serializer->add(new PhpSerializer()); + + return $serializer; + } + + /** + * @return array> + */ + private static function getTags(): array + { + return [ + 'section1' => [ + RenderedTag::createFromArray([ + 'value' => 'value1', + 'section' => 'section1', + 'priority' => 1, + 'unique' => true, + 'fingerprint' => 'fingerprint1', + ]), + RenderedTag::createFromArray([ + 'value' => 'value2', + 'section' => 'section1', + 'priority' => 2, + 'unique' => true, + 'fingerprint' => 'fingerprint2', + ]), + ], + ]; + } + + private static function getSerializedJson(): string + { + return '{"section1":[{"value":"value1","section":"section1","priority":1,"unique":true,"fingerprint":"fingerprint1"},{"value":"value2","section":"section1","priority":2,"unique":true,"fingerprint":"fingerprint2"}]}'; + } + + private static function getSerializedPhp(): string + { + return 'a:1:{s:8:"section1";a:2:{i:0;O:29:"Setono\TagBag\Tag\RenderedTag":5:{s:5:"value";s:6:"value1";s:7:"section";s:8:"section1";s:8:"priority";i:1;s:6:"unique";b:1;s:11:"fingerprint";s:12:"fingerprint1";}i:1;O:29:"Setono\TagBag\Tag\RenderedTag":5:{s:5:"value";s:6:"value2";s:7:"section";s:8:"section1";s:8:"priority";i:2;s:6:"unique";b:1;s:11:"fingerprint";s:12:"fingerprint2";}}}'; + } +} diff --git a/tests/Serializer/JsonSerializerTest.php b/tests/Serializer/JsonSerializerTest.php new file mode 100644 index 0000000..5b2ae1b --- /dev/null +++ b/tests/Serializer/JsonSerializerTest.php @@ -0,0 +1,18 @@ +