Skip to content
This repository was archived by the owner on Oct 2, 2023. It is now read-only.

Commit f490416

Browse files
committed
[#4] Improve validation tooling in http-kernel-extension
1 parent 6b76ff1 commit f490416

26 files changed

+859
-63
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
.php_cs.cache
44
composer.lock
55
/coverage
6+
.php-cs-fixer.cache

README.md

+64-15
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ also validate the resulting object with Symfony Validation if you set validation
6262
- A `BadRequestHttpException` will be thrown when the request or rather the resulting object is invalid according to the
6363
Symfony Validation, the request body can't be deserialized, it contains invalid JSON or the hierarchy levels of the
6464
request body of exceed 512.
65+
- If you are using the [ConstraintViolationErrorHandler](src/ErrorHandler/ConstraintViolationErrorHandler.php) error handler, a
66+
[ConstraintViolationException](src/Exception/ConstraintViolationException.php) will be thrown if the validation of your object
67+
fails. You can also implement your own handler by implementing the [ErrorHandlerInterface](src/ErrorHandler/ErrorHandlerInterface.php).
6568
- Currently, only JSON is supported as payload format and the payload is only taken from the requests body.
6669

6770
### How to use?
@@ -84,20 +87,14 @@ needed by the serializer.
8487
8588
final class UpdateFooDto
8689
{
87-
/**
88-
* @Assert\NotNull(message="Id should not be null.")
89-
* @Assert\Positive(message="Id should be a positive integer.")
90-
*/
90+
#[Assert\NotNull]
91+
#[Assert\Positive]
9192
private int $id;
9293
93-
/**
94-
* @Assert\NotBlank(message="Client version should not be be blank.")
95-
*/
94+
#[Assert\NotBlank]
9695
private string $clientVersion;
9796
98-
/**
99-
* @Assert\NotNull(message="Browser info should not be null.")
100-
*/
97+
#[Assert\NotNull]
10198
private array $browserInfo;
10299
103100
public function getClientVersion(): string
@@ -187,12 +184,64 @@ final class FooController extends AbstractController
187184
}
188185
```
189186

190-
#### Error handler
187+
#### Error handling
188+
189+
The extension provides a default error handler (`http-kernel-extensions/src/ErrorHandler/ConstraintViolationErrorHandler.php`) which
190+
handles common de-normalization errors that should be considered type errors. It will create a
191+
`Fusonic\HttpKernelExtensions\Exception\ConstraintViolationException` [ConstraintViolationException](src/Exception/ConstraintViolationException.php)
192+
which can be used with the provided `Fusonic\HttpKernelExtensions\Normalizer\ConstraintViolationExceptionNormalizer` [ConstraintViolationExceptionNormalizer](src/Normalizer/ConstraintViolationExceptionNormalizer.php).
193+
This normalizer is uses on Symfony's built-in `Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer` and enhances it
194+
with some extra information: an `errorCode` and `messageTemplate`. Both useful for parsing validation errors on the client side.
195+
If that does not match your needs you can simply provide your own error handler by implementing
196+
the `Fusonic\HttpKernelExtensions\ErrorHandler\ErrorHandlerInterface` and passing it to the `RequestDtoResolver`.
197+
198+
You have to register the normalizer as a service like this:
199+
200+
```yaml
201+
Fusonic\HttpKernelExtensions\Normalizer\ConstraintViolationExceptionNormalizer:
202+
arguments:
203+
- "@serializer.normalizer.constraint_violation_list"
204+
tags:
205+
- { name: serializer.normalizer }
206+
```
207+
208+
##### Using an exception listener/subscriber
209+
In Symfony you can use an exception listener or subscriber to eventually convert the `ConstraintViolationException` into an actual response using
210+
the `Fusonic\HttpKernelExtensions\Normalizer\ConstraintViolationExceptionNormalizer`. For example:
211+
212+
```php
213+
214+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
215+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
216+
use Fusonic\HttpKernelExtensions\Exception\ConstraintViolationException;
217+
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
218+
219+
final class ExceptionSubscriber implements EventSubscriberInterface {
220+
221+
public function __construct(private NormalizerInterface $normalizer)
222+
{
223+
}
224+
225+
public static function getSubscribedEvents(): array
226+
{
227+
return [
228+
KernelEvents::EXCEPTION => 'onKernelException',
229+
];
230+
}
231+
232+
public function onKernelException(ExceptionEvent $event): void
233+
{
234+
$throwable = $event->getThrowable();
235+
236+
if ($throwable instanceof ConstraintViolationException) {
237+
$data = $this->normalizer->normalize($throwable);
238+
$event->setResponse(new JsonResponse($data, 422));
239+
}
240+
}
241+
}
242+
```
191243

192-
The extension provides a default error handler in here `http-kernel-extensions/src/ErrorHandler/ErrorHandler.php` which
193-
throws `BadRequestHttpExceptions` in case the request can't be deserialized onto the given class or Symfony Validation
194-
deems it invalid. If that does not match your needs you can simply provide your own error handler by implementing
195-
the `ErrorHandlerInterface` and passing it to the `RequestDtoResolver`.
244+
Check the [Events and Event Listeners](https://symfony.com/doc/current/event_dispatcher.html) for details.
196245

197246
#### ContextAwareProvider
198247

UPGRADE-3.0.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Upgrade 2.x to 3.0
2+
3+
## Changes
4+
A new error handler has been added for handling Symfony validation errors. The default error handler is now set to
5+
`Fusonic\HttpKernelExtensions\ErrorHandler\ConstraintViolationErrorHandler`. If you do not need this behaviour, you should use
6+
your own implementation and inject that into the `Fusonic\HttpKernelExtensions\Controller\RequestDtoResolver`.

composer.json

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "fusonic/http-kernel-extensions",
33
"license": "MIT",
4-
"version": "2.1.0",
4+
"version": "3.0.0",
55
"description": "Symfony HttpKernel Component Extensions.",
66
"type": "library",
77
"authors": [
@@ -30,13 +30,14 @@
3030
},
3131
"require-dev": {
3232
"phpunit/phpunit": "^9.3",
33-
"phpstan/phpstan": "^0.12.42",
34-
"friendsofphp/php-cs-fixer": "^2.16.1",
33+
"phpstan/phpstan": "^1.4",
34+
"friendsofphp/php-cs-fixer": "^3.5",
3535
"doctrine/cache": "^1.10",
36-
"doctrine/annotations": "^1.11"
36+
"doctrine/annotations": "^1.11",
37+
"phpstan/phpstan-phpunit": "^1.0"
3738
},
3839
"scripts": {
39-
"phpstan": "vendor/bin/phpstan analyse",
40+
"phpstan": "php -d memory_limit=2048M vendor/bin/phpstan analyse",
4041
"phpcs-check": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --dry-run --diff --using-cache=yes",
4142
"phpcs-fix": "vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php -v --using-cache=yes",
4243
"test": "vendor/bin/phpunit --testdox",

phpstan.neon

+3
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ parameters:
44
- src
55
- tests
66
checkMissingIterableValueType: false
7+
8+
includes:
9+
- vendor/phpstan/phpstan-phpunit/extension.neon
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
// Copyright (c) Fusonic GmbH. All rights reserved.
4+
// Licensed under the MIT License. See LICENSE file in the project root for license information.
5+
6+
declare(strict_types=1);
7+
8+
namespace Fusonic\HttpKernelExtensions\ConstraintViolation;
9+
10+
use ArgumentCountError;
11+
use ReflectionClass;
12+
use Symfony\Component\Validator\Constraints\NotNull;
13+
use Symfony\Component\Validator\ConstraintViolation;
14+
15+
/**
16+
* Wraps a {@see ArgumentCountError} into a {@see ConstraintViolation}.
17+
*/
18+
class ArgumentCountConstraintViolation extends ConstraintViolation
19+
{
20+
private const EXPECTED_MATCHES = 6;
21+
22+
public function __construct(ArgumentCountError $error)
23+
{
24+
$message = $error->getMessage();
25+
26+
if (!str_starts_with($message, 'Too few arguments to function')) {
27+
throw $error;
28+
}
29+
30+
$matches = null;
31+
$pattern = '/Too few arguments to function (.+)::__construct\(\), (\d+) passed in (.+) on line (\d+) and (?:at least|exactly) (\d+) expected/';
32+
33+
preg_match($pattern, $message, $matches);
34+
35+
if (count($matches) < self::EXPECTED_MATCHES) {
36+
throw $error;
37+
}
38+
39+
// Get the first missing constructor argument
40+
if (!class_exists($matches[1])) {
41+
throw $error;
42+
}
43+
44+
$class = new ReflectionClass($matches[1]);
45+
46+
$constructor = $class->getConstructor();
47+
$parameters = $constructor?->getParameters() ?: [];
48+
49+
$propertyPath = null;
50+
51+
foreach ($parameters as $parameter) {
52+
if (!$parameter->isOptional()) {
53+
$propertyPath = $parameter->getName();
54+
}
55+
}
56+
57+
// If no propertyPath is found throw the exception up
58+
if (null === $propertyPath) {
59+
throw $error;
60+
}
61+
62+
$constraint = new NotNull();
63+
64+
parent::__construct(
65+
message: $constraint->message,
66+
messageTemplate: $constraint->message,
67+
parameters: [],
68+
root: null,
69+
propertyPath: $propertyPath,
70+
invalidValue: null,
71+
code: NotNull::IS_NULL_ERROR,
72+
constraint: $constraint
73+
);
74+
}
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
// Copyright (c) Fusonic GmbH. All rights reserved.
4+
// Licensed under the MIT License. See LICENSE file in the project root for license information.
5+
6+
declare(strict_types=1);
7+
8+
namespace Fusonic\HttpKernelExtensions\ConstraintViolation;
9+
10+
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
11+
use Symfony\Component\Validator\Constraints\NotNull;
12+
use Symfony\Component\Validator\ConstraintViolation;
13+
14+
/**
15+
* Wraps a {@see MissingConstructorArgumentsException} into a {@see ConstraintViolation}.
16+
*/
17+
class MissingConstructorArgumentsConstraintViolation extends ConstraintViolation
18+
{
19+
private const EXPECTED_MATCHES = 3;
20+
21+
public function __construct(MissingConstructorArgumentsException $exception)
22+
{
23+
$message = $exception->getMessage();
24+
25+
if (!str_starts_with($message, 'Cannot create an instance of')) {
26+
throw $exception;
27+
}
28+
29+
$matches = null;
30+
$pattern = '/Cannot create an instance of "(.+)" from serialized data because its constructor requires parameter "(.+)" to be present\./';
31+
32+
preg_match($pattern, $message, $matches);
33+
34+
if (count($matches) < self::EXPECTED_MATCHES) {
35+
throw $exception;
36+
}
37+
38+
$constraint = new NotNull();
39+
40+
parent::__construct(
41+
message: $constraint->message,
42+
messageTemplate: $constraint->message,
43+
parameters: [],
44+
root: null,
45+
propertyPath: $matches[2],
46+
invalidValue: null,
47+
code: NotNull::IS_NULL_ERROR,
48+
constraint: $constraint
49+
);
50+
}
51+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
// Copyright (c) Fusonic GmbH. All rights reserved.
4+
// Licensed under the MIT License. See LICENSE file in the project root for license information.
5+
6+
declare(strict_types=1);
7+
8+
namespace Fusonic\HttpKernelExtensions\ConstraintViolation;
9+
10+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
11+
use Symfony\Component\Validator\Constraints\Type;
12+
use Symfony\Component\Validator\ConstraintViolation;
13+
14+
/**
15+
* Wraps a {@see NotNormalizableValueException} into a {@see ConstraintViolation}.
16+
*/
17+
class NotNormalizableValueConstraintViolation extends ConstraintViolation
18+
{
19+
private const EXPECTED_MATCHES = 5;
20+
21+
public function __construct(NotNormalizableValueException $exception)
22+
{
23+
$message = $exception->getMessage();
24+
25+
if (str_starts_with($message, 'The type of the')) {
26+
$pattern = '/The type of the "(\w+)" attribute for class "(.+)" must be one of "(.+)" \("(.+)" given\)\./';
27+
} elseif (str_starts_with($message, 'Failed to denormalize attribute')) {
28+
$pattern = '/Failed to denormalize attribute "(\w+)" value for class "(.+)": Expected argument of type "(.+)", "(.+)" given/';
29+
} else {
30+
throw $exception;
31+
}
32+
33+
$matches = null;
34+
preg_match($pattern, $message, $matches);
35+
36+
if (count($matches) < self::EXPECTED_MATCHES) {
37+
throw $exception;
38+
}
39+
40+
$constraint = new Type($matches[1]);
41+
42+
parent::__construct(
43+
message: str_replace('{{ type }}', $matches[3], $constraint->message),
44+
messageTemplate: $constraint->message,
45+
parameters: ['{{ type }}' => $matches[3]],
46+
root: null,
47+
propertyPath: $matches[1],
48+
invalidValue: $matches[4],
49+
code: Type::INVALID_TYPE_ERROR,
50+
constraint: $constraint
51+
);
52+
}
53+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
// Copyright (c) Fusonic GmbH. All rights reserved.
4+
// Licensed under the MIT License. See LICENSE file in the project root for license information.
5+
6+
declare(strict_types=1);
7+
8+
namespace Fusonic\HttpKernelExtensions\ConstraintViolation;
9+
10+
use Symfony\Component\Validator\Constraints\Type;
11+
use Symfony\Component\Validator\ConstraintViolation;
12+
use TypeError;
13+
14+
/**
15+
* Wraps a {@see TypeError} into a {@see ConstraintViolation}.
16+
*/
17+
class TypeConstraintViolation extends ConstraintViolation
18+
{
19+
private const EXPECTED_MATCHES = 4;
20+
21+
public function __construct(TypeError $error)
22+
{
23+
$message = $error->getMessage();
24+
25+
if (!str_contains($message, 'must be of type')) {
26+
throw $error;
27+
}
28+
29+
$matches = null;
30+
$pattern = '/.+: Argument #\d+ \(\$(.+)\) must be of type \??(.+), (.+) given/';
31+
32+
preg_match($pattern, $message, $matches);
33+
34+
if (count($matches) < self::EXPECTED_MATCHES) {
35+
throw $error;
36+
}
37+
38+
$constraint = new Type($matches[2]);
39+
40+
parent::__construct(
41+
message: str_replace('{{ type }}', $matches[2], $constraint->message),
42+
messageTemplate: $constraint->message,
43+
parameters: ['{{ type }}' => $matches[2]],
44+
root: null,
45+
propertyPath: $matches[1],
46+
invalidValue: $matches[3],
47+
code: Type::INVALID_TYPE_ERROR,
48+
constraint: $constraint
49+
);
50+
}
51+
}

0 commit comments

Comments
 (0)