Skip to content

Commit d2250c1

Browse files
authored
Merge pull request #1692 from jdeniau/jd-feat-queryParameterValidation
throw an exception if required filter is not set
2 parents 23a8dff + 1e353af commit d2250c1

File tree

13 files changed

+594
-1
lines changed

13 files changed

+594
-1
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
Feature: Validate filters based upon filter description
2+
3+
@createSchema
4+
Scenario: Required filter should not throw an error if set
5+
When I am on "/filter_validators?required=foo"
6+
Then the response status code should be 200
7+
8+
When I am on "/filter_validators?required="
9+
Then the response status code should be 200
10+
11+
Scenario: Required filter should throw an error if not set
12+
When I am on "/filter_validators"
13+
Then the response status code should be 400
14+
And the JSON node "detail" should be equal to 'Query parameter "required" is required'
15+
16+
Scenario: Required filter should not throw an error if set
17+
When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo"
18+
Then the response status code should be 200
19+
20+
Scenario: Required filter should throw an error if not set
21+
When I am on "/array_filter_validators"
22+
Then the response status code should be 400
23+
And the JSON node "detail" should match '/^Query parameter "arrayRequired\[\]" is required\nQuery parameter "indexedArrayRequired\[foo\]" is required$/'
24+
25+
When I am on "/array_filter_validators?arrayRequired=foo&indexedArrayRequired[foo]=foo"
26+
Then the response status code should be 400
27+
And the JSON node "detail" should be equal to 'Query parameter "arrayRequired[]" is required'
28+
29+
When I am on "/array_filter_validators?arrayRequired[foo]=foo"
30+
Then the response status code should be 400
31+
And the JSON node "detail" should match '/^Query parameter "arrayRequired\[\]" is required\nQuery parameter "indexedArrayRequired\[foo\]" is required$/'
32+
33+
When I am on "/array_filter_validators?arrayRequired[]=foo"
34+
Then the response status code should be 400
35+
And the JSON node "detail" should be equal to 'Query parameter "indexedArrayRequired[foo]" is required'
36+
37+
When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[bar]=bar"
38+
Then the response status code should be 400
39+
And the JSON node "detail" should be equal to 'Query parameter "indexedArrayRequired[foo]" is required'

src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection;
1515

16+
use ApiPlatform\Core\Exception\FilterValidationException;
1617
use ApiPlatform\Core\Exception\InvalidArgumentException;
1718
use FOS\UserBundle\FOSUserBundle;
1819
use GraphQL\GraphQL;
@@ -248,6 +249,7 @@ private function addExceptionToStatusSection(ArrayNodeDefinition $rootNode)
248249
->defaultValue([
249250
ExceptionInterface::class => Response::HTTP_BAD_REQUEST,
250251
InvalidArgumentException::class => Response::HTTP_BAD_REQUEST,
252+
FilterValidationException::class => Response::HTTP_BAD_REQUEST,
251253
])
252254
->info('The list of exceptions mapped to their HTTP status code.')
253255
->normalizeKeys(false)

src/Bridge/Symfony/Bundle/Resources/config/validator.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@
2222

2323
<tag name="kernel.event_listener" event="kernel.view" method="onKernelView" priority="64" />
2424
</service>
25+
26+
<service id="api_platform.listener.view.validate_query_parameters" class="ApiPlatform\Core\Filter\QueryParameterValidateListener" public="false">
27+
<argument type="service" id="api_platform.metadata.resource.metadata_factory" />
28+
<argument type="service" id="api_platform.filter_locator" />
29+
30+
<tag name="kernel.event_listener" event="kernel.request" method="onKernelRequest" priority="16" />
31+
</service>
2532
</services>
2633

2734
</container>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Exception;
15+
16+
/**
17+
* Filter validation exception.
18+
*
19+
* @author Julien DENIAU <[email protected]>
20+
*/
21+
final class FilterValidationException extends \Exception implements ExceptionInterface
22+
{
23+
private $constraintViolationList;
24+
25+
public function __construct(array $constraintViolationList, string $message = '', int $code = 0, \Exception $previous = null)
26+
{
27+
$this->constraintViolationList = $constraintViolationList;
28+
29+
parent::__construct($message ?: $this->__toString(), $code, $previous);
30+
}
31+
32+
public function __toString(): string
33+
{
34+
return implode("\n", $this->constraintViolationList);
35+
}
36+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Core\Filter;
15+
16+
use ApiPlatform\Core\Api\FilterLocatorTrait;
17+
use ApiPlatform\Core\Exception\FilterValidationException;
18+
use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface;
19+
use ApiPlatform\Core\Util\RequestAttributesExtractor;
20+
use Psr\Container\ContainerInterface;
21+
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
22+
23+
/**
24+
* Validates query parameters depending on filter description.
25+
*
26+
* @author Julien Deniau <[email protected]>
27+
*/
28+
final class QueryParameterValidateListener
29+
{
30+
use FilterLocatorTrait;
31+
32+
private $resourceMetadataFactory;
33+
34+
public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, ContainerInterface $filterLocator)
35+
{
36+
$this->resourceMetadataFactory = $resourceMetadataFactory;
37+
$this->setFilterLocator($filterLocator);
38+
}
39+
40+
public function onKernelRequest(GetResponseEvent $event)
41+
{
42+
$request = $event->getRequest();
43+
if (
44+
!$request->isMethodSafe(false)
45+
|| !($attributes = RequestAttributesExtractor::extractAttributes($request))
46+
|| !isset($attributes['collection_operation_name'])
47+
|| 'get' !== ($operationName = $attributes['collection_operation_name'])
48+
) {
49+
return;
50+
}
51+
52+
$resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']);
53+
$resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true);
54+
55+
$errorList = [];
56+
foreach ($resourceFilters as $filterId) {
57+
if (!$filter = $this->getFilter($filterId)) {
58+
continue;
59+
}
60+
61+
foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) {
62+
if (!($data['required'] ?? false)) { // property is not required
63+
continue;
64+
}
65+
66+
if (!$this->isRequiredFilterValid($name, $request)) {
67+
$errorList[] = sprintf('Query parameter "%s" is required', $name);
68+
}
69+
}
70+
}
71+
72+
if ($errorList) {
73+
throw new FilterValidationException($errorList);
74+
}
75+
}
76+
77+
/**
78+
* Test if required filter is valid. It validates array notation too like "required[bar]".
79+
*/
80+
private function isRequiredFilterValid($name, $request): bool
81+
{
82+
$matches = [];
83+
parse_str($name, $matches);
84+
if (!$matches) {
85+
return false;
86+
}
87+
88+
$rootName = array_keys($matches)[0] ?? '';
89+
if (!$rootName) {
90+
return false;
91+
}
92+
93+
if (\is_array($matches[$rootName])) {
94+
$keyName = array_keys($matches[$rootName])[0];
95+
96+
$queryParameter = $request->query->get($rootName);
97+
98+
return \is_array($queryParameter) && isset($queryParameter[$keyName]);
99+
}
100+
101+
return null !== $request->query->get($rootName);
102+
}
103+
}

tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface;
2929
use ApiPlatform\Core\DataProvider\ItemDataProviderInterface;
3030
use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface;
31+
use ApiPlatform\Core\Exception\FilterValidationException;
3132
use ApiPlatform\Core\Exception\InvalidArgumentException;
3233
use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface;
3334
use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface;
@@ -451,7 +452,11 @@ private function getPartialContainerBuilderProphecy($test = false)
451452
'api_platform.description' => 'description',
452453
'api_platform.error_formats' => ['jsonproblem' => ['application/problem+json'], 'jsonld' => ['application/ld+json']],
453454
'api_platform.formats' => ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']],
454-
'api_platform.exception_to_status' => [ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST],
455+
'api_platform.exception_to_status' => [
456+
ExceptionInterface::class => Response::HTTP_BAD_REQUEST,
457+
InvalidArgumentException::class => Response::HTTP_BAD_REQUEST,
458+
FilterValidationException::class => Response::HTTP_BAD_REQUEST,
459+
],
455460
'api_platform.title' => 'title',
456461
'api_platform.version' => 'version',
457462
'api_platform.allow_plain_identifiers' => false,
@@ -516,6 +521,7 @@ private function getPartialContainerBuilderProphecy($test = false)
516521
'api_platform.listener.view.respond',
517522
'api_platform.listener.view.serialize',
518523
'api_platform.listener.view.validate',
524+
'api_platform.listener.view.validate_query_parameters',
519525
'api_platform.listener.view.write',
520526
'api_platform.metadata.extractor.xml',
521527
'api_platform.metadata.property.metadata_factory.cached',

tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\DependencyInjection;
1515

1616
use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Configuration;
17+
use ApiPlatform\Core\Exception\FilterValidationException;
1718
use ApiPlatform\Core\Exception\InvalidArgumentException;
1819
use PHPUnit\Framework\TestCase;
1920
use Symfony\Component\Config\Definition\Builder\TreeBuilder;
@@ -67,6 +68,7 @@ public function testDefaultConfig()
6768
'exception_to_status' => [
6869
ExceptionInterface::class => Response::HTTP_BAD_REQUEST,
6970
InvalidArgumentException::class => Response::HTTP_BAD_REQUEST,
71+
FilterValidationException::class => Response::HTTP_BAD_REQUEST,
7072
],
7173
'default_operation_path_resolver' => 'api_platform.operation_path_resolver.underscore',
7274
'path_segment_name_generator' => 'api_platform.path_segment_name_generator.underscore',

0 commit comments

Comments
 (0)