diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php index f83e331e4118f..9a4ed12ac0474 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapQueryParameter.php @@ -11,12 +11,14 @@ namespace Symfony\Component\HttpKernel\Attribute; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver; /** * Can be used to pass a query parameter to a controller argument. * * @author Ruud Kamphuis + * @author Ionut Enache */ #[\Attribute(\Attribute::TARGET_PARAMETER)] final class MapQueryParameter extends ValueResolver @@ -32,6 +34,7 @@ public function __construct( public int $flags = 0, public array $options = [], string $resolver = QueryParameterValueResolver::class, + public int $validationFailedStatusCode = Response::HTTP_NOT_FOUND, ) { parent::__construct($resolver); } diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 45761dfbbed42..712da050db306 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add method `isKernelTerminating()` to `ExceptionEvent` that allows to check if an exception was thrown while the kernel is being terminated * Add `HttpException::fromStatusCode()` + * Add `$validationFailedStatusCode` argument to `#[MapQueryParameter]` that allows setting a custom HTTP status code when validation fails 7.0 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php index b186a39c594c8..97d7020a56fd4 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/QueryParameterValueResolver.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; /** * Resolve arguments of type: array, string, int, float, bool, \BackedEnum from query parameters. @@ -23,6 +23,7 @@ * @author Ruud Kamphuis * @author Nicolas Grekas * @author Mateusz Anders + * @author Ionut Enache */ final class QueryParameterValueResolver implements ValueResolverInterface { @@ -33,12 +34,14 @@ public function resolve(Request $request, ArgumentMetadata $argument): array } $name = $attribute->name ?? $argument->getName(); + $validationFailedCode = $attribute->validationFailedStatusCode; + if (!$request->query->has($name)) { if ($argument->isNullable() || $argument->hasDefaultValue()) { return []; } - throw new NotFoundHttpException(sprintf('Missing query parameter "%s".', $name)); + throw HttpException::fromStatusCode($validationFailedCode, sprintf('Missing query parameter "%s".', $name)); } $value = $request->query->all()[$name]; @@ -52,7 +55,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): array $filtered = array_values(array_filter((array) $value, \is_array(...))); if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) { - throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name)); + throw HttpException::fromStatusCode($validationFailedCode, sprintf('Invalid query parameter "%s".', $name)); } return $filtered; @@ -103,7 +106,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): array } if (null === $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) { - throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name)); + throw HttpException::fromStatusCode($validationFailedCode, sprintf('Invalid query parameter "%s".', $name)); } if (!\is_array($value)) { @@ -117,7 +120,7 @@ public function resolve(Request $request, ArgumentMetadata $argument): array } if ($filtered !== $value && !($attribute->flags & \FILTER_NULL_ON_FAILURE)) { - throw new NotFoundHttpException(sprintf('Invalid query parameter "%s".', $name)); + throw HttpException::fromStatusCode($validationFailedCode, sprintf('Invalid query parameter "%s".', $name)); } return $argument->isVariadic() ? $filtered : [$filtered]; diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php index 4348f932fd5c6..cc7e1411691ba 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/QueryParameterValueResolverTest.php @@ -13,10 +13,13 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\MapQueryParameter; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\QueryParameterValueResolver; use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Tests\Fixtures\Suit; @@ -29,17 +32,45 @@ protected function setUp(): void $this->resolver = new QueryParameterValueResolver(); } + public function testSkipWhenNoAttribute() + { + $metadata = new ArgumentMetadata('firstName', 'string', false, true, false); + + $this->assertSame([], $this->resolver->resolve(Request::create('/'), $metadata)); + } + /** - * @dataProvider provideTestResolve + * @dataProvider validDataProvider */ - public function testResolve(Request $request, ArgumentMetadata $metadata, array $expected, string $exceptionClass = null, string $exceptionMessage = null) + public function testResolvingSuccessfully(Request $request, ArgumentMetadata $metadata, array $expected) { - if ($exceptionMessage) { - self::expectException($exceptionClass); - self::expectExceptionMessage($exceptionMessage); - } + $this->assertSame($expected, $this->resolver->resolve($request, $metadata)); + } - self::assertSame($expected, $this->resolver->resolve($request, $metadata)); + /** + * @dataProvider invalidArgumentTypeProvider + */ + public function testResolvingWithInvalidArgumentType(Request $request, ArgumentMetadata $metadata, string $exceptionMessage) + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage($exceptionMessage); + + $this->resolver->resolve($request, $metadata); + } + + /** + * @dataProvider invalidOrMissingArgumentProvider + */ + public function testResolvingWithInvalidOrMissingArgument(Request $request, ArgumentMetadata $metadata, HttpException $expectedException) + { + try { + $this->resolver->resolve($request, $metadata); + + $this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class)); + } catch (HttpException $exception) { + $this->assertSame($expectedException->getMessage(), $exception->getMessage()); + $this->assertSame($expectedException->getStatusCode(), $exception->getStatusCode()); + } } /** @@ -47,250 +78,250 @@ public function testResolve(Request $request, ArgumentMetadata $metadata, array * Request, * ArgumentMetadata, * array, - * null|class-string<\Exception>, - * null|string * }> */ - public static function provideTestResolve(): iterable + public static function validDataProvider(): iterable { yield 'parameter found and array' => [ Request::create('/', 'GET', ['ids' => ['1', '2']]), new ArgumentMetadata('ids', 'array', false, false, false, attributes: [new MapQueryParameter()]), [['1', '2']], - null, ]; + yield 'parameter found and array variadic' => [ Request::create('/', 'GET', ['ids' => [['1', '2'], ['2']]]), new ArgumentMetadata('ids', 'array', true, false, false, attributes: [new MapQueryParameter()]), [['1', '2'], ['2']], - null, - ]; - yield 'parameter found and array variadic with parameter not array failure' => [ - Request::create('/', 'GET', ['ids' => [['1', '2'], 1]]), - new ArgumentMetadata('ids', 'array', true, false, false, attributes: [new MapQueryParameter()]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "ids".', ]; + yield 'parameter found and string' => [ Request::create('/', 'GET', ['firstName' => 'John']), new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter()]), ['John'], - null, ]; + yield 'parameter found and string variadic' => [ Request::create('/', 'GET', ['ids' => ['1', '2']]), new ArgumentMetadata('ids', 'string', true, false, false, attributes: [new MapQueryParameter()]), ['1', '2'], - null, ]; + yield 'parameter found and string with regexp filter that matches' => [ Request::create('/', 'GET', ['firstName' => 'John']), new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), ['John'], - null, ]; + yield 'parameter found and string with regexp filter that falls back to null on failure' => [ Request::create('/', 'GET', ['firstName' => 'Fabien']), new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), [null], - null, - ]; - yield 'parameter found and string with regexp filter that does not match' => [ - Request::create('/', 'GET', ['firstName' => 'Fabien']), - new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "firstName".', ]; + yield 'parameter found and string variadic with regexp filter that matches' => [ Request::create('/', 'GET', ['firstName' => ['John', 'John']]), new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), ['John', 'John'], - null, ]; + yield 'parameter found and string variadic with regexp filter that falls back to null on failure' => [ Request::create('/', 'GET', ['firstName' => ['John', 'Fabien']]), new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, flags: \FILTER_NULL_ON_FAILURE, options: ['regexp' => '/John/'])]), ['John'], - null, - ]; - yield 'parameter found and string variadic with regexp filter that does not match' => [ - Request::create('/', 'GET', ['firstName' => ['Fabien']]), - new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "firstName".', ]; + yield 'parameter found and integer' => [ Request::create('/', 'GET', ['age' => 123]), new ArgumentMetadata('age', 'int', false, false, false, attributes: [new MapQueryParameter()]), [123], - null, ]; + yield 'parameter found and integer variadic' => [ Request::create('/', 'GET', ['age' => [123, 222]]), new ArgumentMetadata('age', 'int', true, false, false, attributes: [new MapQueryParameter()]), [123, 222], - null, ]; + yield 'parameter found and float' => [ Request::create('/', 'GET', ['price' => 10.99]), new ArgumentMetadata('price', 'float', false, false, false, attributes: [new MapQueryParameter()]), [10.99], - null, ]; + yield 'parameter found and float variadic' => [ Request::create('/', 'GET', ['price' => [10.99, 5.99]]), new ArgumentMetadata('price', 'float', true, false, false, attributes: [new MapQueryParameter()]), [10.99, 5.99], - null, ]; + yield 'parameter found and boolean yes' => [ Request::create('/', 'GET', ['isVerified' => 'yes']), new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), [true], - null, ]; + yield 'parameter found and boolean yes variadic' => [ Request::create('/', 'GET', ['isVerified' => ['yes', 'yes']]), new ArgumentMetadata('isVerified', 'bool', true, false, false, attributes: [new MapQueryParameter()]), [true, true], - null, ]; + yield 'parameter found and boolean true' => [ Request::create('/', 'GET', ['isVerified' => 'true']), new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), [true], - null, ]; + yield 'parameter found and boolean 1' => [ Request::create('/', 'GET', ['isVerified' => '1']), new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), [true], - null, ]; + yield 'parameter found and boolean no' => [ Request::create('/', 'GET', ['isVerified' => 'no']), new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), [false], - null, - ]; - yield 'parameter found and boolean invalid' => [ - Request::create('/', 'GET', ['isVerified' => 'whatever']), - new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "isVerified".', ]; yield 'parameter found and backing value' => [ Request::create('/', 'GET', ['suit' => 'H']), new ArgumentMetadata('suit', Suit::class, false, false, false, attributes: [new MapQueryParameter()]), [Suit::Hearts], - null, ]; + yield 'parameter found and backing value variadic' => [ Request::create('/', 'GET', ['suits' => ['H', 'D']]), new ArgumentMetadata('suits', Suit::class, true, false, false, attributes: [new MapQueryParameter()]), [Suit::Hearts, Suit::Diamonds], - null, - ]; - yield 'parameter found and backing value not int nor string' => [ - Request::create('/', 'GET', ['suit' => 1]), - new ArgumentMetadata('suit', Suit::class, false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_BOOL)]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "suit".', ]; + yield 'parameter found and backing value not int nor string that fallbacks to null on failure' => [ Request::create('/', 'GET', ['suit' => 1]), new ArgumentMetadata('suit', Suit::class, false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_BOOL, flags: \FILTER_NULL_ON_FAILURE)]), [null], - null, - ]; - yield 'parameter found and value not valid backing value' => [ - Request::create('/', 'GET', ['suit' => 'B']), - new ArgumentMetadata('suit', Suit::class, false, false, false, attributes: [new MapQueryParameter()]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "suit".', ]; + yield 'parameter found and value not valid backing value that falls back to null on failure' => [ Request::create('/', 'GET', ['suit' => 'B']), new ArgumentMetadata('suit', Suit::class, false, false, false, attributes: [new MapQueryParameter(flags: \FILTER_NULL_ON_FAILURE)]), [null], - null, - ]; - yield 'parameter found and backing type variadic and at least one backing value not int nor string' => [ - Request::create('/', 'GET', ['suits' => [1, 'D']]), - new ArgumentMetadata('suits', Suit::class, false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_BOOL)]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "suits".', ]; + yield 'parameter found and backing type variadic and at least one backing value not int nor string that fallbacks to null on failure' => [ Request::create('/', 'GET', ['suits' => [1, 'D']]), new ArgumentMetadata('suits', Suit::class, false, false, false, attributes: [new MapQueryParameter(flags: \FILTER_NULL_ON_FAILURE)]), [null], - null, - ]; - yield 'parameter found and backing type variadic and at least one value not valid backing value' => [ - Request::create('/', 'GET', ['suits' => ['B', 'D']]), - new ArgumentMetadata('suits', Suit::class, false, false, false, attributes: [new MapQueryParameter()]), - [], - NotFoundHttpException::class, - 'Invalid query parameter "suits".', ]; + yield 'parameter found and backing type variadic and at least one value not valid backing value that falls back to null on failure' => [ Request::create('/', 'GET', ['suits' => ['B', 'D']]), new ArgumentMetadata('suits', Suit::class, false, false, false, attributes: [new MapQueryParameter(flags: \FILTER_NULL_ON_FAILURE)]), [null], - null, ]; yield 'parameter not found but nullable' => [ Request::create('/', 'GET'), new ArgumentMetadata('firstName', 'string', false, false, false, true, [new MapQueryParameter()]), [], - null, ]; yield 'parameter not found but optional' => [ Request::create('/', 'GET'), new ArgumentMetadata('firstName', 'string', false, true, false, attributes: [new MapQueryParameter()]), [], - null, - ]; - - yield 'parameter not found' => [ - Request::create('/', 'GET'), - new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter()]), - [], - NotFoundHttpException::class, - 'Missing query parameter "firstName".', ]; + } + /** + * @return iterable + */ + public static function invalidArgumentTypeProvider(): iterable + { yield 'unsupported type' => [ Request::create('/', 'GET', ['standardClass' => 'test']), new ArgumentMetadata('standardClass', \stdClass::class, false, false, false, attributes: [new MapQueryParameter()]), - [], - \LogicException::class, '#[MapQueryParameter] cannot be used on controller argument "$standardClass" of type "stdClass"; one of array, string, int, float, bool or \BackedEnum should be used.', ]; + yield 'unsupported type variadic' => [ Request::create('/', 'GET', ['standardClass' => 'test']), new ArgumentMetadata('standardClass', \stdClass::class, true, false, false, attributes: [new MapQueryParameter()]), - [], - \LogicException::class, '#[MapQueryParameter] cannot be used on controller argument "...$standardClass" of type "stdClass"; one of array, string, int, float, bool or \BackedEnum should be used.', ]; } - public function testSkipWhenNoAttribute() + /** + * @return iterable + */ + public static function invalidOrMissingArgumentProvider(): iterable { - $metadata = new ArgumentMetadata('firstName', 'string', false, true, false); + yield 'parameter found and array variadic with parameter not array failure' => [ + Request::create('/', 'GET', ['ids' => [['1', '2'], 1]]), + new ArgumentMetadata('ids', 'array', true, false, false, attributes: [new MapQueryParameter()]), + new NotFoundHttpException('Invalid query parameter "ids".'), + ]; + + yield 'parameter found and string with regexp filter that does not match' => [ + Request::create('/', 'GET', ['firstName' => 'Fabien']), + new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]), + new NotFoundHttpException('Invalid query parameter "firstName".'), + ]; - self::assertSame([], $this->resolver->resolve(Request::create('/'), $metadata)); + yield 'parameter found and string variadic with regexp filter that does not match' => [ + Request::create('/', 'GET', ['firstName' => ['Fabien']]), + new ArgumentMetadata('firstName', 'string', true, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_REGEXP, options: ['regexp' => '/John/'])]), + new NotFoundHttpException('Invalid query parameter "firstName".'), + ]; + + yield 'parameter found and boolean invalid' => [ + Request::create('/', 'GET', ['isVerified' => 'whatever']), + new ArgumentMetadata('isVerified', 'bool', false, false, false, attributes: [new MapQueryParameter()]), + new NotFoundHttpException('Invalid query parameter "isVerified".'), + ]; + + yield 'parameter found and backing value not int nor string' => [ + Request::create('/', 'GET', ['suit' => 1]), + new ArgumentMetadata('suit', Suit::class, false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_BOOL)]), + new NotFoundHttpException('Invalid query parameter "suit".'), + ]; + + yield 'parameter found and value not valid backing value' => [ + Request::create('/', 'GET', ['suit' => 'B']), + new ArgumentMetadata('suit', Suit::class, false, false, false, attributes: [new MapQueryParameter()]), + new NotFoundHttpException('Invalid query parameter "suit".'), + ]; + + yield 'parameter found and backing type variadic and at least one backing value not int nor string' => [ + Request::create('/', 'GET', ['suits' => [1, 'D']]), + new ArgumentMetadata('suits', Suit::class, false, false, false, attributes: [new MapQueryParameter(filter: \FILTER_VALIDATE_BOOL)]), + new NotFoundHttpException('Invalid query parameter "suits".'), + ]; + + yield 'parameter found and backing type variadic and at least one value not valid backing value' => [ + Request::create('/', 'GET', ['suits' => ['B', 'D']]), + new ArgumentMetadata('suits', Suit::class, false, false, false, attributes: [new MapQueryParameter()]), + new NotFoundHttpException('Invalid query parameter "suits".'), + ]; + + yield 'parameter not found' => [ + Request::create('/', 'GET'), + new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter()]), + new NotFoundHttpException('Missing query parameter "firstName".'), + ]; + + yield 'parameter not found with custom validation failed status code' => [ + Request::create('/', 'GET'), + new ArgumentMetadata('firstName', 'string', false, false, false, attributes: [new MapQueryParameter(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]), + new BadRequestHttpException('Missing query parameter "firstName".'), + ]; } }