From b066b8604a9dff70cf5c552b2d249f05419428de Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Tue, 25 Aug 2026 00:00:38 +0200 Subject: [PATCH 1/3] 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 | 137 ++++++++++++ lib/private/Serializer/Serializer.php | 76 +++++++ lib/private/Server.php | 3 + lib/public/Serializer/Attribute/Groups.php | 60 +++++ lib/public/Serializer/Attribute/Ignore.php | 32 +++ .../Serializer/Attribute/SerializedName.php | 41 ++++ .../Serializer/Attribute/SerializedPath.php | 41 ++++ lib/public/Serializer/Format.php | 27 +++ lib/public/Serializer/ISerializer.php | 50 +++++ .../lib/Serializer/SerializerArrayTestDto.php | 21 ++ .../Serializer/SerializerCollisionTestDto.php | 28 +++ .../SerializerNestedArrayTestDto.php | 18 ++ .../SerializerNestedItemTestDto.php | 17 ++ tests/lib/Serializer/SerializerTest.php | 205 ++++++++++++++++++ tests/lib/Serializer/SerializerTestDto.php | 56 +++++ 18 files changed, 835 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/Groups.php create mode 100644 lib/public/Serializer/Attribute/Ignore.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/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 34fbffa593cce..b78ce8bf555f4 160000 --- a/3rdparty +++ b/3rdparty @@ -1 +1 @@ -Subproject commit 34fbffa593cced9b497267f3fa622da6e36304b2 +Subproject commit b78ce8bf555f4e454cefdf4c46a3fab58935ab59 diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index be06cdd45b860..cdb3aca310b6e 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -909,6 +909,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\\Groups' => $baseDir . '/lib/public/Serializer/Attribute/Groups.php', + 'OCP\\Serializer\\Attribute\\Ignore' => $baseDir . '/lib/public/Serializer/Attribute/Ignore.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 73277b5502fe1..f09d915eecf92 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', ), @@ -950,6 +950,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\\Groups' => __DIR__ . '/../../..' . '/lib/public/Serializer/Attribute/Groups.php', + 'OCP\\Serializer\\Attribute\\Ignore' => __DIR__ . '/../../..' . '/lib/public/Serializer/Attribute/Ignore.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..e621c117330ce --- /dev/null +++ b/lib/private/Serializer/AttributeLoader.php @@ -0,0 +1,137 @@ +getReflectionClass(); + $className = $reflectionClass->name; + $loaded = false; + + $classGroups = []; + foreach ($reflectionClass->getAttributes(Groups::class) as $attribute) { + $classGroups = $attribute->newInstance()->groups; + } + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + + foreach ($reflectionClass->getProperties() as $property) { + if ($property->getDeclaringClass()->name !== $className) { + continue; + } + + if (!isset($attributesMetadata[$property->name])) { + $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; + } + } + } + + 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]); + + // A property with the same name always wins, no accessor/mutator collision resolution. + if (isset($attributesMetadata[$attributeName])) { + continue; + } + + $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; + } + + 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()) { + Groups::class, Ignore::class, SerializedName::class, SerializedPath::class => $reflectionAttribute->newInstance(), + default => null, + }; + + match (true) { + $attribute instanceof Groups => array_map($attributeMetadata->addGroup(...), $attribute->groups), + $attribute instanceof Ignore => $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/Groups.php b/lib/public/Serializer/Attribute/Groups.php new file mode 100644 index 0000000000000..1dd44fe920050 --- /dev/null +++ b/lib/public/Serializer/Attribute/Groups.php @@ -0,0 +1,60 @@ +groups = (array)$groups; + + if (!$this->groups) { + throw new \InvalidArgumentException('The groups given to ' . self::class . ' cannot be empty.'); + } + + foreach ($this->groups as $group) { + if (!is_string($group) || $group === '') { + throw new \InvalidArgumentException('The groups given to ' . self::class . ' must be a string or an array of non-empty strings.'); + } + } + } +} diff --git a/lib/public/Serializer/Attribute/Ignore.php b/lib/public/Serializer/Attribute/Ignore.php new file mode 100644 index 0000000000000..0a8ed3ddb1f25 --- /dev/null +++ b/lib/public/Serializer/Attribute/Ignore.php @@ -0,0 +1,32 @@ + $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/SerializerArrayTestDto.php b/tests/lib/Serializer/SerializerArrayTestDto.php new file mode 100644 index 0000000000000..05852fdd786bc --- /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 testPropertyMetadataWinsOverAccessorWithSameDerivedName(): void { + $classMetadata = (new ClassMetadataFactory(new AttributeLoader()))->getMetadataFor(SerializerCollisionTestDto::class); + + $attributeMetadata = $classMetadata->getAttributesMetadata()['name']; + + $this->assertSame('property_name', $attributeMetadata->getSerializedName()); + } + + 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']]); + + $this->assertSame( + "full_name,meta.city,active\n" + . "\"Jane Doe\",London,1\n" + . "\"John Doe\",London,1\n", + $csv, + ); + } + + 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..064ee480b8e89 --- /dev/null +++ b/tests/lib/Serializer/SerializerTestDto.php @@ -0,0 +1,56 @@ + Date: Tue, 25 Aug 2026 00:23:12 +0200 Subject: [PATCH 2/3] feat: Import symfony/validator Signed-off-by: Carl Schwan --- lib/composer/composer/autoload_classmap.php | 12 ++ lib/composer/composer/autoload_static.php | 12 ++ lib/private/Server.php | 3 + lib/private/Validator/AttributeLoader.php | 127 +++++++++++++++++ lib/private/Validator/Validator.php | 40 ++++++ lib/public/Validator/Constraints/Choice.php | 53 +++++++ lib/public/Validator/Constraints/Count.php | 49 +++++++ lib/public/Validator/Constraints/Email.php | 36 +++++ lib/public/Validator/Constraints/Length.php | 49 +++++++ lib/public/Validator/Constraints/NotBlank.php | 39 ++++++ lib/public/Validator/Constraints/NotNull.php | 36 +++++ lib/public/Validator/Constraints/Range.php | 47 +++++++ lib/public/Validator/Constraints/Regex.php | 43 ++++++ lib/public/Validator/IValidator.php | 34 +++++ lib/public/Validator/Violation.php | 30 ++++ .../lib/Validator/ValidatorNotNullTestDto.php | 20 +++ tests/lib/Validator/ValidatorTest.php | 131 ++++++++++++++++++ tests/lib/Validator/ValidatorTestDto.php | 38 +++++ 18 files changed, 799 insertions(+) create mode 100644 lib/private/Validator/AttributeLoader.php create mode 100644 lib/private/Validator/Validator.php create mode 100644 lib/public/Validator/Constraints/Choice.php create mode 100644 lib/public/Validator/Constraints/Count.php create mode 100644 lib/public/Validator/Constraints/Email.php create mode 100644 lib/public/Validator/Constraints/Length.php create mode 100644 lib/public/Validator/Constraints/NotBlank.php create mode 100644 lib/public/Validator/Constraints/NotNull.php create mode 100644 lib/public/Validator/Constraints/Range.php create mode 100644 lib/public/Validator/Constraints/Regex.php create mode 100644 lib/public/Validator/IValidator.php create mode 100644 lib/public/Validator/Violation.php create mode 100644 tests/lib/Validator/ValidatorNotNullTestDto.php create mode 100644 tests/lib/Validator/ValidatorTest.php create mode 100644 tests/lib/Validator/ValidatorTestDto.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index cdb3aca310b6e..a0b551468985b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -1162,6 +1162,16 @@ 'OCP\\User\\IAvailabilityCoordinator' => $baseDir . '/lib/public/User/IAvailabilityCoordinator.php', 'OCP\\User\\IOutOfOfficeData' => $baseDir . '/lib/public/User/IOutOfOfficeData.php', 'OCP\\Util' => $baseDir . '/lib/public/Util.php', + 'OCP\\Validator\\Constraints\\Choice' => $baseDir . '/lib/public/Validator/Constraints/Choice.php', + 'OCP\\Validator\\Constraints\\Count' => $baseDir . '/lib/public/Validator/Constraints/Count.php', + 'OCP\\Validator\\Constraints\\Email' => $baseDir . '/lib/public/Validator/Constraints/Email.php', + 'OCP\\Validator\\Constraints\\Length' => $baseDir . '/lib/public/Validator/Constraints/Length.php', + 'OCP\\Validator\\Constraints\\NotBlank' => $baseDir . '/lib/public/Validator/Constraints/NotBlank.php', + 'OCP\\Validator\\Constraints\\NotNull' => $baseDir . '/lib/public/Validator/Constraints/NotNull.php', + 'OCP\\Validator\\Constraints\\Range' => $baseDir . '/lib/public/Validator/Constraints/Range.php', + 'OCP\\Validator\\Constraints\\Regex' => $baseDir . '/lib/public/Validator/Constraints/Regex.php', + 'OCP\\Validator\\IValidator' => $baseDir . '/lib/public/Validator/IValidator.php', + 'OCP\\Validator\\Violation' => $baseDir . '/lib/public/Validator/Violation.php', 'OCP\\WorkflowEngine\\EntityContext\\IContextPortation' => $baseDir . '/lib/public/WorkflowEngine/EntityContext/IContextPortation.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayName' => $baseDir . '/lib/public/WorkflowEngine/EntityContext/IDisplayName.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayText' => $baseDir . '/lib/public/WorkflowEngine/EntityContext/IDisplayText.php', @@ -2445,6 +2455,8 @@ 'OC\\User\\PartiallyDeletedUsersBackend' => $baseDir . '/lib/private/User/PartiallyDeletedUsersBackend.php', 'OC\\User\\Session' => $baseDir . '/lib/private/User/Session.php', 'OC\\User\\User' => $baseDir . '/lib/private/User/User.php', + 'OC\\Validator\\AttributeLoader' => $baseDir . '/lib/private/Validator/AttributeLoader.php', + 'OC\\Validator\\Validator' => $baseDir . '/lib/private/Validator/Validator.php', 'OC_App' => $baseDir . '/lib/private/legacy/OC_App.php', 'OC_Defaults' => $baseDir . '/lib/private/legacy/OC_Defaults.php', 'OC_Helper' => $baseDir . '/lib/private/legacy/OC_Helper.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index f09d915eecf92..ad6aec76e612e 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -1203,6 +1203,16 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\User\\IAvailabilityCoordinator' => __DIR__ . '/../../..' . '/lib/public/User/IAvailabilityCoordinator.php', 'OCP\\User\\IOutOfOfficeData' => __DIR__ . '/../../..' . '/lib/public/User/IOutOfOfficeData.php', 'OCP\\Util' => __DIR__ . '/../../..' . '/lib/public/Util.php', + 'OCP\\Validator\\Constraints\\Choice' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Choice.php', + 'OCP\\Validator\\Constraints\\Count' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Count.php', + 'OCP\\Validator\\Constraints\\Email' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Email.php', + 'OCP\\Validator\\Constraints\\Length' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Length.php', + 'OCP\\Validator\\Constraints\\NotBlank' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/NotBlank.php', + 'OCP\\Validator\\Constraints\\NotNull' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/NotNull.php', + 'OCP\\Validator\\Constraints\\Range' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Range.php', + 'OCP\\Validator\\Constraints\\Regex' => __DIR__ . '/../../..' . '/lib/public/Validator/Constraints/Regex.php', + 'OCP\\Validator\\IValidator' => __DIR__ . '/../../..' . '/lib/public/Validator/IValidator.php', + 'OCP\\Validator\\Violation' => __DIR__ . '/../../..' . '/lib/public/Validator/Violation.php', 'OCP\\WorkflowEngine\\EntityContext\\IContextPortation' => __DIR__ . '/../../..' . '/lib/public/WorkflowEngine/EntityContext/IContextPortation.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayName' => __DIR__ . '/../../..' . '/lib/public/WorkflowEngine/EntityContext/IDisplayName.php', 'OCP\\WorkflowEngine\\EntityContext\\IDisplayText' => __DIR__ . '/../../..' . '/lib/public/WorkflowEngine/EntityContext/IDisplayText.php', @@ -2486,6 +2496,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\User\\PartiallyDeletedUsersBackend' => __DIR__ . '/../../..' . '/lib/private/User/PartiallyDeletedUsersBackend.php', 'OC\\User\\Session' => __DIR__ . '/../../..' . '/lib/private/User/Session.php', 'OC\\User\\User' => __DIR__ . '/../../..' . '/lib/private/User/User.php', + 'OC\\Validator\\AttributeLoader' => __DIR__ . '/../../..' . '/lib/private/Validator/AttributeLoader.php', + 'OC\\Validator\\Validator' => __DIR__ . '/../../..' . '/lib/private/Validator/Validator.php', 'OC_App' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_App.php', 'OC_Defaults' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Defaults.php', 'OC_Helper' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Helper.php', diff --git a/lib/private/Server.php b/lib/private/Server.php index 40dcf4794a4ce..891d95e6b4999 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -157,6 +157,7 @@ use OC\User\Listeners\UserChangedListener; use OC\User\Session; use OC\User\User; +use OC\Validator\Validator as ConstraintValidator; use OCA\Theming\ImageManager; use OCA\Theming\Service\BackgroundService; use OCA\Theming\ThemingDefaults; @@ -297,6 +298,7 @@ use OCP\User\Events\UserLoggedInWithCookieEvent; use OCP\User\Events\UserLoggedOutEvent; use OCP\User\IAvailabilityCoordinator; +use OCP\Validator\IValidator as IConstraintValidator; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -1006,6 +1008,7 @@ public function __construct( }); $this->registerAlias(IEventDispatcher::class, EventDispatcher::class); $this->registerAlias(ISerializer::class, Serializer::class); + $this->registerAlias(IConstraintValidator::class, ConstraintValidator::class); /** @deprecated 35.0.0 */ $this->registerDeprecatedAlias('CryptoWrapper', CryptoWrapper::class); diff --git a/lib/private/Validator/AttributeLoader.php b/lib/private/Validator/AttributeLoader.php new file mode 100644 index 0000000000000..51165f631f10f --- /dev/null +++ b/lib/private/Validator/AttributeLoader.php @@ -0,0 +1,127 @@ +getReflectionClass(); + $className = $reflectionClass->name; + $loaded = false; + + foreach ($reflectionClass->getProperties() as $property) { + if ($property->getDeclaringClass()->name !== $className) { + continue; + } + + foreach ($property->getAttributes() as $reflectionAttribute) { + $constraint = $this->buildConstraint($reflectionAttribute); + if ($constraint === null) { + continue; + } + + $metadata->addPropertyConstraint($property->name, $constraint); + $loaded = true; + } + } + + return $loaded; + } + + private function buildConstraint(\ReflectionAttribute $reflectionAttribute): ?SymfonyConstraint { + $attribute = match ($reflectionAttribute->getName()) { + NotBlank::class, NotNull::class, Length::class, Email::class, Range::class, Choice::class, Regex::class, Count::class => $reflectionAttribute->newInstance(), + default => null, + }; + + return match (true) { + $attribute instanceof NotBlank => new Assert\NotBlank( + message: $attribute->message, + allowNull: $attribute->allowNull, + groups: $attribute->groups, + ), + $attribute instanceof NotNull => new Assert\NotNull( + message: $attribute->message, + groups: $attribute->groups, + ), + $attribute instanceof Length => new Assert\Length( + exactly: $attribute->exactly, + min: $attribute->min, + max: $attribute->max, + exactMessage: $attribute->exactMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + $attribute instanceof Email => new Assert\Email( + message: $attribute->message, + groups: $attribute->groups, + ), + $attribute instanceof Range => new Assert\Range( + min: $attribute->min, + max: $attribute->max, + notInRangeMessage: $attribute->notInRangeMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + $attribute instanceof Choice => new Assert\Choice( + choices: $attribute->choices, + multiple: $attribute->multiple, + min: $attribute->min, + max: $attribute->max, + message: $attribute->message, + multipleMessage: $attribute->multipleMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + $attribute instanceof Regex => new Assert\Regex( + pattern: $attribute->pattern, + match: $attribute->match, + message: $attribute->message, + groups: $attribute->groups, + ), + $attribute instanceof Count => new Assert\Count( + exactly: $attribute->exactly, + min: $attribute->min, + max: $attribute->max, + exactMessage: $attribute->exactMessage, + minMessage: $attribute->minMessage, + maxMessage: $attribute->maxMessage, + groups: $attribute->groups, + ), + default => null, + }; + } +} diff --git a/lib/private/Validator/Validator.php b/lib/private/Validator/Validator.php new file mode 100644 index 0000000000000..a7f3b5e25f195 --- /dev/null +++ b/lib/private/Validator/Validator.php @@ -0,0 +1,40 @@ +validator = (new ValidatorBuilder()) + ->addLoader(new AttributeLoader()) + ->getValidator(); + } + + #[\Override] + public function validate(mixed $data, string|array|null $groups = null): array { + $violations = $this->validator->validate($data, null, $groups); + + return array_map( + static fn (ConstraintViolationInterface $violation): Violation => new Violation( + propertyPath: $violation->getPropertyPath(), + message: (string)$violation->getMessage(), + invalidValue: $violation->getInvalidValue(), + ), + iterator_to_array($violations), + ); + } +} diff --git a/lib/public/Validator/Constraints/Choice.php b/lib/public/Validator/Constraints/Choice.php new file mode 100644 index 0000000000000..ea35ccba57e64 --- /dev/null +++ b/lib/public/Validator/Constraints/Choice.php @@ -0,0 +1,53 @@ + $choices the valid choices, must not be empty + * @param bool $multiple whether the value is an array of valid choices instead of a single one (defaults to false) + * @param int<0, max>|null $min the minimum number of valid choices, only used when `$multiple` is true + * @param positive-int|null $max the maximum number of valid choices, only used when `$multiple` is true + * @param string|null $message the error message for an invalid single choice, or null to use the built-in default + * @param string|null $multipleMessage the error message for an invalid choice in a multiple selection, or null to use the built-in default + * @param string|null $minMessage the error message when fewer than `$min` choices are given, or null to use the built-in default + * @param string|null $maxMessage the error message when more than `$max` choices are given, or null to use the built-in default + * @param string[]|null $groups the validation groups this constraint belongs to + * @since 36.0.0 + */ + public function __construct( + public readonly array $choices, + public readonly bool $multiple = false, + public readonly ?int $min = null, + public readonly ?int $max = null, + public readonly ?string $message = null, + public readonly ?string $multipleMessage = null, + public readonly ?string $minMessage = null, + public readonly ?string $maxMessage = null, + public readonly ?array $groups = null, + ) { + if (!$choices) { + throw new \InvalidArgumentException('The choices given to ' . self::class . ' cannot be empty.'); + } + } +} diff --git a/lib/public/Validator/Constraints/Count.php b/lib/public/Validator/Constraints/Count.php new file mode 100644 index 0000000000000..e27edef51b91b --- /dev/null +++ b/lib/public/Validator/Constraints/Count.php @@ -0,0 +1,49 @@ +|null $min the minimum expected number of elements + * @param int<0, max>|null $max the maximum expected number of elements + * @param int<0, max>|null $exactly the exact expected number of elements, equivalent to setting `$min` and `$max` to the same value + * @param string|null $minMessage the error message when there are too few elements, or null to use the built-in default + * @param string|null $maxMessage the error message when there are too many elements, or null to use the built-in default + * @param string|null $exactMessage the error message when `$exactly` is set and the count differs, or null to use the built-in default + * @param string[]|null $groups the validation groups this constraint belongs to + * @since 36.0.0 + */ + public function __construct( + public readonly ?int $min = null, + public readonly ?int $max = null, + public readonly ?int $exactly = null, + public readonly ?string $minMessage = null, + public readonly ?string $maxMessage = null, + public readonly ?string $exactMessage = null, + public readonly ?array $groups = null, + ) { + if ($min === null && $max === null && $exactly === null) { + throw new \InvalidArgumentException('At least one of "min", "max" or "exactly" must be set on ' . self::class . '.'); + } + } +} diff --git a/lib/public/Validator/Constraints/Email.php b/lib/public/Validator/Constraints/Email.php new file mode 100644 index 0000000000000..030b48bcc7858 --- /dev/null +++ b/lib/public/Validator/Constraints/Email.php @@ -0,0 +1,36 @@ +|null $min the minimum expected length + * @param positive-int|null $max the maximum expected length + * @param positive-int|null $exactly the exact expected length, equivalent to setting `$min` and `$max` to the same value + * @param string|null $minMessage the error message when the value is too short, or null to use the built-in default + * @param string|null $maxMessage the error message when the value is too long, or null to use the built-in default + * @param string|null $exactMessage the error message when `$exactly` is set and the length differs, or null to use the built-in default + * @param string[]|null $groups the validation groups this constraint belongs to + * @since 36.0.0 + */ + public function __construct( + public readonly ?int $min = null, + public readonly ?int $max = null, + public readonly ?int $exactly = null, + public readonly ?string $minMessage = null, + public readonly ?string $maxMessage = null, + public readonly ?string $exactMessage = null, + public readonly ?array $groups = null, + ) { + if ($min === null && $max === null && $exactly === null) { + throw new \InvalidArgumentException('At least one of "min", "max" or "exactly" must be set on ' . self::class . '.'); + } + } +} diff --git a/lib/public/Validator/Constraints/NotBlank.php b/lib/public/Validator/Constraints/NotBlank.php new file mode 100644 index 0000000000000..8b9d160202134 --- /dev/null +++ b/lib/public/Validator/Constraints/NotBlank.php @@ -0,0 +1,39 @@ +validator = new Validator(); + } + + private function validDto(): ValidatorTestDto { + return new ValidatorTestDto( + name: 'Jane', + email: 'jane@example.com', + age: 30, + role: 'admin', + username: 'jane_doe', + tags: ['a'], + ); + } + + public function testServiceIsRegistered(): void { + $this->assertInstanceOf(Validator::class, Server::get(IValidator::class)); + } + + public function testValidDataProducesNoViolations(): void { + $this->assertSame([], $this->validator->validate($this->validDto())); + } + + public function testNotBlankConstraint(): void { + // An empty string also violates the Length(min: 2) constraint on the same property, + // so both are expected to fire. + $dto = new ValidatorTestDto(name: '', email: 'jane@example.com', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(2, $violations); + $this->assertSame('name', $violations[0]->propertyPath); + $this->assertSame('name', $violations[1]->propertyPath); + } + + public function testLengthConstraint(): void { + $dto = new ValidatorTestDto(name: 'J', email: 'jane@example.com', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('name', $violations[0]->propertyPath); + $this->assertSame('J', $violations[0]->invalidValue); + } + + public function testEmailConstraintChecksWithMatchingGroup(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'not-an-email', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto, groups: ['detailed']); + + $this->assertCount(1, $violations); + $this->assertSame('email', $violations[0]->propertyPath); + $this->assertSame('not-an-email', $violations[0]->invalidValue); + } + + public function testEmailConstraintIsSkippedWithoutMatchingGroup(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'not-an-email', age: 30, role: 'admin', username: 'jane_doe', tags: ['a']); + + // The Email constraint only belongs to the "detailed" group, so validating without + // groups (the implicit "Default" group) does not check it. + $this->assertSame([], $this->validator->validate($dto)); + } + + public function testRangeConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 200, role: 'admin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('age', $violations[0]->propertyPath); + } + + public function testChoiceConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 30, role: 'superadmin', username: 'jane_doe', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('role', $violations[0]->propertyPath); + } + + public function testRegexConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 30, role: 'admin', username: 'Jane Doe!', tags: ['a']); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('username', $violations[0]->propertyPath); + } + + public function testCountConstraint(): void { + $dto = new ValidatorTestDto(name: 'Jane', email: 'jane@example.com', age: 30, role: 'admin', username: 'jane_doe', tags: []); + + $violations = $this->validator->validate($dto); + + $this->assertCount(1, $violations); + $this->assertSame('tags', $violations[0]->propertyPath); + } + + public function testNotNullConstraint(): void { + $violations = $this->validator->validate(new ValidatorNotNullTestDto(value: null)); + + $this->assertCount(1, $violations); + $this->assertSame('value', $violations[0]->propertyPath); + } + + public function testNotNullConstraintPassesForNonNullValue(): void { + $this->assertSame([], $this->validator->validate(new ValidatorNotNullTestDto(value: 'something'))); + } +} diff --git a/tests/lib/Validator/ValidatorTestDto.php b/tests/lib/Validator/ValidatorTestDto.php new file mode 100644 index 0000000000000..919f9de91f719 --- /dev/null +++ b/tests/lib/Validator/ValidatorTestDto.php @@ -0,0 +1,38 @@ + Date: Tue, 25 Aug 2026 10:34:52 +0200 Subject: [PATCH 3/3] feat(http): Wire Validator and Serializer in http request dispatcher Allow to write the following class PersonController extends Controller { public function create(#[RequestPayload] PersonDto $person): DataResponse { // $person is already deserialized and valid at this point } } Signed-off-by: Carl Schwan --- lib/composer/composer/autoload_classmap.php | 4 + lib/composer/composer/autoload_static.php | 4 + lib/private/AppFramework/Http/Dispatcher.php | 13 ++- lib/private/AppFramework/Http/Request.php | 12 +++ .../Http/RequestPayloadResolver.php | 51 +++++++++++ .../Middleware/InvalidParameterMiddleware.php | 24 ++++- .../Utility/ControllerMethodReflector.php | 18 ++++ .../Http/Attribute/RequestPayload.php | 44 +++++++++ .../Http/InvalidPayloadException.php | 28 ++++++ .../Http/ValidationFailedException.php | 31 +++++++ lib/public/IRequest.php | 9 ++ .../lib/AppFramework/Http/DispatcherTest.php | 46 ++++++++++ .../Http/RequestPayloadResolverTest.php | 89 +++++++++++++++++++ tests/lib/AppFramework/Http/RequestTest.php | 35 ++++++++ .../InvalidParameterMiddlewareTest.php | 69 ++++++++++++++ 15 files changed, 473 insertions(+), 4 deletions(-) create mode 100644 lib/private/AppFramework/Http/RequestPayloadResolver.php create mode 100644 lib/public/AppFramework/Http/Attribute/RequestPayload.php create mode 100644 lib/public/AppFramework/Http/InvalidPayloadException.php create mode 100644 lib/public/AppFramework/Http/ValidationFailedException.php create mode 100644 tests/lib/AppFramework/Http/RequestPayloadResolverTest.php create mode 100644 tests/lib/AppFramework/Middleware/InvalidParameterMiddlewareTest.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index a0b551468985b..73c3aa408235b 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -138,6 +138,7 @@ 'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PublicPage.php', 'OCP\\AppFramework\\Http\\Attribute\\RequestHeader' => $baseDir . '/lib/public/AppFramework/Http/Attribute/RequestHeader.php', + 'OCP\\AppFramework\\Http\\Attribute\\RequestPayload' => $baseDir . '/lib/public/AppFramework/Http/Attribute/RequestPayload.php', 'OCP\\AppFramework\\Http\\Attribute\\Route' => $baseDir . '/lib/public/AppFramework/Http/Attribute/Route.php', 'OCP\\AppFramework\\Http\\Attribute\\StrictCookiesRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/StrictCookiesRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\SubAdminRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/SubAdminRequired.php', @@ -157,6 +158,7 @@ 'OCP\\AppFramework\\Http\\ICallbackResponse' => $baseDir . '/lib/public/AppFramework/Http/ICallbackResponse.php', 'OCP\\AppFramework\\Http\\IOutput' => $baseDir . '/lib/public/AppFramework/Http/IOutput.php', 'OCP\\AppFramework\\Http\\InvalidEnumParameterException' => $baseDir . '/lib/public/AppFramework/Http/InvalidEnumParameterException.php', + 'OCP\\AppFramework\\Http\\InvalidPayloadException' => $baseDir . '/lib/public/AppFramework/Http/InvalidPayloadException.php', 'OCP\\AppFramework\\Http\\InvalidStringParameterException' => $baseDir . '/lib/public/AppFramework/Http/InvalidStringParameterException.php', 'OCP\\AppFramework\\Http\\JSONResponse' => $baseDir . '/lib/public/AppFramework/Http/JSONResponse.php', 'OCP\\AppFramework\\Http\\NotFoundResponse' => $baseDir . '/lib/public/AppFramework/Http/NotFoundResponse.php', @@ -176,6 +178,7 @@ 'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php', 'OCP\\AppFramework\\Http\\TextPlainResponse' => $baseDir . '/lib/public/AppFramework/Http/TextPlainResponse.php', 'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => $baseDir . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php', + 'OCP\\AppFramework\\Http\\ValidationFailedException' => $baseDir . '/lib/public/AppFramework/Http/ValidationFailedException.php', 'OCP\\AppFramework\\Http\\ZipResponse' => $baseDir . '/lib/public/AppFramework/Http/ZipResponse.php', 'OCP\\AppFramework\\IAppContainer' => $baseDir . '/lib/public/AppFramework/IAppContainer.php', 'OCP\\AppFramework\\Middleware' => $baseDir . '/lib/public/AppFramework/Middleware.php', @@ -1225,6 +1228,7 @@ 'OC\\AppFramework\\Http\\Output' => $baseDir . '/lib/private/AppFramework/Http/Output.php', 'OC\\AppFramework\\Http\\Request' => $baseDir . '/lib/private/AppFramework/Http/Request.php', 'OC\\AppFramework\\Http\\RequestId' => $baseDir . '/lib/private/AppFramework/Http/RequestId.php', + 'OC\\AppFramework\\Http\\RequestPayloadResolver' => $baseDir . '/lib/private/AppFramework/Http/RequestPayloadResolver.php', 'OC\\AppFramework\\Middleware\\AdditionalScriptsMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php', 'OC\\AppFramework\\Middleware\\CompressionMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/CompressionMiddleware.php', 'OC\\AppFramework\\Middleware\\InvalidParameterMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index ad6aec76e612e..42ca58365c68c 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -179,6 +179,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PublicPage.php', 'OCP\\AppFramework\\Http\\Attribute\\RequestHeader' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/RequestHeader.php', + 'OCP\\AppFramework\\Http\\Attribute\\RequestPayload' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/RequestPayload.php', 'OCP\\AppFramework\\Http\\Attribute\\Route' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/Route.php', 'OCP\\AppFramework\\Http\\Attribute\\StrictCookiesRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/StrictCookiesRequired.php', 'OCP\\AppFramework\\Http\\Attribute\\SubAdminRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/SubAdminRequired.php', @@ -198,6 +199,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\ICallbackResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ICallbackResponse.php', 'OCP\\AppFramework\\Http\\IOutput' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/IOutput.php', 'OCP\\AppFramework\\Http\\InvalidEnumParameterException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidEnumParameterException.php', + 'OCP\\AppFramework\\Http\\InvalidPayloadException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidPayloadException.php', 'OCP\\AppFramework\\Http\\InvalidStringParameterException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidStringParameterException.php', 'OCP\\AppFramework\\Http\\JSONResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/JSONResponse.php', 'OCP\\AppFramework\\Http\\NotFoundResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/NotFoundResponse.php', @@ -217,6 +219,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php', 'OCP\\AppFramework\\Http\\TextPlainResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TextPlainResponse.php', 'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php', + 'OCP\\AppFramework\\Http\\ValidationFailedException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ValidationFailedException.php', 'OCP\\AppFramework\\Http\\ZipResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ZipResponse.php', 'OCP\\AppFramework\\IAppContainer' => __DIR__ . '/../../..' . '/lib/public/AppFramework/IAppContainer.php', 'OCP\\AppFramework\\Middleware' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Middleware.php', @@ -1266,6 +1269,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\AppFramework\\Http\\Output' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Output.php', 'OC\\AppFramework\\Http\\Request' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Request.php', 'OC\\AppFramework\\Http\\RequestId' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/RequestId.php', + 'OC\\AppFramework\\Http\\RequestPayloadResolver' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/RequestPayloadResolver.php', 'OC\\AppFramework\\Middleware\\AdditionalScriptsMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php', 'OC\\AppFramework\\Middleware\\CompressionMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/CompressionMiddleware.php', 'OC\\AppFramework\\Middleware\\InvalidParameterMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php', diff --git a/lib/private/AppFramework/Http/Dispatcher.php b/lib/private/AppFramework/Http/Dispatcher.php index 21999c0cc77c5..28af1dd622d99 100644 --- a/lib/private/AppFramework/Http/Dispatcher.php +++ b/lib/private/AppFramework/Http/Dispatcher.php @@ -16,6 +16,7 @@ use OC\DB\ConnectionAdapter; use OCP\App\IAppManager; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\RequestPayload; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\InvalidEnumParameterException; use OCP\AppFramework\Http\InvalidStringParameterException; @@ -53,6 +54,7 @@ public function __construct( private readonly IEventLogger $eventLogger, private readonly ContainerInterface $appContainer, private readonly IUserSession $userSession, + private readonly RequestPayloadResolver $requestPayloadResolver, ) { } @@ -155,10 +157,19 @@ private function executeController(Controller $controller, string $methodName): $types = ['int', 'integer', 'bool', 'boolean', 'float', 'double']; foreach ($this->reflector->getParameters() as $param => $default) { + $type = $this->reflector->getType($param); + + $payloadAttribute = $type !== null ? $this->reflector->getParameterAttribute($param, RequestPayload::class) : null; + if ($payloadAttribute !== null) { + /** @var RequestPayload $requestPayload */ + $requestPayload = $payloadAttribute->newInstance(); + $arguments[] = $this->requestPayloadResolver->resolve($param, $type, $this->request->getRawContent(), $requestPayload->validationGroups); + continue; + } + // try to get the parameter from the request object and cast // it to the type annotated in the @param annotation $value = $this->request->getParam($param, $default); - $type = $this->reflector->getType($param); // Converted the string `'false'` to false when the controller wants a boolean if ($value === 'false' && ($type === 'bool' || $type === 'boolean')) { diff --git a/lib/private/AppFramework/Http/Request.php b/lib/private/AppFramework/Http/Request.php index 75c2b8568e560..3af29cf1caf63 100644 --- a/lib/private/AppFramework/Http/Request.php +++ b/lib/private/AppFramework/Http/Request.php @@ -90,6 +90,11 @@ class Request implements \ArrayAccess, \Countable, IRequest { */ private ?\JsonException $decodingException = null; + /** + * The raw request body, when the Content-Type was JSON-compatible. + */ + private ?string $rawContent = null; + /** * @param array $vars Associative request data with the following optional keys: * - array 'urlParams' route parameters extracted from the URL @@ -388,6 +393,7 @@ protected function decodeContent() { if (preg_match(self::JSON_CONTENT_TYPE_REGEX, $this->getHeader('Content-Type')) === 1) { $content = file_get_contents($this->inputStream); if ($content !== '') { + $this->rawContent = $content; try { $params = json_decode($content, true, flags:JSON_THROW_ON_ERROR); } catch (\JsonException $e) { @@ -423,6 +429,12 @@ public function throwDecodingExceptionIfAny(): void { } } + #[\Override] + public function getRawContent(): ?string { + $this->decodeContent(); + return $this->rawContent; + } + #[\Override] public function passesCSRFCheck(): bool { if ($this->csrfTokenManager === null) { diff --git a/lib/private/AppFramework/Http/RequestPayloadResolver.php b/lib/private/AppFramework/Http/RequestPayloadResolver.php new file mode 100644 index 0000000000000..36acdb78b89d8 --- /dev/null +++ b/lib/private/AppFramework/Http/RequestPayloadResolver.php @@ -0,0 +1,51 @@ + $type + * @param string|string[]|null $validationGroups + * @return T + * @throws InvalidPayloadException if `$rawContent` is not valid JSON, or does not satisfy `$type`'s constructor + * @throws ValidationFailedException if the built object does not satisfy its own validation constraints + */ + public function resolve(string $parameterName, string $type, ?string $rawContent, string|array|null $validationGroups): object { + try { + $payload = $this->serializer->deserialize($rawContent ?? '', $type, Format::JSON); + } catch (\Throwable $e) { + throw new InvalidPayloadException($parameterName, $e->getMessage()); + } + + $violations = $this->validator->validate($payload, $validationGroups); + if ($violations !== []) { + throw new ValidationFailedException($parameterName, $violations); + } + + return $payload; + } +} diff --git a/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php b/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php index e0aa306a569d8..6efeead705492 100644 --- a/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php +++ b/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php @@ -13,14 +13,18 @@ use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\InvalidEnumParameterException; +use OCP\AppFramework\Http\InvalidPayloadException; use OCP\AppFramework\Http\InvalidStringParameterException; use OCP\AppFramework\Http\ParameterOutOfRangeException; use OCP\AppFramework\Http\Response; +use OCP\AppFramework\Http\ValidationFailedException; use OCP\AppFramework\Middleware; +use OCP\Validator\Violation; /** - * Turns a controller parameter validation failure detected by the Dispatcher - * into a 400 Bad Request response. + * Turns a controller parameter validation failure detected by the Dispatcher into a 400 Bad + * Request response, or a 422 Unprocessable Entity response for a failed + * {@see \OCP\AppFramework\Http\Attribute\RequestPayload} validation. */ class InvalidParameterMiddleware extends Middleware { /** @@ -30,10 +34,24 @@ class InvalidParameterMiddleware extends Middleware { public function afterException(Controller $controller, string $methodName, \Exception $exception): Response { if ($exception instanceof ParameterOutOfRangeException || $exception instanceof InvalidStringParameterException - || $exception instanceof InvalidEnumParameterException) { + || $exception instanceof InvalidEnumParameterException + || $exception instanceof InvalidPayloadException) { return new DataResponse(['message' => $exception->getMessage()], Http::STATUS_BAD_REQUEST); } + if ($exception instanceof ValidationFailedException) { + return new DataResponse([ + 'message' => $exception->getMessage(), + 'violations' => array_map( + static fn (Violation $violation): array => [ + 'propertyPath' => $violation->propertyPath, + 'message' => $violation->message, + ], + $exception->violations, + ), + ], Http::STATUS_UNPROCESSABLE_ENTITY); + } + throw $exception; } } diff --git a/lib/private/AppFramework/Utility/ControllerMethodReflector.php b/lib/private/AppFramework/Utility/ControllerMethodReflector.php index 3c2a7d01f618e..eacc789f85d0e 100644 --- a/lib/private/AppFramework/Utility/ControllerMethodReflector.php +++ b/lib/private/AppFramework/Utility/ControllerMethodReflector.php @@ -198,6 +198,24 @@ public function getAttribute(string $attributeClass): ?\ReflectionAttribute { return null; } + /** + * @template T + * @param class-string $attributeClass + * @return ?\ReflectionAttribute + */ + public function getParameterAttribute(string $parameter, string $attributeClass): ?\ReflectionAttribute { + foreach ($this->reflectionMethod->getParameters() as $reflectionParameter) { + if ($reflectionParameter->name !== $parameter) { + continue; + } + + $attributes = $reflectionParameter->getAttributes($attributeClass); + return $attributes[0] ?? null; + } + + return null; + } + /** * Check if a method contains an annotation * @param string $name the name of the annotation diff --git a/lib/public/AppFramework/Http/Attribute/RequestPayload.php b/lib/public/AppFramework/Http/Attribute/RequestPayload.php new file mode 100644 index 0000000000000..e2f264ed363c1 --- /dev/null +++ b/lib/public/AppFramework/Http/Attribute/RequestPayload.php @@ -0,0 +1,44 @@ +eventLogger = $this->createMock(IEventLogger::class); $this->container = $this->createMock(ContainerInterface::class); $this->userSession = $this->createMock(IUserSession::class); + $this->requestPayloadResolver = $this->createMock(RequestPayloadResolver::class); $app = $this->createMock(DIContainer::class); $request = $this->createMock(Request::class); $this->http = $this->createMock(\OC\AppFramework\Http::class); @@ -164,6 +180,7 @@ protected function setUp(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $this->response = $this->createMock(Response::class); @@ -335,6 +352,7 @@ public function testControllerParametersInjected(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -345,6 +363,21 @@ public function testControllerParametersInjected(): void { $this->assertEquals('[3,false,4,1]', $response[3]); } + public function testRequestPayloadParameterIsResolvedViaRequestPayloadResolver(): void { + $payload = new TestRequestPayloadDto('Jane Doe'); + $this->requestPayloadResolver->expects($this->once()) + ->method('resolve') + ->with('payload', TestRequestPayloadDto::class, null, null) + ->willReturn($payload); + + $controller = new TestController('app', $this->request); + + $this->dispatcherPassthrough(); + $response = $this->dispatcher->dispatch($controller, 'execRequestPayload'); + + $this->assertJsonStringEqualsJsonString(json_encode([$payload]), $response[3]); + } + public function testControllerParametersInjectedStringBackedEnum(): void { $this->request = new Request( [ @@ -365,6 +398,7 @@ public function testControllerParametersInjectedStringBackedEnum(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -394,6 +428,7 @@ public function testControllerParametersInjectedIntBackedEnum(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -421,6 +456,7 @@ public function testControllerParametersInjectedNullableBackedEnumDefault(): voi $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -453,6 +489,7 @@ public function testControllerParametersInjectedDefaultOverwritten(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -488,6 +525,7 @@ public function testResponseTransformedByUrlFormat(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -523,6 +561,7 @@ public function testResponseTransformsDataResponse(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -559,6 +598,7 @@ public function testResponseTransformedByAcceptHeader(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -595,6 +635,7 @@ public function testResponseTransformedBySendingMultipartFormData(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -633,6 +674,7 @@ public function testResponsePrimarilyTransformedByParameterFormat(): void { $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $controller = new TestController('app', $this->request); @@ -696,6 +738,7 @@ public function testEnsureParameterValueSatisfiesRange(?int $min, ?int $max, int $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); if ($throw) { @@ -738,6 +781,7 @@ public function testEnsureParameterValueSatisfiesStringConstraint(bool $satisfie $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); if ($throw) { @@ -775,6 +819,7 @@ public function testResolveBackedEnumValue(string $enumClass, string|int $input, $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $result = self::invokePrivate($this->dispatcher, 'resolveBackedEnumValue', ['myArgument', $enumClass, $input]); @@ -804,6 +849,7 @@ public function testResolveBackedEnumValueThrowsOnInvalidValue(string $enumClass $this->eventLogger, $this->container, $this->userSession, + $this->requestPayloadResolver, ); $this->expectException(InvalidEnumParameterException::class); diff --git a/tests/lib/AppFramework/Http/RequestPayloadResolverTest.php b/tests/lib/AppFramework/Http/RequestPayloadResolverTest.php new file mode 100644 index 0000000000000..7d746cc451d41 --- /dev/null +++ b/tests/lib/AppFramework/Http/RequestPayloadResolverTest.php @@ -0,0 +1,89 @@ +resolver = new RequestPayloadResolver(new Serializer(), new Validator()); + } + + public function testResolvesAValidPayload(): void { + $dto = $this->resolver->resolve( + 'person', + RequestPayloadResolverTestDto::class, + '{"full_name": "Jane Doe", "email": "jane@example.com"}', + null, + ); + + $this->assertInstanceOf(RequestPayloadResolverTestDto::class, $dto); + $this->assertSame('Jane Doe', $dto->name); + $this->assertSame('jane@example.com', $dto->email); + } + + public function testThrowsInvalidPayloadExceptionOnMalformedJson(): void { + $this->expectException(InvalidPayloadException::class); + $this->expectExceptionMessage('person'); + + $this->resolver->resolve('person', RequestPayloadResolverTestDto::class, '{not json', null); + } + + public function testThrowsInvalidPayloadExceptionOnEmptyBody(): void { + $this->expectException(InvalidPayloadException::class); + + $this->resolver->resolve('person', RequestPayloadResolverTestDto::class, null, null); + } + + public function testThrowsInvalidPayloadExceptionOnMissingRequiredField(): void { + $this->expectException(InvalidPayloadException::class); + + $this->resolver->resolve('person', RequestPayloadResolverTestDto::class, '{"email": "jane@example.com"}', null); + } + + public function testThrowsValidationFailedExceptionOnConstraintViolation(): void { + try { + $this->resolver->resolve( + 'person', + RequestPayloadResolverTestDto::class, + '{"full_name": "Jane Doe", "email": "not-an-email"}', + null, + ); + $this->fail('Expected ' . ValidationFailedException::class . ' to be thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame('person', $e->parameterName); + $this->assertCount(1, $e->violations); + $this->assertSame('email', $e->violations[0]->propertyPath); + } + } +} diff --git a/tests/lib/AppFramework/Http/RequestTest.php b/tests/lib/AppFramework/Http/RequestTest.php index 306bdc956726f..320bdde50e5e1 100644 --- a/tests/lib/AppFramework/Http/RequestTest.php +++ b/tests/lib/AppFramework/Http/RequestTest.php @@ -203,6 +203,41 @@ public function testJsonPost(): void { $this->assertSame('Joey', $request['nickname']); } + public function testGetRawContentReturnsTheUndecodedJsonBody(): void { + global $data; + $data = '{"name": "John Q. Public", "nickname": "Joey"}'; + $vars = [ + 'method' => 'POST', + 'server' => ['CONTENT_TYPE' => 'application/json; utf-8'] + ]; + + $request = new Request( + $vars, + $this->requestId, + $this->config, + $this->csrfTokenManager, + $this->stream + ); + + $this->assertSame($data, $request->getRawContent()); + } + + public function testGetRawContentReturnsNullForNonJsonRequests(): void { + $vars = [ + 'method' => 'GET', + ]; + + $request = new Request( + $vars, + $this->requestId, + $this->config, + $this->csrfTokenManager, + $this->stream + ); + + $this->assertNull($request->getRawContent()); + } + public function testScimJsonPost(): void { global $data; $data = '{"userName":"testusername", "displayName":"Example User"}'; diff --git a/tests/lib/AppFramework/Middleware/InvalidParameterMiddlewareTest.php b/tests/lib/AppFramework/Middleware/InvalidParameterMiddlewareTest.php new file mode 100644 index 0000000000000..58e81bc5c55ec --- /dev/null +++ b/tests/lib/AppFramework/Middleware/InvalidParameterMiddlewareTest.php @@ -0,0 +1,69 @@ +middleware = new InvalidParameterMiddleware(); + $this->controller = $this->createMock(Controller::class); + } + + public function testInvalidPayloadExceptionBecomesBadRequest(): void { + $response = $this->middleware->afterException( + $this->controller, + 'create', + new InvalidPayloadException('person', 'malformed JSON'), + ); + + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + + public function testValidationFailedExceptionBecomesUnprocessableEntityWithViolations(): void { + $violations = [ + new Violation('email', 'This value is not a valid email address.', 'not-an-email'), + ]; + + $response = $this->middleware->afterException( + $this->controller, + 'create', + new ValidationFailedException('person', $violations), + ); + + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertSame(Http::STATUS_UNPROCESSABLE_ENTITY, $response->getStatus()); + $this->assertSame([ + 'propertyPath' => 'email', + 'message' => 'This value is not a valid email address.', + ], $response->getData()['violations'][0]); + } + + public function testOtherExceptionsAreRethrown(): void { + $exception = new \Exception('unrelated'); + + $this->expectExceptionObject($exception); + + $this->middleware->afterException($this->controller, 'create', $exception); + } +}