Skip to content

Commit 123a24b

Browse files
committed
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 <carl@carlschwan.eu>
1 parent 3c102a7 commit 123a24b

15 files changed

Lines changed: 473 additions & 4 deletions

lib/composer/composer/autoload_classmap.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php',
139139
'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => $baseDir . '/lib/public/AppFramework/Http/Attribute/PublicPage.php',
140140
'OCP\\AppFramework\\Http\\Attribute\\RequestHeader' => $baseDir . '/lib/public/AppFramework/Http/Attribute/RequestHeader.php',
141+
'OCP\\AppFramework\\Http\\Attribute\\RequestPayload' => $baseDir . '/lib/public/AppFramework/Http/Attribute/RequestPayload.php',
141142
'OCP\\AppFramework\\Http\\Attribute\\Route' => $baseDir . '/lib/public/AppFramework/Http/Attribute/Route.php',
142143
'OCP\\AppFramework\\Http\\Attribute\\StrictCookiesRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/StrictCookiesRequired.php',
143144
'OCP\\AppFramework\\Http\\Attribute\\SubAdminRequired' => $baseDir . '/lib/public/AppFramework/Http/Attribute/SubAdminRequired.php',
@@ -157,6 +158,7 @@
157158
'OCP\\AppFramework\\Http\\ICallbackResponse' => $baseDir . '/lib/public/AppFramework/Http/ICallbackResponse.php',
158159
'OCP\\AppFramework\\Http\\IOutput' => $baseDir . '/lib/public/AppFramework/Http/IOutput.php',
159160
'OCP\\AppFramework\\Http\\InvalidEnumParameterException' => $baseDir . '/lib/public/AppFramework/Http/InvalidEnumParameterException.php',
161+
'OCP\\AppFramework\\Http\\InvalidPayloadException' => $baseDir . '/lib/public/AppFramework/Http/InvalidPayloadException.php',
160162
'OCP\\AppFramework\\Http\\InvalidStringParameterException' => $baseDir . '/lib/public/AppFramework/Http/InvalidStringParameterException.php',
161163
'OCP\\AppFramework\\Http\\JSONResponse' => $baseDir . '/lib/public/AppFramework/Http/JSONResponse.php',
162164
'OCP\\AppFramework\\Http\\NotFoundResponse' => $baseDir . '/lib/public/AppFramework/Http/NotFoundResponse.php',
@@ -176,6 +178,7 @@
176178
'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => $baseDir . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php',
177179
'OCP\\AppFramework\\Http\\TextPlainResponse' => $baseDir . '/lib/public/AppFramework/Http/TextPlainResponse.php',
178180
'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => $baseDir . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php',
181+
'OCP\\AppFramework\\Http\\ValidationFailedException' => $baseDir . '/lib/public/AppFramework/Http/ValidationFailedException.php',
179182
'OCP\\AppFramework\\Http\\ZipResponse' => $baseDir . '/lib/public/AppFramework/Http/ZipResponse.php',
180183
'OCP\\AppFramework\\IAppContainer' => $baseDir . '/lib/public/AppFramework/IAppContainer.php',
181184
'OCP\\AppFramework\\Middleware' => $baseDir . '/lib/public/AppFramework/Middleware.php',
@@ -1225,6 +1228,7 @@
12251228
'OC\\AppFramework\\Http\\Output' => $baseDir . '/lib/private/AppFramework/Http/Output.php',
12261229
'OC\\AppFramework\\Http\\Request' => $baseDir . '/lib/private/AppFramework/Http/Request.php',
12271230
'OC\\AppFramework\\Http\\RequestId' => $baseDir . '/lib/private/AppFramework/Http/RequestId.php',
1231+
'OC\\AppFramework\\Http\\RequestPayloadResolver' => $baseDir . '/lib/private/AppFramework/Http/RequestPayloadResolver.php',
12281232
'OC\\AppFramework\\Middleware\\AdditionalScriptsMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php',
12291233
'OC\\AppFramework\\Middleware\\CompressionMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/CompressionMiddleware.php',
12301234
'OC\\AppFramework\\Middleware\\InvalidParameterMiddleware' => $baseDir . '/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php',

lib/composer/composer/autoload_static.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
179179
'OCP\\AppFramework\\Http\\Attribute\\PasswordConfirmationRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PasswordConfirmationRequired.php',
180180
'OCP\\AppFramework\\Http\\Attribute\\PublicPage' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/PublicPage.php',
181181
'OCP\\AppFramework\\Http\\Attribute\\RequestHeader' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/RequestHeader.php',
182+
'OCP\\AppFramework\\Http\\Attribute\\RequestPayload' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/RequestPayload.php',
182183
'OCP\\AppFramework\\Http\\Attribute\\Route' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/Route.php',
183184
'OCP\\AppFramework\\Http\\Attribute\\StrictCookiesRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/StrictCookiesRequired.php',
184185
'OCP\\AppFramework\\Http\\Attribute\\SubAdminRequired' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Attribute/SubAdminRequired.php',
@@ -198,6 +199,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
198199
'OCP\\AppFramework\\Http\\ICallbackResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ICallbackResponse.php',
199200
'OCP\\AppFramework\\Http\\IOutput' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/IOutput.php',
200201
'OCP\\AppFramework\\Http\\InvalidEnumParameterException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidEnumParameterException.php',
202+
'OCP\\AppFramework\\Http\\InvalidPayloadException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidPayloadException.php',
201203
'OCP\\AppFramework\\Http\\InvalidStringParameterException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/InvalidStringParameterException.php',
202204
'OCP\\AppFramework\\Http\\JSONResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/JSONResponse.php',
203205
'OCP\\AppFramework\\Http\\NotFoundResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/NotFoundResponse.php',
@@ -217,6 +219,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
217219
'OCP\\AppFramework\\Http\\Template\\SimpleMenuAction' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Template/SimpleMenuAction.php',
218220
'OCP\\AppFramework\\Http\\TextPlainResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TextPlainResponse.php',
219221
'OCP\\AppFramework\\Http\\TooManyRequestsResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TooManyRequestsResponse.php',
222+
'OCP\\AppFramework\\Http\\ValidationFailedException' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ValidationFailedException.php',
220223
'OCP\\AppFramework\\Http\\ZipResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/ZipResponse.php',
221224
'OCP\\AppFramework\\IAppContainer' => __DIR__ . '/../../..' . '/lib/public/AppFramework/IAppContainer.php',
222225
'OCP\\AppFramework\\Middleware' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Middleware.php',
@@ -1266,6 +1269,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
12661269
'OC\\AppFramework\\Http\\Output' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Output.php',
12671270
'OC\\AppFramework\\Http\\Request' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/Request.php',
12681271
'OC\\AppFramework\\Http\\RequestId' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/RequestId.php',
1272+
'OC\\AppFramework\\Http\\RequestPayloadResolver' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Http/RequestPayloadResolver.php',
12691273
'OC\\AppFramework\\Middleware\\AdditionalScriptsMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/AdditionalScriptsMiddleware.php',
12701274
'OC\\AppFramework\\Middleware\\CompressionMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/CompressionMiddleware.php',
12711275
'OC\\AppFramework\\Middleware\\InvalidParameterMiddleware' => __DIR__ . '/../../..' . '/lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php',

lib/private/AppFramework/Http/Dispatcher.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use OC\DB\ConnectionAdapter;
1717
use OCP\App\IAppManager;
1818
use OCP\AppFramework\Controller;
19+
use OCP\AppFramework\Http\Attribute\RequestPayload;
1920
use OCP\AppFramework\Http\DataResponse;
2021
use OCP\AppFramework\Http\InvalidEnumParameterException;
2122
use OCP\AppFramework\Http\InvalidStringParameterException;
@@ -53,6 +54,7 @@ public function __construct(
5354
private readonly IEventLogger $eventLogger,
5455
private readonly ContainerInterface $appContainer,
5556
private readonly IUserSession $userSession,
57+
private readonly RequestPayloadResolver $requestPayloadResolver,
5658
) {
5759
}
5860

@@ -155,10 +157,19 @@ private function executeController(Controller $controller, string $methodName):
155157
$types = ['int', 'integer', 'bool', 'boolean', 'float', 'double'];
156158

157159
foreach ($this->reflector->getParameters() as $param => $default) {
160+
$type = $this->reflector->getType($param);
161+
162+
$payloadAttribute = $type !== null ? $this->reflector->getParameterAttribute($param, RequestPayload::class) : null;
163+
if ($payloadAttribute !== null) {
164+
/** @var RequestPayload $requestPayload */
165+
$requestPayload = $payloadAttribute->newInstance();
166+
$arguments[] = $this->requestPayloadResolver->resolve($param, $type, $this->request->getRawContent(), $requestPayload->validationGroups);
167+
continue;
168+
}
169+
158170
// try to get the parameter from the request object and cast
159171
// it to the type annotated in the @param annotation
160172
$value = $this->request->getParam($param, $default);
161-
$type = $this->reflector->getType($param);
162173

163174
// Converted the string `'false'` to false when the controller wants a boolean
164175
if ($value === 'false' && ($type === 'bool' || $type === 'boolean')) {

lib/private/AppFramework/Http/Request.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ class Request implements \ArrayAccess, \Countable, IRequest {
9090
*/
9191
private ?\JsonException $decodingException = null;
9292

93+
/**
94+
* The raw request body, when the Content-Type was JSON-compatible.
95+
*/
96+
private ?string $rawContent = null;
97+
9398
/**
9499
* @param array $vars Associative request data with the following optional keys:
95100
* - array 'urlParams' route parameters extracted from the URL
@@ -388,6 +393,7 @@ protected function decodeContent() {
388393
if (preg_match(self::JSON_CONTENT_TYPE_REGEX, $this->getHeader('Content-Type')) === 1) {
389394
$content = file_get_contents($this->inputStream);
390395
if ($content !== '') {
396+
$this->rawContent = $content;
391397
try {
392398
$params = json_decode($content, true, flags:JSON_THROW_ON_ERROR);
393399
} catch (\JsonException $e) {
@@ -423,6 +429,12 @@ public function throwDecodingExceptionIfAny(): void {
423429
}
424430
}
425431

432+
#[\Override]
433+
public function getRawContent(): ?string {
434+
$this->decodeContent();
435+
return $this->rawContent;
436+
}
437+
426438
#[\Override]
427439
public function passesCSRFCheck(): bool {
428440
if ($this->csrfTokenManager === null) {
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OC\AppFramework\Http;
11+
12+
use OCP\AppFramework\Http\InvalidPayloadException;
13+
use OCP\AppFramework\Http\ValidationFailedException;
14+
use OCP\Serializer\Format;
15+
use OCP\Serializer\ISerializer;
16+
use OCP\Validator\IValidator;
17+
18+
/**
19+
* Builds a {@see \OCP\AppFramework\Http\Attribute\RequestPayload} controller argument from the
20+
* raw JSON request body
21+
*/
22+
class RequestPayloadResolver {
23+
public function __construct(
24+
private readonly ISerializer $serializer,
25+
private readonly IValidator $validator,
26+
) {
27+
}
28+
29+
/**
30+
* @template T
31+
* @param class-string<T> $type
32+
* @param string|string[]|null $validationGroups
33+
* @return T
34+
* @throws InvalidPayloadException if `$rawContent` is not valid JSON, or does not satisfy `$type`'s constructor
35+
* @throws ValidationFailedException if the built object does not satisfy its own validation constraints
36+
*/
37+
public function resolve(string $parameterName, string $type, ?string $rawContent, string|array|null $validationGroups): object {
38+
try {
39+
$payload = $this->serializer->deserialize($rawContent ?? '', $type, Format::JSON);
40+
} catch (\Throwable $e) {
41+
throw new InvalidPayloadException($parameterName, $e->getMessage());
42+
}
43+
44+
$violations = $this->validator->validate($payload, $validationGroups);
45+
if ($violations !== []) {
46+
throw new ValidationFailedException($parameterName, $violations);
47+
}
48+
49+
return $payload;
50+
}
51+
}

lib/private/AppFramework/Middleware/InvalidParameterMiddleware.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,18 @@
1313
use OCP\AppFramework\Http;
1414
use OCP\AppFramework\Http\DataResponse;
1515
use OCP\AppFramework\Http\InvalidEnumParameterException;
16+
use OCP\AppFramework\Http\InvalidPayloadException;
1617
use OCP\AppFramework\Http\InvalidStringParameterException;
1718
use OCP\AppFramework\Http\ParameterOutOfRangeException;
1819
use OCP\AppFramework\Http\Response;
20+
use OCP\AppFramework\Http\ValidationFailedException;
1921
use OCP\AppFramework\Middleware;
22+
use OCP\Validator\Violation;
2023

2124
/**
22-
* Turns a controller parameter validation failure detected by the Dispatcher
23-
* into a 400 Bad Request response.
25+
* Turns a controller parameter validation failure detected by the Dispatcher into a 400 Bad
26+
* Request response, or a 422 Unprocessable Entity response for a failed
27+
* {@see \OCP\AppFramework\Http\Attribute\RequestPayload} validation.
2428
*/
2529
class InvalidParameterMiddleware extends Middleware {
2630
/**
@@ -30,10 +34,24 @@ class InvalidParameterMiddleware extends Middleware {
3034
public function afterException(Controller $controller, string $methodName, \Exception $exception): Response {
3135
if ($exception instanceof ParameterOutOfRangeException
3236
|| $exception instanceof InvalidStringParameterException
33-
|| $exception instanceof InvalidEnumParameterException) {
37+
|| $exception instanceof InvalidEnumParameterException
38+
|| $exception instanceof InvalidPayloadException) {
3439
return new DataResponse(['message' => $exception->getMessage()], Http::STATUS_BAD_REQUEST);
3540
}
3641

42+
if ($exception instanceof ValidationFailedException) {
43+
return new DataResponse([
44+
'message' => $exception->getMessage(),
45+
'violations' => array_map(
46+
static fn (Violation $violation): array => [
47+
'propertyPath' => $violation->propertyPath,
48+
'message' => $violation->message,
49+
],
50+
$exception->violations,
51+
),
52+
], Http::STATUS_UNPROCESSABLE_ENTITY);
53+
}
54+
3755
throw $exception;
3856
}
3957
}

lib/private/AppFramework/Utility/ControllerMethodReflector.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,24 @@ public function getAttribute(string $attributeClass): ?\ReflectionAttribute {
198198
return null;
199199
}
200200

201+
/**
202+
* @template T
203+
* @param class-string<T> $attributeClass
204+
* @return ?\ReflectionAttribute<T>
205+
*/
206+
public function getParameterAttribute(string $parameter, string $attributeClass): ?\ReflectionAttribute {
207+
foreach ($this->reflectionMethod->getParameters() as $reflectionParameter) {
208+
if ($reflectionParameter->name !== $parameter) {
209+
continue;
210+
}
211+
212+
$attributes = $reflectionParameter->getAttributes($attributeClass);
213+
return $attributes[0] ?? null;
214+
}
215+
216+
return null;
217+
}
218+
201219
/**
202220
* Check if a method contains an annotation
203221
* @param string $name the name of the annotation
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCP\AppFramework\Http\Attribute;
11+
12+
use Attribute;
13+
14+
/**
15+
* Marks a controller method parameter as populated from the JSON request body
16+
*
17+
* The parameter's type is deserialized from the request body with
18+
* {@see \OCP\Serializer\ISerializer} and validated with {@see \OCP\Validator\IValidator}
19+
* before the controller method is called. A malformed or unprocessable body never reaches
20+
* the controller: it short-circuits into a 400 or 422 response.
21+
*
22+
* ```
23+
* class PersonController extends Controller {
24+
* public function create(#[RequestPayload] PersonDto $person): DataResponse {
25+
* // $person is already deserialized and valid at this point
26+
* }
27+
* }
28+
* ```
29+
*
30+
* @since 36.0.0
31+
*/
32+
#[Attribute(Attribute::TARGET_PARAMETER)]
33+
final class RequestPayload {
34+
/**
35+
* @param string|string[]|null $validationGroups only constraints tagged with one of these
36+
* groups are checked, `null` checks every
37+
* constraint regardless of its groups
38+
* @since 36.0.0
39+
*/
40+
public function __construct(
41+
public readonly string|array|null $validationGroups = null,
42+
) {
43+
}
44+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCP\AppFramework\Http;
11+
12+
/**
13+
* Thrown when a {@see \OCP\AppFramework\Http\Attribute\RequestPayload} parameter could not be
14+
* built from the request body, e.g. because it is not valid JSON or is missing required fields
15+
*
16+
* @since 36.0.0
17+
*/
18+
class InvalidPayloadException extends \InvalidArgumentException {
19+
/**
20+
* @since 36.0.0
21+
*/
22+
public function __construct(
23+
public readonly string $parameterName,
24+
string $reason,
25+
) {
26+
parent::__construct(sprintf('Parameter %s could not be built from the request body: %s', $parameterName, $reason));
27+
}
28+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCP\AppFramework\Http;
11+
12+
use OCP\Validator\Violation;
13+
14+
/**
15+
* Thrown when a {@see \OCP\AppFramework\Http\Attribute\RequestPayload} parameter was built from
16+
* the request body but failed validation
17+
*
18+
* @since 36.0.0
19+
*/
20+
class ValidationFailedException extends \RuntimeException {
21+
/**
22+
* @param Violation[] $violations
23+
* @since 36.0.0
24+
*/
25+
public function __construct(
26+
public readonly string $parameterName,
27+
public readonly array $violations,
28+
) {
29+
parent::__construct(sprintf('Parameter %s failed validation with %d violation(s)', $parameterName, count($violations)));
30+
}
31+
}

0 commit comments

Comments
 (0)