├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── AbstractValidatedRequest.php ├── Bundle └── RequestValidationBundle.php ├── Constraint ├── RequestConstraint.php ├── RequestConstraintFactory.php └── RequestConstraintValidator.php ├── DependencyInjection └── RequestValidationExtension.php ├── EventSubscriber └── RequestValidationSubscriber.php ├── Renderer └── ViolationListRenderer.php ├── Resources └── config │ └── services.php ├── Utility └── PropertyPath.php └── ValidationRules.php /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [Github](https://github.com/). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 123inkt / DigitalRevolution 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Minimum PHP Version](https://img.shields.io/badge/php-%3E%3D%208.1-8892BF)](https://php.net/) 2 | [![Minimum Symfony Version](https://img.shields.io/badge/symfony-%3E%3D%206.2-brightgreen)](https://symfony.com/doc/current/validation.html) 3 | ![Run tests](https://github.com/123inkt/symfony-request-validation/actions/workflows/test.yml/badge.svg) 4 | 5 | # Symfony Request Validation 6 | A request validation component for Symfony. Ease the validation of request properties without the need for an entire Symfony Form. 7 | 8 | ## Installation 9 | Include the library as dependency in your own project via: 10 | ```bash 11 | composer require "digitalrevolution/symfony-request-validation" 12 | ``` 13 | 14 | Update `/config/bundles.php`: 15 | ```php 16 | return [ 17 | ... 18 | DigitalRevolution\SymfonyRequestValidation\Bundle\RequestValidationBundle::class => ['all' => true], 19 | ]; 20 | ``` 21 | 22 | ## Usage 23 | 24 | 1) Create your own `ExampleRequest` class which extends the `AbstractValidatedRequest` class. 25 | 2) Configure your own `ValidationRules`. See the [Validation shorthand library](https://github.com/123inkt/symfony-validation-shorthand) for 26 | more information about the rules. 27 | 3) Ensure your `ExampleRequest` class is registered as [service in your Symfony project](https://symfony.com/doc/current/service_container.html). 28 | 29 | ```php 30 | use DigitalRevolution\SymfonyRequestValidation\AbstractValidatedRequest; 31 | use DigitalRevolution\SymfonyRequestValidation\ValidationRules; 32 | 33 | class ExampleRequest extends AbstractValidatedRequest 34 | { 35 | protected function getValidationRules(): ValidationRules 36 | { 37 | return new ValidationRules([ 38 | 'request' => [ 39 | 'productId' => 'required|int|min:0', 40 | 'productName' => 'required|string|between:50,255' 41 | ] 42 | ]); 43 | } 44 | 45 | public function getProductId(): int 46 | { 47 | return $this->request->request->getInt('productId'); 48 | } 49 | 50 | public function getProductName(): string 51 | { 52 | return $this->request->request->getString('productName'); 53 | } 54 | } 55 | ``` 56 | 57 | All that remains is using your `ExampleRequest` class in your `Controller` and it will only be invoked when the request validation passes. 58 | ```php 59 | class ExampleController 60 | { 61 | /** 62 | * @Route("/", name="my_example") 63 | */ 64 | public function index(ExampleRequest $request): Response 65 | { 66 | return ...; 67 | } 68 | } 69 | ``` 70 | 71 | ## Invalid request handling 72 | 73 | By default if a request is invalid an `InvalidRequestException` will be thrown. If you prefer a different behaviour, overwrite the `handleViolations` 74 | method. 75 | ```php 76 | class ExampleRequest extends AbstractValidatedRequest 77 | { 78 | ... 79 | 80 | protected function handleViolations(ConstraintViolationListInterface $violationList): void 81 | { 82 | $renderer = new ViolationListRenderer($violationList); 83 | $this->logger->error($renderer->render()); 84 | } 85 | } 86 | ``` 87 | 88 | Note: if no exceptions are thrown in the `handleViolations`, you'll always receive a request in your `Controller`. Use `Request->isValid()` to verify 89 | the request is valid. 90 | 91 | ## About us 92 | 93 | At 123inkt (Part of Digital Revolution B.V.), every day more than 50 development professionals are working on improving our internal ERP 94 | and our several shops. Do you want to join us? [We are looking for developers](https://www.werkenbij123inkt.nl/zoek-op-afdeling/it). 95 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "digitalrevolution/symfony-request-validation", 3 | "description": "Automatic request validation for symfony", 4 | "type": "symfony-bundle", 5 | "license": "MIT", 6 | "minimum-stability": "stable", 7 | "config": { 8 | "sort-packages": true, 9 | "allow-plugins": { 10 | "phpstan/extension-installer": true 11 | }, 12 | "lock": false 13 | }, 14 | "require": { 15 | "php": ">=8.1", 16 | "symfony/config": "^6.2 || ^7.0", 17 | "symfony/dependency-injection": "^6.2 || ^7.0", 18 | "symfony/http-foundation": "^6.2 || ^7.0", 19 | "symfony/http-kernel": "^6.2 || ^7.0", 20 | "symfony/validator": "^6.2 || ^7.0", 21 | "digitalrevolution/symfony-validation-shorthand": "^1.2" 22 | }, 23 | "require-dev": { 24 | "digitalrevolution/phpunit-file-coverage-inspection": "^v2.0.0", 25 | "roave/security-advisories": "dev-latest", 26 | "squizlabs/php_codesniffer": "^3.6", 27 | "phpmd/phpmd": "^2.14", 28 | "phpunit/phpunit": "^9.6", 29 | "phpstan/phpstan": "^2.0", 30 | "phpstan/phpstan-phpunit": "^2.0", 31 | "phpstan/phpstan-strict-rules": "^2.0", 32 | "phpstan/phpstan-symfony": "^2.0", 33 | "phpstan/extension-installer": "^1.3" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "DigitalRevolution\\SymfonyRequestValidation\\": "src/" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "DigitalRevolution\\SymfonyRequestValidation\\Tests\\": "tests/" 43 | } 44 | }, 45 | "scripts": { 46 | "baseline": ["@baseline:phpstan"], 47 | "baseline:phpstan": "phpstan --generate-baseline", 48 | "check": ["@check:phpstan", "@check:phpmd", "@check:phpcs"], 49 | "check:phpstan": "phpstan analyse", 50 | "check:phpmd": "phpmd src,tests text phpmd.xml.dist --suffixes php", 51 | "check:phpcs": "phpcs src tests", 52 | "fix": "@fix:phpcbf", 53 | "fix:phpcbf": "phpcbf src tests", 54 | "test": "phpunit", 55 | "test:integration": "phpunit --testsuite integration", 56 | "test:unit": "phpunit --testsuite unit" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/AbstractValidatedRequest.php: -------------------------------------------------------------------------------- 1 | getCurrentRequest(); 30 | if ($request === null) { 31 | throw new BadRequestException("Request is 'null', unable to validate"); 32 | } 33 | 34 | $this->request = $request; 35 | $this->validator = $validator; 36 | $this->constraintFactory = $constraintFactory; 37 | } 38 | 39 | public function getRequest(): Request 40 | { 41 | return $this->request; 42 | } 43 | 44 | public function isValid(): bool 45 | { 46 | return $this->isValid; 47 | } 48 | 49 | /** 50 | * @throws BadRequestException|InvalidRuleException 51 | * @internal invoked by RequestValidationSubscriber 52 | */ 53 | final public function validate(): ?Response 54 | { 55 | $rules = $this->getValidationRules(); 56 | if ($rules !== null) { 57 | $violationList = $this->validator->validate($this->request, $this->constraintFactory->createConstraint($rules)); 58 | if (count($violationList) > 0) { 59 | return $this->handleViolations($violationList); 60 | } 61 | } 62 | 63 | $response = $this->validateCustomRules(); 64 | if ($response instanceof Response) { 65 | return $response; 66 | } 67 | 68 | $this->isValid = true; 69 | 70 | return null; 71 | } 72 | 73 | /** 74 | * Get all the constraints for the current query params 75 | */ 76 | abstract protected function getValidationRules(): ?ValidationRules; 77 | 78 | /** 79 | * Override this function to validate addition custom validation rules after the standard Symfony rules have been validated. 80 | * - return null if validation was successful 81 | * - return Response to immediately end the request with this response. 82 | * - throw BadRequestException when request was not valid. 83 | * @throws BadRequestException 84 | * @codeCoverageIgnore 85 | */ 86 | protected function validateCustomRules(): ?Response 87 | { 88 | return null; 89 | } 90 | 91 | /** 92 | * Called when there are one or more violations. Defaults to throwing BadRequestException. Overwrite 93 | * to add your own handling. If response is returned, this response will be sent instead of invoking the controller. 94 | * 95 | * @param ConstraintViolationListInterface $violationList 96 | * @throws BadRequestException 97 | */ 98 | protected function handleViolations(ConstraintViolationListInterface $violationList): ?Response 99 | { 100 | throw new BadRequestException((new ViolationListRenderer($violationList))->render()); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Bundle/RequestValidationBundle.php: -------------------------------------------------------------------------------- 1 | */ 20 | protected const ERROR_NAMES = [ 21 | self::WRONG_VALUE_TYPE => 'WRONG_VALUE_TYPE', 22 | self::MISSING_QUERY_CONSTRAINT => 'MISSING_QUERY_CONSTRAINT', 23 | self::MISSING_REQUEST_CONSTRAINT => 'MISSING_REQUEST_CONSTRAINT', 24 | self::INVALID_BODY_CONTENT => 'INVALID_BODY_CONTENT', 25 | ]; 26 | 27 | public string $wrongTypeMessage = 'Expect value to be of type Symfony\Component\HttpFoundation\Request'; 28 | public string $queryMessage = 'Request::query is not empty, but there is no constraint configured.'; 29 | public string $requestMessage = 'Request::request is not empty, but there is no constraint configured.'; 30 | public string $invalidBodyMessage = 'Request::content cant be decoded'; 31 | 32 | /** @var Constraint|Constraint[]|null */ 33 | public Constraint|array|null $query = null; 34 | 35 | /** @var Constraint|Constraint[]|null */ 36 | public Constraint|array|null $request = null; 37 | 38 | /** @var Constraint|Constraint[]|null */ 39 | public Constraint|array|null $attributes = null; 40 | 41 | public bool $allowExtraFields = false; 42 | 43 | /** 44 | * @param array{ 45 | * query?: Constraint|Constraint[], 46 | * request?: Constraint|Constraint[], 47 | * attributes?: Constraint|Constraint[], 48 | * allowExtraFields?: bool 49 | * }|null $options 50 | */ 51 | public function __construct(?array $options = null) 52 | { 53 | // make sure defaults are set 54 | $options = $options ?? []; 55 | $options['query'] = $options['query'] ?? null; 56 | $options['request'] = $options['request'] ?? null; 57 | $options['attributes'] = $options['attributes'] ?? null; 58 | $options['allowExtraFields'] = $options['allowExtraFields'] ?? false; 59 | 60 | parent::__construct($options); 61 | } 62 | 63 | public function getRequiredOptions(): array 64 | { 65 | return ['query', 'request', 'attributes', 'allowExtraFields']; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Constraint/RequestConstraintFactory.php: -------------------------------------------------------------------------------- 1 | factory = $factory ?? new ConstraintFactory(); 19 | } 20 | 21 | /** 22 | * @throws InvalidRuleException 23 | */ 24 | public function createConstraint(ValidationRules $validationRules): RequestConstraint 25 | { 26 | /** 27 | * @var array{ 28 | * query?: Constraint|Constraint[], 29 | * request?: Constraint|Constraint[], 30 | * attributes?: Constraint|Constraint[], 31 | * } $options 32 | */ 33 | $options = []; 34 | foreach ($validationRules->getDefinitions() as $key => $definitions) { 35 | $options[$key] = $this->factory->fromRuleDefinitions($definitions, $validationRules->getAllowExtraFields()); 36 | } 37 | 38 | // Set extra constraint options 39 | $options['allowExtraFields'] = $validationRules->getAllowExtraFields(); 40 | 41 | return new RequestConstraint($options); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Constraint/RequestConstraintValidator.php: -------------------------------------------------------------------------------- 1 | context; 30 | if ($value instanceof Request === false) { 31 | $context->buildViolation($constraint->wrongTypeMessage) 32 | ->setCode($constraint::WRONG_VALUE_TYPE) 33 | ->addViolation(); 34 | 35 | return; 36 | } 37 | 38 | $this->validateQuery($constraint, $value); 39 | $this->validateRequest($constraint, $value); 40 | $this->validateAttributes($constraint, $value); 41 | } 42 | 43 | private function validateQuery(RequestConstraint $constraint, Request $request): void 44 | { 45 | if ($constraint->query !== null) { 46 | $this->context->getValidator() 47 | ->inContext($this->context) 48 | ->atPath('[query]') 49 | ->validate($request->query->all(), $constraint->query); 50 | } elseif ($constraint->allowExtraFields === false && count($request->query) > 0) { 51 | $this->context->buildViolation($constraint->queryMessage) 52 | ->atPath('[query]') 53 | ->setCode($constraint::MISSING_QUERY_CONSTRAINT) 54 | ->addViolation(); 55 | } 56 | } 57 | 58 | private function validateRequest(RequestConstraint $constraint, Request $request): void 59 | { 60 | if ($constraint->request === null) { 61 | if ($constraint->allowExtraFields === false && count($request->request) > 0) { 62 | $this->context->buildViolation($constraint->requestMessage) 63 | ->atPath('[request]') 64 | ->setCode($constraint::MISSING_REQUEST_CONSTRAINT) 65 | ->addViolation(); 66 | } 67 | 68 | return; 69 | } 70 | 71 | $contentType = $request->getContentTypeFormat(); 72 | if (in_array($contentType, ['json', 'jsonld'], true)) { 73 | $data = $this->validateAndGetJsonBody($constraint, $request); 74 | if ($data === null) { 75 | return; 76 | } 77 | } else { 78 | $data = $request->request->all(); 79 | } 80 | 81 | $this->context->getValidator() 82 | ->inContext($this->context) 83 | ->atPath('[request]') 84 | ->validate($data, $constraint->request); 85 | } 86 | 87 | /** 88 | * @return mixed[]|null 89 | */ 90 | private function validateAndGetJsonBody(RequestConstraint $constraint, Request $request): ?array 91 | { 92 | try { 93 | return $request->toArray(); 94 | } catch (JsonException $exception) { 95 | $this->context->buildViolation($constraint->invalidBodyMessage) 96 | ->atPath('[request]') 97 | ->setCode($constraint::INVALID_BODY_CONTENT) 98 | ->addViolation(); 99 | 100 | return null; 101 | } 102 | } 103 | 104 | private function validateAttributes(RequestConstraint $constraint, Request $request): void 105 | { 106 | if ($constraint->attributes !== null) { 107 | $this->context->getValidator() 108 | ->inContext($this->context) 109 | ->atPath('[attributes]') 110 | ->validate($request->attributes->all(), $constraint->attributes); 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/DependencyInjection/RequestValidationExtension.php: -------------------------------------------------------------------------------- 1 | fileLocator = $fileLocator; 23 | } 24 | 25 | /** 26 | * @inheritDoc 27 | * 28 | * @throws Exception 29 | */ 30 | public function load(array $configs, ContainerBuilder $container): void 31 | { 32 | $loader = new PhpFileLoader($container, $this->fileLocator ?? new FileLocator(__DIR__ . '/../Resources/config')); 33 | $loader->load('services.php'); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/EventSubscriber/RequestValidationSubscriber.php: -------------------------------------------------------------------------------- 1 | ['handleArguments', 1]]; 21 | } 22 | 23 | /** 24 | * @throws Exception 25 | */ 26 | public function handleArguments(ControllerArgumentsEvent $event): void 27 | { 28 | $arguments = $event->getArguments(); 29 | foreach ($arguments as $argument) { 30 | if ($argument instanceof AbstractValidatedRequest === false) { 31 | continue; 32 | } 33 | 34 | $result = $argument->validate(); 35 | if ($result instanceof Response) { 36 | $event->setController(fn() => $result); 37 | break; 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Renderer/ViolationListRenderer.php: -------------------------------------------------------------------------------- 1 | */ 13 | private $violationList; 14 | 15 | /** 16 | * @param ConstraintViolationListInterface $violations 17 | */ 18 | public function __construct(ConstraintViolationListInterface $violations) 19 | { 20 | $this->violationList = $violations; 21 | } 22 | 23 | public function render(): string 24 | { 25 | $messages = []; 26 | /** @var ConstraintViolationInterface $violation */ 27 | foreach ($this->violationList as $violation) { 28 | $propertyPath = implode('.', PropertyPath::toArray($violation->getPropertyPath())); 29 | $messages[] = $propertyPath . ': ' . $violation->getMessage(); 30 | } 31 | 32 | return implode("\n", $messages); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Resources/config/services.php: -------------------------------------------------------------------------------- 1 | services(); 16 | 17 | $services->set(ConstraintCollectionBuilder::class); 18 | $services->set(ConstraintResolver::class); 19 | $services->set(RuleParser::class); 20 | $services->set(ConstraintFactory::class); 21 | $services->set(RequestConstraintFactory::class); 22 | $services->set(RequestValidationSubscriber::class)->tag('kernel.event_subscriber'); 23 | }; 24 | -------------------------------------------------------------------------------- /src/Utility/PropertyPath.php: -------------------------------------------------------------------------------- 1 | >|array 11 | * @phpstan-type DefinitionCollection array{query?: ConstraintList, request?: ConstraintList, attributes?: ConstraintList} 12 | */ 13 | class ValidationRules 14 | { 15 | /** @phpstan-var DefinitionCollection $definitions */ 16 | private $definitions; 17 | 18 | private bool $allowExtraFields; 19 | 20 | /** 21 | * @phpstan-param DefinitionCollection $definitions 22 | * @param bool $allowExtraFields Allow the request to have extra fields, not present in the definition list 23 | */ 24 | public function __construct(array $definitions, bool $allowExtraFields = false) 25 | { 26 | // expect no other keys than `query` or `request` 27 | if (count(array_diff(array_keys($definitions), ['query', 'request', 'attributes'])) > 0) { 28 | throw new InvalidArgumentException('Expecting at most `query`, `request` or `attribute` property to be set'); 29 | } 30 | 31 | $this->definitions = $definitions; 32 | $this->allowExtraFields = $allowExtraFields; 33 | } 34 | 35 | /** 36 | * @phpstan-return DefinitionCollection $definitions 37 | */ 38 | public function getDefinitions(): array 39 | { 40 | return $this->definitions; 41 | } 42 | 43 | public function getAllowExtraFields(): bool 44 | { 45 | return $this->allowExtraFields; 46 | } 47 | } 48 | --------------------------------------------------------------------------------