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..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', @@ -909,6 +912,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', @@ -1156,6 +1165,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', @@ -1209,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', @@ -2316,6 +2336,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', @@ -2437,6 +2459,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 73277b5502fe1..42ca58365c68c 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', ), @@ -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', @@ -950,6 +953,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', @@ -1197,6 +1206,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', @@ -1250,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', @@ -2357,6 +2377,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', @@ -2478,6 +2500,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/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/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..891d95e6b4999 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; @@ -156,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; @@ -269,6 +271,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; @@ -295,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; @@ -1003,6 +1007,8 @@ 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/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 @@ +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/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 @@ +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); + } +} 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 @@ +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 @@ +