From 94e871fb90cdfe0043203680539ecc99061e5ff6 Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Tue, 25 Aug 2026 00:00:38 +0200 Subject: [PATCH] feat: Add symfony serializer This is a very simple wrapper. This expose most interesting features of the symfony serializer to serialize and deserialize DTO objects. This can be used in combination with the new Entity system from AppFramework ORM. Note that to make full use of this, this still needs to be hooked in the http dispatcher and http response handling to remove some boilerplate. Also another next step is doing the same with the symfony validation component to validate both the ORM entities and the serialized/deserialized objects. Assisted-by: ClaudeCode:claude-sonnet-5 Signed-off-by: Carl Schwan --- 3rdparty | 2 +- lib/composer/composer/autoload_classmap.php | 8 + lib/composer/composer/autoload_static.php | 20 +- lib/private/Serializer/AttributeLoader.php | 135 ++++++++++ lib/private/Serializer/Serializer.php | 76 ++++++ lib/private/Server.php | 3 + .../Attribute/SerializationGroups.php | 50 ++++ .../Attribute/SerializationIgnore.php | 32 +++ .../Serializer/Attribute/SerializedName.php | 38 +++ .../Serializer/Attribute/SerializedPath.php | 38 +++ lib/public/Serializer/Format.php | 27 ++ lib/public/Serializer/ISerializer.php | 50 ++++ .../SerializerAccessorPrecedenceTestDto.php | 34 +++ .../lib/Serializer/SerializerArrayTestDto.php | 21 ++ .../Serializer/SerializerCollisionTestDto.php | 29 +++ .../SerializerNestedArrayTestDto.php | 18 ++ .../SerializerNestedItemTestDto.php | 17 ++ tests/lib/Serializer/SerializerTest.php | 236 ++++++++++++++++++ tests/lib/Serializer/SerializerTestDto.php | 56 +++++ 19 files changed, 883 insertions(+), 7 deletions(-) create mode 100644 lib/private/Serializer/AttributeLoader.php create mode 100644 lib/private/Serializer/Serializer.php create mode 100644 lib/public/Serializer/Attribute/SerializationGroups.php create mode 100644 lib/public/Serializer/Attribute/SerializationIgnore.php create mode 100644 lib/public/Serializer/Attribute/SerializedName.php create mode 100644 lib/public/Serializer/Attribute/SerializedPath.php create mode 100644 lib/public/Serializer/Format.php create mode 100644 lib/public/Serializer/ISerializer.php create mode 100644 tests/lib/Serializer/SerializerAccessorPrecedenceTestDto.php create mode 100644 tests/lib/Serializer/SerializerArrayTestDto.php create mode 100644 tests/lib/Serializer/SerializerCollisionTestDto.php create mode 100644 tests/lib/Serializer/SerializerNestedArrayTestDto.php create mode 100644 tests/lib/Serializer/SerializerNestedItemTestDto.php create mode 100644 tests/lib/Serializer/SerializerTest.php create mode 100644 tests/lib/Serializer/SerializerTestDto.php diff --git a/3rdparty b/3rdparty index 60fd9dfedaf3d..4f8a1c3fd0090 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 60fd9dfedaf3d29a6be94b27de28ca54d3c72a9f +Subproject commit 4f8a1c3fd0090325d935256ef8b6565c9d7c9da0 diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 7b13905cc26a3..ee192ad0236cd 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -910,6 +910,12 @@ 'OCP\\Security\\Signature\\Model\\Signatory' => $baseDir . '/lib/public/Security/Signature/Model/Signatory.php', 'OCP\\Security\\VerificationToken\\IVerificationToken' => $baseDir . '/lib/public/Security/VerificationToken/IVerificationToken.php', 'OCP\\Security\\VerificationToken\\InvalidTokenException' => $baseDir . '/lib/public/Security/VerificationToken/InvalidTokenException.php', + 'OCP\\Serializer\\Attribute\\SerializationGroups' => $baseDir . '/lib/public/Serializer/Attribute/SerializationGroups.php', + 'OCP\\Serializer\\Attribute\\SerializationIgnore' => $baseDir . '/lib/public/Serializer/Attribute/SerializationIgnore.php', + 'OCP\\Serializer\\Attribute\\SerializedName' => $baseDir . '/lib/public/Serializer/Attribute/SerializedName.php', + 'OCP\\Serializer\\Attribute\\SerializedPath' => $baseDir . '/lib/public/Serializer/Attribute/SerializedPath.php', + 'OCP\\Serializer\\Format' => $baseDir . '/lib/public/Serializer/Format.php', + 'OCP\\Serializer\\ISerializer' => $baseDir . '/lib/public/Serializer/ISerializer.php', 'OCP\\Server' => $baseDir . '/lib/public/Server.php', 'OCP\\ServerVersion' => $baseDir . '/lib/public/ServerVersion.php', 'OCP\\Session\\Exceptions\\SessionNotAvailableException' => $baseDir . '/lib/public/Session/Exceptions/SessionNotAvailableException.php', @@ -2316,6 +2322,8 @@ 'OC\\Security\\TrustedDomainHelper' => $baseDir . '/lib/private/Security/TrustedDomainHelper.php', 'OC\\Security\\VerificationToken\\CleanUpJob' => $baseDir . '/lib/private/Security/VerificationToken/CleanUpJob.php', 'OC\\Security\\VerificationToken\\VerificationToken' => $baseDir . '/lib/private/Security/VerificationToken/VerificationToken.php', + 'OC\\Serializer\\AttributeLoader' => $baseDir . '/lib/private/Serializer/AttributeLoader.php', + 'OC\\Serializer\\Serializer' => $baseDir . '/lib/private/Serializer/Serializer.php', 'OC\\Server' => $baseDir . '/lib/private/Server.php', 'OC\\ServerContainer' => $baseDir . '/lib/private/ServerContainer.php', 'OC\\ServerInfo' => $baseDir . '/lib/private/ServerInfo.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index ec97a4134df1f..8722fc67071bd 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -11,32 +11,32 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 ); public static $prefixLengthsPsr4 = array ( - 'O' => + 'O' => array ( 'OC\\Core\\' => 8, 'OC\\' => 3, 'OCP\\' => 4, ), - 'N' => + 'N' => array ( 'NCU\\' => 4, ), ); public static $prefixDirsPsr4 = array ( - 'OC\\Core\\' => + 'OC\\Core\\' => array ( 0 => __DIR__ . '/../../..' . '/core', ), - 'OC\\' => + 'OC\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/private', ), - 'OCP\\' => + 'OCP\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/public', ), - 'NCU\\' => + 'NCU\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/unstable', ), @@ -951,6 +951,12 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Security\\Signature\\Model\\Signatory' => __DIR__ . '/../../..' . '/lib/public/Security/Signature/Model/Signatory.php', 'OCP\\Security\\VerificationToken\\IVerificationToken' => __DIR__ . '/../../..' . '/lib/public/Security/VerificationToken/IVerificationToken.php', 'OCP\\Security\\VerificationToken\\InvalidTokenException' => __DIR__ . '/../../..' . '/lib/public/Security/VerificationToken/InvalidTokenException.php', + 'OCP\\Serializer\\Attribute\\SerializationGroups' => __DIR__ . '/../../..' . '/lib/public/Serializer/Attribute/SerializationGroups.php', + 'OCP\\Serializer\\Attribute\\SerializationIgnore' => __DIR__ . '/../../..' . '/lib/public/Serializer/Attribute/SerializationIgnore.php', + 'OCP\\Serializer\\Attribute\\SerializedName' => __DIR__ . '/../../..' . '/lib/public/Serializer/Attribute/SerializedName.php', + 'OCP\\Serializer\\Attribute\\SerializedPath' => __DIR__ . '/../../..' . '/lib/public/Serializer/Attribute/SerializedPath.php', + 'OCP\\Serializer\\Format' => __DIR__ . '/../../..' . '/lib/public/Serializer/Format.php', + 'OCP\\Serializer\\ISerializer' => __DIR__ . '/../../..' . '/lib/public/Serializer/ISerializer.php', 'OCP\\Server' => __DIR__ . '/../../..' . '/lib/public/Server.php', 'OCP\\ServerVersion' => __DIR__ . '/../../..' . '/lib/public/ServerVersion.php', 'OCP\\Session\\Exceptions\\SessionNotAvailableException' => __DIR__ . '/../../..' . '/lib/public/Session/Exceptions/SessionNotAvailableException.php', @@ -2357,6 +2363,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Security\\TrustedDomainHelper' => __DIR__ . '/../../..' . '/lib/private/Security/TrustedDomainHelper.php', 'OC\\Security\\VerificationToken\\CleanUpJob' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/CleanUpJob.php', 'OC\\Security\\VerificationToken\\VerificationToken' => __DIR__ . '/../../..' . '/lib/private/Security/VerificationToken/VerificationToken.php', + 'OC\\Serializer\\AttributeLoader' => __DIR__ . '/../../..' . '/lib/private/Serializer/AttributeLoader.php', + 'OC\\Serializer\\Serializer' => __DIR__ . '/../../..' . '/lib/private/Serializer/Serializer.php', 'OC\\Server' => __DIR__ . '/../../..' . '/lib/private/Server.php', 'OC\\ServerContainer' => __DIR__ . '/../../..' . '/lib/private/ServerContainer.php', 'OC\\ServerInfo' => __DIR__ . '/../../..' . '/lib/private/ServerInfo.php', diff --git a/lib/private/Serializer/AttributeLoader.php b/lib/private/Serializer/AttributeLoader.php new file mode 100644 index 0000000000000..f2644f2f4c185 --- /dev/null +++ b/lib/private/Serializer/AttributeLoader.php @@ -0,0 +1,135 @@ +getReflectionClass(); + $className = $reflectionClass->name; + $loaded = false; + + $classGroups = []; + foreach ($reflectionClass->getAttributes(SerializationGroups::class) as $attribute) { + $classGroups = array_merge($classGroups, $attribute->newInstance()->groups); + } + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + + foreach ($reflectionClass->getMethods() as $method) { + if ($method->getDeclaringClass()->name !== $className + || $method->isStatic() + || $method->getNumberOfParameters() > 0 + || !preg_match('/^(?:get|is|has)([A-Z].*)$/', $method->name, $matches) + ) { + continue; + } + $attributeName = lcfirst($matches[1]); + + $attributeMetadata = new AttributeMetadata($attributeName); + $hasAttribute = false; + foreach ($method->getAttributes() as $reflectionAttribute) { + if ($this->applyAttribute($attributeMetadata, $reflectionAttribute)) { + $hasAttribute = true; + } + } + + if (!$hasAttribute) { + continue; + } + + foreach ($classGroups as $group) { + $attributeMetadata->addGroup($group); + } + + $attributesMetadata[$attributeName] = $attributeMetadata; + $classMetadata->addAttributeMetadata($attributeMetadata); + $loaded = true; + } + + foreach ($reflectionClass->getProperties() as $property) { + if ($property->getDeclaringClass()->name !== $className) { + continue; + } + + // A method with the same name already claimed this name, see above. + if (isset($attributesMetadata[$property->name])) { + continue; + } + + $attributesMetadata[$property->name] = new AttributeMetadata($property->name); + $classMetadata->addAttributeMetadata($attributesMetadata[$property->name]); + $attributeMetadata = $attributesMetadata[$property->name]; + + foreach ($classGroups as $group) { + $attributeMetadata->addGroup($group); + } + + foreach ($property->getAttributes() as $reflectionAttribute) { + if ($this->applyAttribute($attributeMetadata, $reflectionAttribute)) { + $loaded = true; + } + } + } + + return $loaded; + } + + /** + * @return bool true if `$reflectionAttribute` was one of our own attributes + */ + private function applyAttribute(AttributeMetadata $attributeMetadata, \ReflectionAttribute $reflectionAttribute): bool { + $attribute = match ($reflectionAttribute->getName()) { + SerializationGroups::class, SerializationIgnore::class, SerializedName::class, SerializedPath::class => $reflectionAttribute->newInstance(), + default => null, + }; + + match (true) { + $attribute instanceof SerializationGroups => array_map($attributeMetadata->addGroup(...), $attribute->groups), + $attribute instanceof SerializationIgnore => $attributeMetadata->setIgnore(true), + $attribute instanceof SerializedName => $attributeMetadata->setSerializedName($attribute->serializedName), + $attribute instanceof SerializedPath => $attributeMetadata->setSerializedPath($this->parseSerializedPath($attribute)), + default => null, + }; + + return $attribute !== null; + } + + private function parseSerializedPath(SerializedPath $attribute): PropertyPath { + try { + return new PropertyPath($attribute->serializedPath); + } catch (InvalidPropertyPathException $e) { + throw new \InvalidArgumentException(sprintf('"%s" is not a valid serialized path.', $attribute->serializedPath), 0, $e); + } + } +} diff --git a/lib/private/Serializer/Serializer.php b/lib/private/Serializer/Serializer.php new file mode 100644 index 0000000000000..f9a5e980e5b10 --- /dev/null +++ b/lib/private/Serializer/Serializer.php @@ -0,0 +1,76 @@ +serializer = new SymfonySerializer( + [ + new DateTimeNormalizer(), + new BackedEnumNormalizer(), + new ArrayDenormalizer(), + new ObjectNormalizer( + classMetadataFactory: $classMetadataFactory, + nameConverter: $nameConverter, + propertyTypeExtractor: $propertyTypeExtractor, + ), + ], + [ + new JsonEncoder(), + new XmlEncoder(), + new CsvEncoder(), + ], + ); + } + + #[\Override] + public function serialize(mixed $data, Format $format = Format::JSON, array $context = []): string { + return $this->serializer->serialize($data, $format->value, $context); + } + + #[\Override] + public function deserialize(string $data, string $type, Format $format = Format::JSON, array $context = []): mixed { + return $this->serializer->deserialize($data, $type, $format->value, $context); + } +} diff --git a/lib/private/Server.php b/lib/private/Server.php index 7edd96f041440..40dcf4794a4ce 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -128,6 +128,7 @@ use OC\Security\Signature\SignatureManager; use OC\Security\TrustedDomainHelper; use OC\Security\VerificationToken\VerificationToken; +use OC\Serializer\Serializer; use OC\Session\CryptoWrapper; use OC\Session\Memory; use OC\Settings\DeclarativeManager; @@ -269,6 +270,7 @@ use OCP\Security\RateLimiting\ILimiter; use OCP\Security\Signature\ISignatureManager; use OCP\Security\VerificationToken\IVerificationToken; +use OCP\Serializer\ISerializer; use OCP\ServerVersion; use OCP\Settings\IDeclarativeManager; use OCP\SetupCheck\ISetupCheckManager; @@ -1003,6 +1005,7 @@ public function __construct( ); }); $this->registerAlias(IEventDispatcher::class, EventDispatcher::class); + $this->registerAlias(ISerializer::class, Serializer::class); /** @deprecated 35.0.0 */ $this->registerDeprecatedAlias('CryptoWrapper', CryptoWrapper::class); diff --git a/lib/public/Serializer/Attribute/SerializationGroups.php b/lib/public/Serializer/Attribute/SerializationGroups.php new file mode 100644 index 0000000000000..bcccd3d90d6e9 --- /dev/null +++ b/lib/public/Serializer/Attribute/SerializationGroups.php @@ -0,0 +1,50 @@ + + * @since 36.0.0 + */ + public readonly array $groups; + + /** + * @param non-empty-string ...$groups the groups to define on the attribute target + * @since 36.0.0 + */ + public function __construct(string ...$groups) { + $this->groups = $groups; + } +} diff --git a/lib/public/Serializer/Attribute/SerializationIgnore.php b/lib/public/Serializer/Attribute/SerializationIgnore.php new file mode 100644 index 0000000000000..e616f9ea2771e --- /dev/null +++ b/lib/public/Serializer/Attribute/SerializationIgnore.php @@ -0,0 +1,32 @@ + $data the data to serialize, e.g. an object, or an array of objects + * @param Format $format the output format + * @param array, ...}> $context context options, e.g. `['groups' => ['group1']]` + * to only serialize properties tagged with one of those groups + * @since 36.0.0 + */ + public function serialize(mixed $data, Format $format = Format::JSON, array $context = []): string; + + /** + * Deserializes data of the given format into an instance of `$type` + * + * @template T + * @param string $data the raw data to deserialize + * @param class-string|non-empty-string $type the class to deserialize the data into, e.g. `Person::class` or `Person::class . '[]'` + * @param Format $format the input format + * @param array}> $context context options, e.g. `['groups' => ['group1']]` + * to only populate properties tagged with one of those groups + * @return T + * @since 36.0.0 + */ + public function deserialize(string $data, string $type, Format $format = Format::JSON, array $context = []): mixed; +} diff --git a/tests/lib/Serializer/SerializerAccessorPrecedenceTestDto.php b/tests/lib/Serializer/SerializerAccessorPrecedenceTestDto.php new file mode 100644 index 0000000000000..bd0b7e67cca30 --- /dev/null +++ b/tests/lib/Serializer/SerializerAccessorPrecedenceTestDto.php @@ -0,0 +1,34 @@ +active; + } + + #[SerializationGroups('basic')] + public function isVerified(): bool { + return $this->verified; + } +} diff --git a/tests/lib/Serializer/SerializerArrayTestDto.php b/tests/lib/Serializer/SerializerArrayTestDto.php new file mode 100644 index 0000000000000..4463ba1e7e7dc --- /dev/null +++ b/tests/lib/Serializer/SerializerArrayTestDto.php @@ -0,0 +1,21 @@ +name; + } +} diff --git a/tests/lib/Serializer/SerializerNestedArrayTestDto.php b/tests/lib/Serializer/SerializerNestedArrayTestDto.php new file mode 100644 index 0000000000000..26276da95b2a8 --- /dev/null +++ b/tests/lib/Serializer/SerializerNestedArrayTestDto.php @@ -0,0 +1,18 @@ +serializer = new Serializer(); + } + + public function testServiceIsRegistered(): void { + $this->assertInstanceOf(Serializer::class, Server::get(ISerializer::class)); + } + + public function testSerializeOnlyIncludesRequestedGroup(): void { + $dto = new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London', secret: 's3cr3t'); + + $json = $this->serializer->serialize($dto, Format::JSON, ['groups' => ['basic']]); + + $this->assertJsonStringEqualsJsonString( + '{"full_name":"Jane Doe","meta":{"city":"London"},"active":true}', + $json, + ); + } + + public function testSerializeAlwaysExcludesIgnoredProperty(): void { + $dto = new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London', secret: 's3cr3t'); + + $json = $this->serializer->serialize($dto); + + $this->assertStringNotContainsString('s3cr3t', $json); + $this->assertStringNotContainsString('secret', $json); + } + + public function testSerializeAlwaysExcludesIgnoredVirtualAttribute(): void { + $dto = new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London', secret: 's3cr3t'); + + $json = $this->serializer->serialize($dto); + + $this->assertStringNotContainsString('nope', $json); + $this->assertStringNotContainsString('secretCode', $json); + } + + public function testSerializeIncludesVirtualAttributeFromAccessorMethod(): void { + $dto = new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London', secret: 's3cr3t'); + + $json = $this->serializer->serialize($dto, Format::JSON, ['groups' => ['detailed']]); + $data = json_decode($json, true); + + $this->assertSame(42, $data['user_score']); + } + + public function testAccessorMetadataWinsOverPropertyWithSameDerivedName(): void { + $classMetadata = (new ClassMetadataFactory(new AttributeLoader()))->getMetadataFor(SerializerCollisionTestDto::class); + + $attributeMetadata = $classMetadata->getAttributesMetadata()['name']; + + $this->assertSame('accessor_name', $attributeMetadata->getSerializedName()); + } + + public function testPropertyAttributesStillApplyWhenTheAccessorCarriesNone(): void { + $dto = new SerializerAccessorPrecedenceTestDto(active: true); + + $json = $this->serializer->serialize($dto, Format::JSON, ['groups' => ['basic']]); + $data = json_decode($json, true); + + $this->assertTrue($data['active']); + } + + public function testAccessorAttributesApplyEvenWhenThePropertyCarriesNone(): void { + // Before the accessor/property precedence fix, a bare property with no attributes of + // its own would still silently claim its derived name, and "verified" would never make + // it into the output despite the accessor's own #[SerializationGroups('basic')]. + $dto = new SerializerAccessorPrecedenceTestDto(active: true, verified: true); + + $json = $this->serializer->serialize($dto, Format::JSON, ['groups' => ['basic']]); + $data = json_decode($json, true); + + $this->assertTrue($data['verified']); + } + + public function testSerializeToXml(): void { + $dto = new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London', secret: 's3cr3t'); + + $xml = $this->serializer->serialize($dto, Format::XML, ['groups' => ['basic']]); + + $this->assertStringContainsString('Jane Doe', $xml); + $this->assertStringNotContainsString('221B Baker Street', $xml); + } + + public function testSerializeToCsv(): void { + $dtos = [ + new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London'), + new SerializerTestDto(name: 'John Doe', address: '10 Downing Street', city: 'London'), + ]; + + $csv = $this->serializer->serialize($dtos, Format::CSV, ['groups' => ['basic']]); + + // Column order follows the order attributes are discovered in (methods before + // properties), which is an implementation detail, so compare by column name instead of + // asserting an exact row/column layout. + $lines = array_map('str_getcsv', array_filter(explode("\n", $csv))); + $header = array_shift($lines); + $rows = $lines; + $this->assertEqualsCanonicalizing(['full_name', 'meta.city', 'active'], $header); + + $rowsByColumn = array_map( + static fn (array $row): array => array_combine($header, $row), + $rows, + ); + $this->assertSame('Jane Doe', $rowsByColumn[0]['full_name']); + $this->assertSame('London', $rowsByColumn[0]['meta.city']); + $this->assertSame('1', $rowsByColumn[0]['active']); + $this->assertSame('John Doe', $rowsByColumn[1]['full_name']); + } + + public function testDeserializeRoundTripsRenamedAndNestedProperties(): void { + $dto = new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London'); + $json = $this->serializer->serialize($dto); + + /** @var SerializerTestDto $deserialized */ + $deserialized = $this->serializer->deserialize($json, SerializerTestDto::class, Format::JSON); + + $this->assertSame('Jane Doe', $deserialized->name); + $this->assertSame('221B Baker Street', $deserialized->address); + $this->assertSame('London', $deserialized->city); + } + + public function testSerializeArrayOfObjects(): void { + $dtos = [ + new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London'), + new SerializerTestDto(name: 'John Doe', address: '10 Downing Street', city: 'London'), + ]; + + $json = $this->serializer->serialize($dtos, Format::JSON, ['groups' => ['basic']]); + + $this->assertJsonStringEqualsJsonString( + '[{"full_name":"Jane Doe","meta":{"city":"London"},"active":true},' + . '{"full_name":"John Doe","meta":{"city":"London"},"active":true}]', + $json, + ); + } + + public function testDeserializeArrayOfObjects(): void { + $dtos = [ + new SerializerTestDto(name: 'Jane Doe', address: '221B Baker Street', city: 'London'), + new SerializerTestDto(name: 'John Doe', address: '10 Downing Street', city: 'Paris'), + ]; + $json = $this->serializer->serialize($dtos); + + /** @var SerializerTestDto[] $deserialized */ + $deserialized = $this->serializer->deserialize($json, SerializerTestDto::class . '[]', Format::JSON); + + $this->assertCount(2, $deserialized); + $this->assertContainsOnlyInstancesOf(SerializerTestDto::class, $deserialized); + $this->assertSame('Jane Doe', $deserialized[0]->name); + $this->assertSame('Paris', $deserialized[1]->city); + } + + public function testDeserializeArrayOfObjectsFailsIfOneEntryIsInvalid(): void { + // ArrayDenormalizer denormalizes each entry through the same object denormalizer and + // does not catch its errors, so one invalid entry (missing the required "$address") is + // enough to fail the whole array, even though the first entry would have been valid. + $json = '[{"full_name":"Jane Doe","address":"221B Baker Street","meta":{"city":"London"}},{"meta":{"city":"Paris"}}]'; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('$address'); + $this->serializer->deserialize($json, SerializerTestDto::class . '[]', Format::JSON); + } + + public function testSerializeAndDeserializeScalarArrayProperty(): void { + $dto = new SerializerArrayTestDto(['first', 'second', 'third']); + + $json = $this->serializer->serialize($dto, Format::JSON, ['groups' => ['basic']]); + $this->assertJsonStringEqualsJsonString('{"tags":["first","second","third"]}', $json); + + /** @var SerializerArrayTestDto $deserialized */ + $deserialized = $this->serializer->deserialize($json, SerializerArrayTestDto::class, Format::JSON); + $this->assertSame(['first', 'second', 'third'], $deserialized->tags); + } + + public function testSerializeAndDeserializeEmptyArrayProperty(): void { + $dto = new SerializerArrayTestDto([]); + + $json = $this->serializer->serialize($dto, Format::JSON, ['groups' => ['basic']]); + $this->assertJsonStringEqualsJsonString('{"tags":[]}', $json); + + /** @var SerializerArrayTestDto $deserialized */ + $deserialized = $this->serializer->deserialize($json, SerializerArrayTestDto::class, Format::JSON); + $this->assertSame([], $deserialized->tags); + } + + public function testSerializeNestedObjectArrayProperty(): void { + $dto = new SerializerNestedArrayTestDto([ + new SerializerNestedItemTestDto('x'), + new SerializerNestedItemTestDto('y'), + ]); + + $json = $this->serializer->serialize($dto); + + $this->assertJsonStringEqualsJsonString('{"items":[{"label":"x"},{"label":"y"}]}', $json); + } + + public function testDeserializeNestedObjectArrayPropertyHydratesItems(): void { + // The "items" property is only typed as "array" natively; symfony/property-info's + // PhpDocExtractor reads the "@var SerializerNestedItemTestDto[]" docblock to learn the + // array's value type, which is what lets the denormalizer hydrate real instances here + // instead of leaving each entry as a plain associative array. + $json = '{"items":[{"label":"x"},{"label":"y"}]}'; + + /** @var SerializerNestedArrayTestDto $deserialized */ + $deserialized = $this->serializer->deserialize($json, SerializerNestedArrayTestDto::class, Format::JSON); + + $this->assertContainsOnlyInstancesOf(SerializerNestedItemTestDto::class, $deserialized->items); + $this->assertSame('x', $deserialized->items[0]->label); + $this->assertSame('y', $deserialized->items[1]->label); + } +} diff --git a/tests/lib/Serializer/SerializerTestDto.php b/tests/lib/Serializer/SerializerTestDto.php new file mode 100644 index 0000000000000..b2d86c143f79c --- /dev/null +++ b/tests/lib/Serializer/SerializerTestDto.php @@ -0,0 +1,56 @@ +