From bdc15449fb2f00f69203eb2586d175964dae0783 Mon Sep 17 00:00:00 2001 From: Piotr Zajac Date: Tue, 28 May 2024 13:26:56 +0200 Subject: [PATCH] validate empty request MapQueryString/MapRequestPayload skips validation when empty request is sent resolves #54617 --- .../Tests/Functional/ApiAttributesTest.php | 376 +++++++++++++++++- .../app/ApiAttributesTest/routing.yml | 20 +- .../RequestPayloadValueResolver.php | 4 +- .../RequestPayloadValueResolverTest.php | 16 +- 4 files changed, 381 insertions(+), 35 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php index 96b6d0ee98e14..e06eabef2cf9a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php @@ -23,11 +23,11 @@ class ApiAttributesTest extends AbstractWebTestCase /** * @dataProvider mapQueryStringProvider */ - public function testMapQueryString(array $query, string $expectedResponse, int $expectedStatusCode) + public function testMapQueryString(string $uri, array $query, string $expectedResponse, int $expectedStatusCode) { $client = self::createClient(['test_case' => 'ApiAttributesTest']); - $client->request('GET', '/map-query-string.json', $query); + $client->request('GET', $uri, $query); $response = $client->getResponse(); if ($expectedResponse) { @@ -40,13 +40,15 @@ public function testMapQueryString(array $query, string $expectedResponse, int $ public static function mapQueryStringProvider(): iterable { - yield 'empty' => [ + yield 'empty nullable query string' => [ + 'uri' => '/map-nullable-query-string.json', 'query' => [], 'expectedResponse' => '', 'expectedStatusCode' => 204, ]; - yield 'valid' => [ + yield 'valid nullable query string' => [ + 'uri' => '/map-nullable-query-string.json', 'query' => ['filter' => ['status' => 'approved', 'quantity' => '4']], 'expectedResponse' => <<<'JSON' { @@ -59,7 +61,63 @@ public static function mapQueryStringProvider(): iterable 'expectedStatusCode' => 200, ]; - yield 'invalid' => [ + yield 'invalid nullable query string' => [ + 'uri' => '/map-nullable-query-string.json', + 'query' => ['filter' => ['status' => 'approved', 'quantity' => '200']], + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 404, + "detail": "filter.quantity: This value should be less than 10.", + "violations": [ + { + "propertyPath": "filter.quantity", + "title": "This value should be less than 10.", + "template": "This value should be less than {{ compared_value }}.", + "parameters": { + "{{ value }}": "200", + "{{ compared_value }}": "10", + "{{ compared_value_type }}": "int" + }, + "type": "urn:uuid:079d7420-2d13-460c-8756-de810eeb37d2" + } + ] + } + JSON, + 'expectedStatusCode' => 404, + ]; + + yield 'empty query string with default value' => [ + 'uri' => '/map-query-string-with-default-value.json', + 'query' => [], + 'expectedResponse' => <<<'JSON' + { + "filter": { + "status": "approved", + "quantity": 5 + } + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'valid query string with default value' => [ + 'uri' => '/map-query-string-with-default-value.json', + 'query' => ['filter' => ['status' => 'approved', 'quantity' => '4']], + 'expectedResponse' => <<<'JSON' + { + "filter": { + "status": "approved", + "quantity": 4 + } + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'invalid query string with default value' => [ + 'uri' => '/map-query-string-with-default-value.json', 'query' => ['filter' => ['status' => 'approved', 'quantity' => '200']], 'expectedResponse' => <<<'JSON' { @@ -89,7 +147,7 @@ public static function mapQueryStringProvider(): iterable /** * @dataProvider mapRequestPayloadProvider */ - public function testMapRequestPayload(string $format, array $parameters, ?string $content, string $expectedResponse, int $expectedStatusCode) + public function testMapRequestPayload(string $uri, string $format, array $parameters, ?string $content, string $expectedResponse, int $expectedStatusCode) { $client = self::createClient(['test_case' => 'ApiAttributesTest']); @@ -102,7 +160,7 @@ public function testMapRequestPayload(string $format, array $parameters, ?string $client->request( 'POST', - '/map-request-body.'.$format, + $uri, $parameters, [], ['HTTP_ACCEPT' => $acceptHeader, 'CONTENT_TYPE' => $acceptHeader], @@ -123,7 +181,8 @@ public function testMapRequestPayload(string $format, array $parameters, ?string public static function mapRequestPayloadProvider(): iterable { - yield 'empty' => [ + yield 'empty nullable request' => [ + 'uri' => '/map-nullable-request-body.json', 'format' => 'json', 'parameters' => [], 'content' => '', @@ -131,7 +190,22 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 204, ]; - yield 'valid json' => [ + yield 'empty with default request value' => [ + 'uri' => '/map-request-body-with-default-value.json', + 'format' => 'json', + 'parameters' => [], + 'content' => '', + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'valid json with nullable request' => [ + 'uri' => '/map-nullable-request-body.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -149,7 +223,27 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 200, ]; - yield 'malformed json' => [ + yield 'valid json with default request value' => [ + 'uri' => '/map-request-body-with-default-value.json', + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'malformed json with nullable request' => [ + 'uri' => '/map-nullable-request-body.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -169,7 +263,38 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 400, ]; - yield 'unsupported format' => [ + yield 'malformed json with default request value' => [ + 'uri' => '/map-request-body-with-default-value.json', + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false, + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10", + "title": "An error occurred", + "status": 400, + "detail": "Bad Request" + } + JSON, + 'expectedStatusCode' => 400, + ]; + + yield 'unsupported format with nullable request' => [ + 'uri' => '/map-nullable-request-body.dummy', + 'format' => 'dummy', + 'parameters' => [], + 'content' => 'Hello', + 'expectedResponse' => '415 Unsupported Media Type', + 'expectedStatusCode' => 415, + ]; + + yield 'unsupported format with default request value' => [ + 'uri' => '/map-request-body-with-default-value.dummy', 'format' => 'dummy', 'parameters' => [], 'content' => 'Hello', @@ -177,7 +302,8 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 415, ]; - yield 'valid xml' => [ + yield 'valid xml with nullable request' => [ + 'uri' => '/map-nullable-request-body.xml', 'format' => 'xml', 'parameters' => [], 'content' => <<<'XML' @@ -195,7 +321,58 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 200, ]; - yield 'invalid type' => [ + yield 'valid xml with default request value' => [ + 'uri' => '/map-request-body-with-default-value.xml', + 'format' => 'xml', + 'parameters' => [], + 'content' => <<<'XML' + + Hello everyone! + true + + XML, + 'expectedResponse' => <<<'XML' + + Hello everyone! + 1 + + XML, + 'expectedStatusCode' => 200, + ]; + + yield 'invalid type with nullable request' => [ + 'uri' => '/map-nullable-request-body.json', + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": "string instead of bool" + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 422, + "detail": "approved: This value should be of type bool.", + "violations": [ + { + "propertyPath": "approved", + "title": "This value should be of type bool.", + "template": "This value should be of type {{ type }}.", + "parameters": { + "{{ type }}": "bool" + } + } + ] + } + JSON, + 'expectedStatusCode' => 422, + ]; + + yield 'invalid type with default request value' => [ + 'uri' => '/map-request-body-with-default-value.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -225,7 +402,51 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 422, ]; - yield 'validation error json' => [ + yield 'validation error json with nullable request' => [ + 'uri' => '/map-nullable-request-body.json', + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "", + "approved": true + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 422, + "detail": "comment: This value should not be blank.\ncomment: This value is too short. It should have 10 characters or more.", + "violations": [ + { + "propertyPath": "comment", + "title": "This value should not be blank.", + "template": "This value should not be blank.", + "parameters": { + "{{ value }}": "\"\"" + }, + "type": "urn:uuid:c1051bb4-d103-4f74-8988-acbcafc7fdc3" + }, + { + "propertyPath": "comment", + "title": "This value is too short. It should have 10 characters or more.", + "template": "This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.", + "parameters": { + "{{ value }}": "\"\"", + "{{ limit }}": "10", + "{{ value_length }}": "0" + }, + "type": "urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45" + } + ] + } + JSON, + 'expectedStatusCode' => 422, + ]; + + yield 'validation error json with default request value' => [ + 'uri' => '/map-request-body-with-default-value.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -267,7 +488,41 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 422, ]; - yield 'validation error xml' => [ + yield 'validation error xml with nullable request' => [ + 'uri' => '/map-nullable-request-body.xml', + 'format' => 'xml', + 'parameters' => [], + 'content' => <<<'XML' + + H + false + + XML, + 'expectedResponse' => <<<'XML' + + + https://symfony.com/errors/validation + Codestin Search App + 422 + comment: This value is too short. It should have 10 characters or more. + + comment + Codestin Search App + + + "H" + 10 + 1 + + urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45 + + + XML, + 'expectedStatusCode' => 422, + ]; + + yield 'validation error xml with default request value' => [ + 'uri' => '/map-request-body-with-default-value.xml', 'format' => 'xml', 'parameters' => [], 'content' => <<<'XML' @@ -299,7 +554,8 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 422, ]; - yield 'valid input' => [ + yield 'valid input with nullable request' => [ + 'uri' => '/map-nullable-request-body.json', 'format' => 'json', 'input' => ['comment' => 'Hello everyone!', 'approved' => '0'], 'content' => null, @@ -312,7 +568,60 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 200, ]; - yield 'validation error input' => [ + yield 'valid input with default request value' => [ + 'uri' => '/map-request-body-with-default-value.json', + 'format' => 'json', + 'input' => ['comment' => 'Hello everyone!', 'approved' => '0'], + 'content' => null, + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'validation error input with nullable request' => [ + 'uri' => '/map-nullable-request-body.json', + 'format' => 'json', + 'input' => ['comment' => '', 'approved' => '1'], + 'content' => null, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 422, + "detail": "comment: This value should not be blank.\ncomment: This value is too short. It should have 10 characters or more.", + "violations": [ + { + "propertyPath": "comment", + "title": "This value should not be blank.", + "template": "This value should not be blank.", + "parameters": { + "{{ value }}": "\"\"" + }, + "type": "urn:uuid:c1051bb4-d103-4f74-8988-acbcafc7fdc3" + }, + { + "propertyPath": "comment", + "title": "This value is too short. It should have 10 characters or more.", + "template": "This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.", + "parameters": { + "{{ value }}": "\"\"", + "{{ limit }}": "10", + "{{ value_length }}": "0" + }, + "type": "urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45" + } + ] + } + JSON, + 'expectedStatusCode' => 422, + ]; + + yield 'validation error input with default request value' => [ + 'uri' => '/map-request-body-with-default-value.json', 'format' => 'json', 'input' => ['comment' => '', 'approved' => '1'], 'content' => null, @@ -351,7 +660,7 @@ public static function mapRequestPayloadProvider(): iterable } } -class WithMapQueryStringController +class WithMapNullableQueryStringController { public function __invoke(#[MapQueryString] ?QueryString $query): Response { @@ -365,7 +674,17 @@ public function __invoke(#[MapQueryString] ?QueryString $query): Response } } -class WithMapRequestPayloadController +class WithMapQueryStringWithDefaultValueController +{ + public function __invoke(#[MapQueryString] QueryString $query = new QueryString(new Filter('approved', 5))): Response + { + return new JsonResponse( + ['filter' => ['status' => $query->filter->status, 'quantity' => $query->filter->quantity]], + ); + } +} + +class WithMapNullableRequestPayloadController { public function __invoke(#[MapRequestPayload] ?RequestBody $body, Request $request): Response { @@ -388,6 +707,25 @@ public function __invoke(#[MapRequestPayload] ?RequestBody $body, Request $reque } } +class WithMapRequestPayloadWithDefaultValueController +{ + public function __invoke(Request $request, #[MapRequestPayload] RequestBody $body = new RequestBody('Hello everyone!', false)): Response + { + if ('json' === $request->getPreferredFormat('json')) { + return new JsonResponse(['comment' => $body->comment, 'approved' => $body->approved]); + } + + return new Response( + << + {$body->comment} + {$body->approved} + + XML + ); + } +} + class QueryString { public function __construct( diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml index 9ec40e1708c2b..9bef42e7c5138 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml @@ -1,7 +1,15 @@ -map_query_string: - path: /map-query-string.{_format} - controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapQueryStringController +map_nullable_query_string: + path: /map-nullable-query-string.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapNullableQueryStringController -map_request_body: - path: /map-request-body.{_format} - controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestPayloadController +map_query_string_with_default_value: + path: /map-query-string-with-default-value.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapQueryStringWithDefaultValueController + +map_nullable_request_body: + path: /map-nullable-request-body.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapNullableRequestPayloadController + +map_request_body_with_default_value: + path: /map-request-body-with-default-value.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestPayloadWithDefaultValueController diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php index b104f861f92ee..735fa97716be9 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -161,7 +161,7 @@ public static function getSubscribedEvents(): array private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object { - if (!$data = $request->query->all()) { + if (!($data = $request->query->all()) && ($attribute->metadata->isNullable() || $attribute->metadata->hasDefaultValue())) { return null; } @@ -182,7 +182,7 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE); } - if ('' === $data = $request->getContent()) { + if ('' === ($data = $request->getContent()) && ($attribute->metadata->isNullable() || $attribute->metadata->hasDefaultValue())) { return null; } diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php index 9b68c80ff27e5..b569dac07be76 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -115,7 +115,7 @@ public function testNullableValueArgument() $validator->expects($this->never()) ->method('validate'); - $resolver = new RequestPayloadValueResolver(new Serializer(), $validator); + $resolver = new RequestPayloadValueResolver(new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]), $validator); $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, true, [ MapRequestPayload::class => new MapRequestPayload(), @@ -137,9 +137,9 @@ public function testQueryNullableValueArgument() $validator->expects($this->never()) ->method('validate'); - $resolver = new RequestPayloadValueResolver(new Serializer(), $validator); + $resolver = new RequestPayloadValueResolver(new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]), $validator); - $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, true, [ + $argument = new ArgumentMetadata('valid', QueryPayload::class, false, false, null, true, [ MapQueryString::class => new MapQueryString(), ]); $request = Request::create('/', 'GET'); @@ -159,7 +159,7 @@ public function testNullPayloadAndNotDefaultOrNullableArgument() $validator->expects($this->never()) ->method('validate'); - $resolver = new RequestPayloadValueResolver(new Serializer(), $validator); + $resolver = new RequestPayloadValueResolver(new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]), $validator); $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ MapRequestPayload::class => new MapRequestPayload(), @@ -174,7 +174,7 @@ public function testNullPayloadAndNotDefaultOrNullableArgument() $resolver->onKernelControllerArguments($event); $this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class)); } catch (HttpException $e) { - $this->assertSame(422, $e->getStatusCode()); + $this->assertSame(400, $e->getStatusCode()); } } @@ -184,9 +184,9 @@ public function testQueryNullPayloadAndNotDefaultOrNullableArgument() $validator->expects($this->never()) ->method('validate'); - $resolver = new RequestPayloadValueResolver(new Serializer(), $validator); + $resolver = new RequestPayloadValueResolver(new Serializer([new ObjectNormalizer()]), $validator); - $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ + $argument = new ArgumentMetadata('valid', QueryPayload::class, false, false, null, false, [ MapQueryString::class => new MapQueryString(), ]); $request = Request::create('/', 'GET'); @@ -229,7 +229,7 @@ public function testWithoutValidatorAndCouldNotDenormalize() public function testValidationNotPassed() { - $content = '{"price": 50, "title": ["not a string"]}'; + $content = '{"price": 50.0, "title": ["not a string"]}'; $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); $validator = $this->createMock(ValidatorInterface::class);