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 @@ +