diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index ab85b45dd5d..23b63bcff09 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -2,16 +2,18 @@ Feature: Validate filters based upon filter description @createSchema Scenario: Required filter should not throw an error if set - When I am on "/filter_validators?required=foo" + When I am on "/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=" Then the response status code should be 200 - When I am on "/filter_validators?required=" - Then the response status code should be 200 + Scenario: Required filter that does not allow empty value should throw an error if empty + When I am on "/filter_validators?required=&required-allow-empty=&arrayRequired[foo]=" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "required" does not allow empty value' Scenario: Required filter should throw an error if not set When I am on "/filter_validators" Then the response status code should be 400 - And the JSON node "detail" should be equal to 'Query parameter "required" is required' + Then the JSON node "detail" should match '/^Query parameter "required" is required\nQuery parameter "required-allow-empty" is required$/' Scenario: Required filter should not throw an error if set When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo" @@ -37,3 +39,129 @@ Feature: Validate filters based upon filter description When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[bar]=bar" Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "indexedArrayRequired[foo]" is required' + + Scenario: Test filter bounds: maximum + When I am on "/filter_validators?required=foo&required-allow-empty&maximum=10" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&maximum=11" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "maximum" must be less than or equal to 10' + + Scenario: Test filter bounds: exclusiveMaximum + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=9" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=10" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "exclusiveMaximum" must be less than 10' + + Scenario: Test filter bounds: minimum + When I am on "/filter_validators?required=foo&required-allow-empty&minimum=5" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&minimum=0" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "minimum" must be greater than or equal to 5' + + Scenario: Test filter bounds: exclusiveMinimum + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=6" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=5" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "exclusiveMinimum" must be greater than 5' + + Scenario: Test filter bounds: max length + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=123" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=1234" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "max-length-3" length must be lower than or equal to 3' + + Scenario: Do not throw an error if value is not an array + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3[]=12345" + Then the response status code should be 200 + + Scenario: Test filter bounds: min length + When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=123" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=12" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "min-length-3" length must be greater than or equal to 3' + + Scenario: Test filter pattern + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=pattern" + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=nrettap" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=not-pattern" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "pattern" must match pattern /^(pattern|nrettap)$/' + + Scenario: Test filter enum + When I am on "/filter_validators?required=foo&required-allow-empty&enum=in-enum" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&enum=not-in-enum" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "enum" must be one of "in-enum, mune-ni"' + + Scenario: Test filter multipleOf + When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=4" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=3" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "multiple-of" must multiple of 2' + + Scenario: Test filter array items csv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&csv-min-2=a,b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-min-2=a" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-min-2" must contain more than 2 values' + + Scenario: Test filter array items csv format maxItems + When I am on "/filter_validators?required=foo&required-allow-empty&csv-max-3=a,b,c" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-max-3=a,b,c,d" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-max-3" must contain less than 3 values' + + Scenario: Test filter array items tsv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&tsv-min-2=a\tb" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&tsv-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "tsv-min-2" must contain more than 2 values' + + Scenario: Test filter array items pipes format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&pipes-min-2=a|b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&pipes-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "pipes-min-2" must contain more than 2 values' + + Scenario: Test filter array items ssv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&ssv-min-2=a b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&ssv-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "ssv-min-2" must contain more than 2 values' + + @dropSchema + Scenario: Test filter array items unique items + When I am on "/filter_validators?required=foo&required-allow-empty&csv-uniques=a,b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-uniques=a,a" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-uniques" must contain unique values' diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index d34dcd8cfe6..2526c93cfc6 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -23,9 +23,13 @@ - - + + + + + + diff --git a/src/EventListener/QueryParameterValidateListener.php b/src/EventListener/QueryParameterValidateListener.php new file mode 100644 index 00000000000..5c76d7e1fdc --- /dev/null +++ b/src/EventListener/QueryParameterValidateListener.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\EventListener; + +use ApiPlatform\Core\Filter\QueryParameterValidator; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; +use Symfony\Component\HttpKernel\Event\GetResponseEvent; + +/** + * Validates query parameters depending on filter description. + * + * @author Julien Deniau + */ +final class QueryParameterValidateListener +{ + private $resourceMetadataFactory; + + private $queryParameterValidator; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, QueryParameterValidator $queryParameterValidator) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->queryParameterValidator = $queryParameterValidator; + } + + public function onKernelRequest(GetResponseEvent $event) + { + $request = $event->getRequest(); + if ( + !$request->isMethodSafe(false) + || !($attributes = RequestAttributesExtractor::extractAttributes($request)) + || !isset($attributes['collection_operation_name']) + || 'get' !== ($operationName = $attributes['collection_operation_name']) + ) { + return; + } + + $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + + $this->queryParameterValidator->validateFilters($attributes['resource_class'], $resourceFilters, $request); + } +} diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php deleted file mode 100644 index ffb919a69fd..00000000000 --- a/src/Filter/QueryParameterValidateListener.php +++ /dev/null @@ -1,104 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Core\Filter; - -use ApiPlatform\Core\Api\FilterLocatorTrait; -use ApiPlatform\Core\Exception\FilterValidationException; -use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use ApiPlatform\Core\Util\RequestAttributesExtractor; -use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\GetResponseEvent; - -/** - * Validates query parameters depending on filter description. - * - * @author Julien Deniau - */ -final class QueryParameterValidateListener -{ - use FilterLocatorTrait; - - private $resourceMetadataFactory; - - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, ContainerInterface $filterLocator) - { - $this->resourceMetadataFactory = $resourceMetadataFactory; - $this->setFilterLocator($filterLocator); - } - - public function onKernelRequest(GetResponseEvent $event) - { - $request = $event->getRequest(); - if ( - !$request->isMethodSafe(false) - || !($attributes = RequestAttributesExtractor::extractAttributes($request)) - || !isset($attributes['collection_operation_name']) - || 'get' !== ($operationName = $attributes['collection_operation_name']) - ) { - return; - } - - $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); - $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); - - $errorList = []; - foreach ($resourceFilters as $filterId) { - if (!$filter = $this->getFilter($filterId)) { - continue; - } - - foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) { - if (!($data['required'] ?? false)) { // property is not required - continue; - } - - if (!$this->isRequiredFilterValid($name, $request)) { - $errorList[] = sprintf('Query parameter "%s" is required', $name); - } - } - } - - if ($errorList) { - throw new FilterValidationException($errorList); - } - } - - /** - * Test if required filter is valid. It validates array notation too like "required[bar]". - */ - private function isRequiredFilterValid(string $name, Request $request): bool - { - $matches = []; - parse_str($name, $matches); - if (!$matches) { - return false; - } - - $rootName = (string) (array_keys($matches)[0] ?? null); - if (!$rootName) { - return false; - } - - if (\is_array($matches[$rootName])) { - $keyName = array_keys($matches[$rootName])[0]; - - $queryParameter = $request->query->get($rootName); - - return \is_array($queryParameter) && isset($queryParameter[$keyName]); - } - - return null !== $request->query->get($rootName); - } -} diff --git a/src/Filter/QueryParameterValidator.php b/src/Filter/QueryParameterValidator.php new file mode 100644 index 00000000000..db64bdd97dc --- /dev/null +++ b/src/Filter/QueryParameterValidator.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter; + +use ApiPlatform\Core\Api\FilterLocatorTrait; +use ApiPlatform\Core\Exception\FilterValidationException; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Validates query parameters depending on filter description. + * + * @author Julien Deniau + */ +class QueryParameterValidator +{ + use FilterLocatorTrait; + + private $validators; + + public function __construct(ContainerInterface $filterLocator) + { + $this->setFilterLocator($filterLocator); + + $this->validators = [ + new Validator\ArrayItems(), + new Validator\Bounds(), + new Validator\Enum(), + new Validator\Length(), + new Validator\MultipleOf(), + new Validator\Pattern(), + new Validator\Required(), + ]; + } + + public function validateFilters(string $resourceClass, array $resourceFilters, Request $request) + { + $errorList = []; + foreach ($resourceFilters as $filterId) { + if (!$filter = $this->getFilter($filterId)) { + continue; + } + + foreach ($filter->getDescription($resourceClass) as $name => $data) { + foreach ($this->validators as $validator) { + $errorList = \array_merge($errorList, $validator->validate($name, $data, $request)); + } + } + } + + if ($errorList) { + throw new FilterValidationException($errorList); + } + } +} diff --git a/src/Filter/Validator/ArrayItems.php b/src/Filter/Validator/ArrayItems.php new file mode 100644 index 00000000000..460ea825280 --- /dev/null +++ b/src/Filter/Validator/ArrayItems.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class ArrayItems implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + if (!$request->query->has($name)) { + return []; + } + + $maxItems = $filterDescription['swagger']['maxItems'] ?? null; + $minItems = $filterDescription['swagger']['minItems'] ?? null; + $uniqueItems = $filterDescription['swagger']['uniqueItems'] ?? false; + + $errorList = []; + + $value = $this->getValue($name, $filterDescription, $request); + $nbItems = \count($value); + + if (null !== $maxItems && $nbItems > $maxItems) { + $errorList[] = \sprintf('Query parameter "%s" must contain less than %d values', $name, $maxItems); + } + + if (null !== $minItems && $nbItems < $minItems) { + $errorList[] = \sprintf('Query parameter "%s" must contain more than %d values', $name, $minItems); + } + + if (true === $uniqueItems && $nbItems > \count(array_unique($value))) { + $errorList[] = \sprintf('Query parameter "%s" must contain unique values', $name); + } + + return $errorList; + } + + private function getValue(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + + if (empty($value) && '0' !== $value) { + return []; + } + + if (\is_array($value)) { + return $value; + } + + $collectionFormat = $filterDescription['swagger']['collectionFormat'] ?? 'csv'; + + return explode(self::getSeparator($collectionFormat), $value) ?: []; + } + + private static function getSeparator(string $collectionFormat): string + { + switch ($collectionFormat) { + case 'csv': + return ','; + case 'ssv': + return ' '; + case 'tsv': + return '\t'; + case 'pipes': + return '|'; + default: + throw new \InvalidArgumentException(sprintf('Unknown collection format %s', $collectionFormat)); + } + } +} diff --git a/src/Filter/Validator/Bounds.php b/src/Filter/Validator/Bounds.php new file mode 100644 index 00000000000..4c7a1d57610 --- /dev/null +++ b/src/Filter/Validator/Bounds.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Bounds implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value) { + return []; + } + + $maximum = $filterDescription['swagger']['maximum'] ?? null; + $minimum = $filterDescription['swagger']['minimum'] ?? null; + + $errorList = []; + + if (null !== $maximum) { + if (($filterDescription['swagger']['exclusiveMaximum'] ?? false) && $value >= $maximum) { + $errorList[] = \sprintf('Query parameter "%s" must be less than %s', $name, $maximum); + } elseif ($value > $maximum) { + $errorList[] = \sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum); + } + } + + if (null !== $minimum) { + if (($filterDescription['swagger']['exclusiveMinimum'] ?? false) && $value <= $minimum) { + $errorList[] = \sprintf('Query parameter "%s" must be greater than %s', $name, $minimum); + } elseif ($value < $minimum) { + $errorList[] = \sprintf('Query parameter "%s" must be greater than or equal to %s', $name, $minimum); + } + } + + return $errorList; + } +} diff --git a/src/Filter/Validator/Enum.php b/src/Filter/Validator/Enum.php new file mode 100644 index 00000000000..7774eb951a2 --- /dev/null +++ b/src/Filter/Validator/Enum.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Enum implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $enum = $filterDescription['swagger']['enum'] ?? null; + + if (null !== $enum && !\in_array($value, $enum, true)) { + return [ + \sprintf('Query parameter "%s" must be one of "%s"', $name, implode(', ', $enum)), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Length.php b/src/Filter/Validator/Length.php new file mode 100644 index 00000000000..525d8012760 --- /dev/null +++ b/src/Filter/Validator/Length.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Length implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $maxLength = $filterDescription['swagger']['maxLength'] ?? null; + $minLength = $filterDescription['swagger']['minLength'] ?? null; + + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $errorList = []; + + if (null !== $maxLength && mb_strlen($value) > $maxLength) { + $errorList[] = \sprintf('Query parameter "%s" length must be lower than or equal to %s', $name, $maxLength); + } + + if (null !== $minLength && mb_strlen($value) < $minLength) { + $errorList[] = \sprintf('Query parameter "%s" length must be greater than or equal to %s', $name, $minLength); + } + + return $errorList; + } +} diff --git a/src/Filter/Validator/MultipleOf.php b/src/Filter/Validator/MultipleOf.php new file mode 100644 index 00000000000..e42726f9a56 --- /dev/null +++ b/src/Filter/Validator/MultipleOf.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class MultipleOf implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $multipleOf = $filterDescription['swagger']['multipleOf'] ?? null; + + if (null !== $multipleOf && 0 !== ($value % $multipleOf)) { + return [ + \sprintf('Query parameter "%s" must multiple of %s', $name, $multipleOf), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Pattern.php b/src/Filter/Validator/Pattern.php new file mode 100644 index 00000000000..ee83f3ac755 --- /dev/null +++ b/src/Filter/Validator/Pattern.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Pattern implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + $value = $request->query->get($name); + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $pattern = $filterDescription['swagger']['pattern'] ?? null; + + if (null !== $pattern && !preg_match($pattern, $value)) { + return [ + \sprintf('Query parameter "%s" must match pattern %s', $name, $pattern), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Required.php b/src/Filter/Validator/Required.php new file mode 100644 index 00000000000..e93c6b85e4c --- /dev/null +++ b/src/Filter/Validator/Required.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +class Required implements ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array + { + // filter is not required, the `checkRequired` method can not break + if (!($filterDescription['required'] ?? false)) { + return []; + } + + // if query param is not given, then break + if (!$this->requestHasQueryParameter($request, $name)) { + return [ + \sprintf('Query parameter "%s" is required', $name), + ]; + } + + // if query param is empty and the configuration does not allow it + if (!($filterDescription['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($request, $name))) { + return [ + \sprintf('Query parameter "%s" does not allow empty value', $name), + ]; + } + + return []; + } + + /** + * Test if request has required parameter. + */ + private function requestHasQueryParameter(Request $request, string $name): bool + { + $matches = []; + parse_str($name, $matches); + if (!$matches) { + return false; + } + + $rootName = \array_keys($matches)[0] ?? ''; + if (!$rootName) { + return false; + } + + if (\is_array($matches[$rootName])) { + $keyName = \array_keys($matches[$rootName])[0]; + + $queryParameter = $request->query->get((string) $rootName); + + return \is_array($queryParameter) && isset($queryParameter[$keyName]); + } + + return $request->query->has((string) $rootName); + } + + /** + * Test if required filter is valid. It validates array notation too like "required[bar]". + */ + private function requestGetQueryParameter(Request $request, string $name) + { + $matches = []; + \parse_str($name, $matches); + if (empty($matches)) { + return null; + } + + $rootName = \array_keys($matches)[0] ?? ''; + if (!$rootName) { + return null; + } + + if (\is_array($matches[$rootName])) { + $keyName = \array_keys($matches[$rootName])[0]; + + $queryParameter = $request->query->get((string) $rootName); + + if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { + return $queryParameter[$keyName]; + } + + return null; + } + + return $request->query->get((string) $rootName); + } +} diff --git a/src/Filter/Validator/ValidatorInterface.php b/src/Filter/Validator/ValidatorInterface.php new file mode 100644 index 00000000000..33740725059 --- /dev/null +++ b/src/Filter/Validator/ValidatorInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +use Symfony\Component\HttpFoundation\Request; + +interface ValidatorInterface +{ + public function validate(string $name, array $filterDescription, Request $request): array; +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index f2a1b9043b5..32b2b8e38c3 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -744,6 +744,7 @@ private function getPartialContainerBuilderProphecy() 'api_platform.listener.view.respond', 'api_platform.listener.view.serialize', 'api_platform.listener.view.validate', + 'api_platform.validator.query_parameter_validator', 'api_platform.listener.view.validate_query_parameters', 'api_platform.listener.view.write', 'api_platform.metadata.extractor.xml', diff --git a/tests/Filter/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php similarity index 70% rename from tests/Filter/QueryParameterValidateListenerTest.php rename to tests/EventListener/QueryParameterValidateListenerTest.php index 573e8d1ff72..6ff154ba7ed 100644 --- a/tests/Filter/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -11,23 +11,22 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\Filter; +namespace ApiPlatform\Core\Tests\EventListener; -use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\EventListener\QueryParameterValidateListener; use ApiPlatform\Core\Exception\FilterValidationException; -use ApiPlatform\Core\Filter\QueryParameterValidateListener; +use ApiPlatform\Core\Filter\QueryParameterValidator; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\GetResponseEvent; class QueryParameterValidateListenerTest extends TestCase { private $testedInstance; - private $filterLocatorProphecy; + private $queryParameterValidor; /** * unsafe method should not use filter validations. @@ -60,8 +59,7 @@ public function testOnKernelRequestWithWrongFilter() $eventProphecy = $this->prophesize(GetResponseEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy->has('some_inexistent_filter')->shouldBeCalled(); - $this->filterLocatorProphecy->get('some_inexistent_filter')->shouldNotBeCalled(); + $this->queryParameterValidor->validateFilters(Dummy::class, ['some_inexistent_filter'], $request)->shouldBeCalled(); $this->assertNull( $this->testedInstance->onKernelRequest($eventProphecy->reveal()) @@ -81,27 +79,11 @@ public function testOnKernelRequestWithRequiredFilterNotSet() $eventProphecy = $this->prophesize(GetResponseEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy - ->has('some_filter') + $this->queryParameterValidor + ->validateFilters(Dummy::class, ['some_filter'], $request) ->shouldBeCalled() - ->willReturn(true) + ->willThrow(new FilterValidationException(['Query parameter "required" is required'])) ; - $filterProphecy = $this->prophesize(FilterInterface::class); - $filterProphecy - ->getDescription(Dummy::class) - ->shouldBeCalled() - ->willReturn([ - 'required' => [ - 'required' => true, - ], - ]) - ; - $this->filterLocatorProphecy - ->get('some_filter') - ->shouldBeCalled() - ->willReturn($filterProphecy->reveal()) - ; - $this->expectException(FilterValidationException::class); $this->expectExceptionMessage('Query parameter "required" is required'); $this->testedInstance->onKernelRequest($eventProphecy->reveal()); @@ -124,25 +106,9 @@ public function testOnKernelRequestWithRequiredFilter() $eventProphecy = $this->prophesize(GetResponseEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy - ->has('some_filter') - ->shouldBeCalled() - ->willReturn(true) - ; - $filterProphecy = $this->prophesize(FilterInterface::class); - $filterProphecy - ->getDescription(Dummy::class) - ->shouldBeCalled() - ->willReturn([ - 'required' => [ - 'required' => true, - ], - ]) - ; - $this->filterLocatorProphecy - ->get('some_filter') + $this->queryParameterValidor + ->validateFilters(Dummy::class, ['some_filter'], $request) ->shouldBeCalled() - ->willReturn($filterProphecy->reveal()) ; $this->assertNull( @@ -163,11 +129,11 @@ private function setUpWithFilters(array $filters = []) ) ; - $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $this->queryParameterValidor = $this->prophesize(QueryParameterValidator::class); $this->testedInstance = new QueryParameterValidateListener( $resourceMetadataFactoryProphecy->reveal(), - $this->filterLocatorProphecy->reveal() + $this->queryParameterValidor->reveal() ); } } diff --git a/tests/Filter/QueryParameterValidatorTest.php b/tests/Filter/QueryParameterValidatorTest.php new file mode 100644 index 00000000000..3f1cdb1cc74 --- /dev/null +++ b/tests/Filter/QueryParameterValidatorTest.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter; + +use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\Exception\FilterValidationException; +use ApiPlatform\Core\Filter\QueryParameterValidator; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Class QueryParameterValidatorTest. + * + * @author Julien Deniau + */ +class QueryParameterValidatorTest extends TestCase +{ + private $testedInstance; + private $filterLocatorProphecy; + + public function setUp() + { + $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + + $this->testedInstance = new QueryParameterValidator( + $this->filterLocatorProphecy->reveal() + ); + } + + /** + * unsafe method should not use filter validations. + */ + public function testOnKernelRequestWithUnsafeMethod() + { + $request = new Request(); + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, [], $request) + ); + } + + /** + * If the tested filter is non-existant, then nothing should append. + */ + public function testOnKernelRequestWithWrongFilter() + { + $request = new Request(); + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, ['some_inexistent_filter'], $request) + ); + } + + /** + * if the required parameter is not set, throw an FilterValidationException. + */ + public function testOnKernelRequestWithRequiredFilterNotSet() + { + $request = new Request(); + + $filterProphecy = $this->prophesize(FilterInterface::class); + $filterProphecy + ->getDescription(Dummy::class) + ->shouldBeCalled() + ->willReturn([ + 'required' => [ + 'required' => true, + ], + ]) + ; + $this->filterLocatorProphecy + ->has('some_filter') + ->shouldBeCalled() + ->willReturn(true) + ; + $this->filterLocatorProphecy + ->get('some_filter') + ->shouldBeCalled() + ->willReturn($filterProphecy->reveal()) + ; + + $this->expectException(FilterValidationException::class); + $this->expectExceptionMessage('Query parameter "required" is required'); + $this->testedInstance->validateFilters(Dummy::class, ['some_filter'], $request); + } + + /** + * if the required parameter is set, no exception should be throwned. + */ + public function testOnKernelRequestWithRequiredFilter() + { + $request = new Request( + ['required' => 'foo'] + ); + + $this->filterLocatorProphecy + ->has('some_filter') + ->shouldBeCalled() + ->willReturn(true) + ; + $filterProphecy = $this->prophesize(FilterInterface::class); + $filterProphecy + ->getDescription(Dummy::class) + ->shouldBeCalled() + ->willReturn([ + 'required' => [ + 'required' => true, + ], + ]) + ; + $this->filterLocatorProphecy + ->get('some_filter') + ->shouldBeCalled() + ->willReturn($filterProphecy->reveal()) + ; + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, ['some_filter'], $request) + ); + } +} diff --git a/tests/Filter/Validator/ArrayItemsTest.php b/tests/Filter/Validator/ArrayItemsTest.php new file mode 100644 index 00000000000..6e4d2a3e9a4 --- /dev/null +++ b/tests/Filter/Validator/ArrayItemsTest.php @@ -0,0 +1,199 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\ArrayItems; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class ArrayItemsTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new ArrayItems(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new ArrayItems(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 3, + 'minItems' => 2, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar', 'bar', 'foo']]); + $this->assertEquals( + ['Query parameter "some_filter" must contain less than 3 values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $request = new Request(['some_filter' => ['foo']]); + $this->assertEquals( + ['Query parameter "some_filter" must contain more than 2 values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 3, + 'minItems' => 2, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar']]); + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $request = new Request(['some_filter' => ['foo', 'bar', 'baz']]); + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testNonMatchingUniqueItems() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'uniqueItems' => true, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar', 'bar', 'foo']]); + $this->assertEquals( + ['Query parameter "some_filter" must contain unique values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingUniqueItems() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'uniqueItems' => true, + ], + ]; + + $request = new Request(['some_filter' => ['foo', 'bar', 'baz']]); + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testSeparators() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 2, + 'uniqueItems' => true, + 'collectionFormat' => 'csv', + ], + ]; + + $request = new Request(['some_filter' => 'foo,bar,bar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'ssv'; + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'ssv'; + $request = new Request(['some_filter' => 'foo bar bar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'tsv'; + $request = new Request(['some_filter' => 'foo\tbar\tbar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'pipes'; + $request = new Request(['some_filter' => 'foo|bar|bar']); + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testSeparatorsUnknownSeparator() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 2, + 'uniqueItems' => true, + 'collectionFormat' => 'unknownFormat', + ], + ]; + $request = new Request(['some_filter' => 'foo,bar,bar']); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown collection format unknownFormat'); + + $filter->validate('some_filter', $filterDefinition, $request); + } +} diff --git a/tests/Filter/Validator/BoundsTest.php b/tests/Filter/Validator/BoundsTest.php new file mode 100644 index 00000000000..d993777a911 --- /dev/null +++ b/tests/Filter/Validator/BoundsTest.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Bounds; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class BoundsTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Bounds(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new Bounds(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingMinimum() + { + $request = new Request(['some_filter' => '9']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + 'exclusiveMinimum' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 9, + 'exclusiveMinimum' => true, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than 9'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingMinimum() + { + $request = new Request(['some_filter' => '10']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 9, + 'exclusiveMinimum' => false, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testNonMatchingMaximum() + { + $request = new Request(['some_filter' => '11']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 9, + 'exclusiveMaximum' => true, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than 9'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingMaximum() + { + $request = new Request(['some_filter' => '10']); + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => false, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/EnumTest.php b/tests/Filter/Validator/EnumTest.php new file mode 100644 index 00000000000..e55697e4376 --- /dev/null +++ b/tests/Filter/Validator/EnumTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Enum; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class EnumTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Enum(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new Enum(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $request = new Request(['some_filter' => 'foobar']); + $filter = new Enum(); + + $filterDefinition = [ + 'swagger' => [ + 'enum' => ['foo', 'bar'], + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be one of "foo, bar"'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $request = new Request(['some_filter' => 'foo']); + $filter = new Enum(); + + $filterDefinition = [ + 'swagger' => [ + 'enum' => ['foo', 'bar'], + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/LengthTest.php b/tests/Filter/Validator/LengthTest.php new file mode 100644 index 00000000000..76e60645af2 --- /dev/null +++ b/tests/Filter/Validator/LengthTest.php @@ -0,0 +1,142 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Length; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class LengthTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Length(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new Length(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + 'maxLength' => 5, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be greater than or equal to 3'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'ab'])) + ); + + $this->assertEquals( + ['Query parameter "some_filter" length must be lower than or equal to 5'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcdef'])) + ); + } + + public function testNonMatchingParameterWithOnlyOneDefinition() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be greater than or equal to 3'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'ab'])) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maxLength' => 5, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be lower than or equal to 5'], + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcdef'])) + ); + } + + public function testMatchingParameter() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + 'maxLength' => 5, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abc'])) + ); + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcd'])) + ); + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcde'])) + ); + } + + public function testMatchingParameterWithOneDefinition() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abc'])) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maxLength' => 5, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, new Request(['some_filter' => 'abcde'])) + ); + } +} diff --git a/tests/Filter/Validator/MultipleOfTest.php b/tests/Filter/Validator/MultipleOfTest.php new file mode 100644 index 00000000000..a90386db765 --- /dev/null +++ b/tests/Filter/Validator/MultipleOfTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\MultipleOf; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class MultipleOfTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new MultipleOf(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = new Request(['some_filter' => '']); + $filter = new MultipleOf(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $request = new Request(['some_filter' => '8']); + $filter = new MultipleOf(); + + $filterDefinition = [ + 'swagger' => [ + 'multipleOf' => 3, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must multiple of 3'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $request = new Request(['some_filter' => '8']); + $filter = new MultipleOf(); + + $filterDefinition = [ + 'swagger' => [ + 'multipleOf' => 4, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/PatternTest.php b/tests/Filter/Validator/PatternTest.php new file mode 100644 index 00000000000..02a924a8f5b --- /dev/null +++ b/tests/Filter/Validator/PatternTest.php @@ -0,0 +1,102 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Pattern; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Julien Deniau + */ +class PatternTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = new Request(); + $filter = new Pattern(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testFilterWithEmptyValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => ''])) + ); + + $weirdParameter = new \stdClass(); + $weirdParameter->foo = 'non string value should not exists'; + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => $weirdParameter])) + ); + } + + public function testFilterWithZeroAsParameter() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must match pattern /foo/'], + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => '0'])) + ); + } + + public function testFilterWithNonMatchingValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must match pattern /foo/'], + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => 'bar'])) + ); + } + + public function testFilterWithNonchingValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo \d+/', + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, new Request(['some_filter' => 'this is a foo '.random_int(0, 10).' and it should match'])) + ); + } +} diff --git a/tests/Filter/Validator/RequiredTest.php b/tests/Filter/Validator/RequiredTest.php new file mode 100644 index 00000000000..b2c1596c144 --- /dev/null +++ b/tests/Filter/Validator/RequiredTest.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Required; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; + +/** + * Class RequiredTest. + * + * @author Julien Deniau + */ +class RequiredTest extends TestCase +{ + public function testNonRequiredFilter() + { + $request = new Request(); + $filter = new Required(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + + $this->assertEmpty( + $filter->validate('some_filter', ['required' => false], $request) + ); + } + + public function testRequiredFilterNotInQuery() + { + $request = new Request(); + $filter = new Required(); + + $this->assertEquals( + ['Query parameter "some_filter" is required'], + $filter->validate('some_filter', ['required' => true], $request) + ); + } + + public function testRequiredFilterIsPresent() + { + $request = new Request(['some_filter' => 'some_value']); + $filter = new Required(); + + $this->assertEmpty( + $filter->validate('some_filter', ['required' => true], $request) + ); + } + + public function testEmptyValueNotAllowed() + { + $request = new Request(['some_filter' => '']); + $filter = new Required(); + + $explicitFilterDefinition = [ + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" does not allow empty value'], + $filter->validate('some_filter', $explicitFilterDefinition, $request) + ); + + $implicitFilterDefinition = [ + 'required' => true, + ]; + + $this->assertEquals( + ['Query parameter "some_filter" does not allow empty value'], + $filter->validate('some_filter', $implicitFilterDefinition, $request) + ); + } + + public function testEmptyValueAllowed() + { + $request = new Request(['some_filter' => '']); + $filter = new Required(); + + $explicitFilterDefinition = [ + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => true, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, $request) + ); + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilterValidator.php b/tests/Fixtures/TestBundle/Entity/FilterValidator.php index 118050a9b8f..eaa62b26345 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Entity/FilterValidator.php @@ -15,6 +15,13 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ORM\Mapping as ORM; @@ -25,7 +32,14 @@ * * @ApiResource(attributes={ * "filters"={ - * RequiredFilter::class + * ArrayItemsFilter::class, + * BoundsFilter::class, + * EnumFilter::class, + * LengthFilter::class, + * MultipleOfFilter::class, + * PatternFilter::class, + * RequiredFilter::class, + * RequiredAllowEmptyFilter::class * } * }) * @ORM\Entity diff --git a/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php new file mode 100644 index 00000000000..7feb9b3409f --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class ArrayItemsFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'csv-min-2' => [ + 'property' => 'csv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'minItems' => 2, + ], + ], + 'csv-max-3' => [ + 'property' => 'csv-max-3', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'maxItems' => 3, + ], + ], + 'ssv-min-2' => [ + 'property' => 'ssv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'ssv', + 'minItems' => 2, + ], + ], + 'tsv-min-2' => [ + 'property' => 'tsv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'tsv', + 'minItems' => 2, + ], + ], + 'pipes-min-2' => [ + 'property' => 'pipes-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'pipes', + 'minItems' => 2, + ], + ], + 'csv-uniques' => [ + 'property' => 'csv-uniques', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'uniqueItems' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/BoundsFilter.php b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php new file mode 100644 index 00000000000..ef46fc29f36 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class BoundsFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'maximum' => [ + 'property' => 'maximum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'maximum' => 10, + ], + ], + 'exclusiveMaximum' => [ + 'property' => 'maximum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => true, + ], + ], + 'minimum' => [ + 'property' => 'minimum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'minimum' => 5, + ], + ], + 'exclusiveMinimum' => [ + 'property' => 'exclusiveMinimum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'minimum' => 5, + 'exclusiveMinimum' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/EnumFilter.php b/tests/Fixtures/TestBundle/Filter/EnumFilter.php new file mode 100644 index 00000000000..a2fe49b2598 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/EnumFilter.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class EnumFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'enum' => [ + 'property' => 'enum', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'enum' => ['in-enum', 'mune-ni'], + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/LengthFilter.php b/tests/Fixtures/TestBundle/Filter/LengthFilter.php new file mode 100644 index 00000000000..be9c30af0b2 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/LengthFilter.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class LengthFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'max-length-3' => [ + 'property' => 'max-length-3', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'maxLength' => 3, + ], + ], + 'min-length-3' => [ + 'property' => 'min-length-3', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'minLength' => 3, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php new file mode 100644 index 00000000000..f806a99950d --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class MultipleOfFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'multiple-of' => [ + 'property' => 'multiple-of', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'multipleOf' => 2, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/PatternFilter.php b/tests/Fixtures/TestBundle/Filter/PatternFilter.php new file mode 100644 index 00000000000..ccb9f56e731 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/PatternFilter.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class PatternFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'pattern' => [ + 'property' => 'pattern', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'pattern' => '/^(pattern|nrettap)$/', + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php new file mode 100644 index 00000000000..d0dc25882dd --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class RequiredAllowEmptyFilter extends AbstractFilter +{ + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'required-allow-empty' => [ + 'property' => 'required-allow-empty', + 'type' => 'string', + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 456baa29f73..68732fd3a1f 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -129,6 +129,34 @@ services: arguments: ['@doctrine'] tags: ['api_platform.filter'] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + app.config_dummy_resource.action: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Action\ConfigCustom' arguments: ['@api_platform.item_data_provider']