From fc7d4134a22d65418aca6d34f6a48c232c261118 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 | 846 ++++++++++++++++-- .../app/ApiAttributesTest/routing.yml | 28 +- .../RequestPayloadValueResolver.php | 4 +- .../RequestPayloadValueResolverTest.php | 16 +- 4 files changed, 809 insertions(+), 85 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php index 96b6d0ee98e14..a64da93b382fb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php @@ -11,6 +11,7 @@ namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; +use Composer\InstalledVersions; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -23,13 +24,14 @@ 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) { self::assertJsonStringEqualsJsonString($expectedResponse, $response->getContent()); } else { @@ -40,13 +42,70 @@ public function testMapQueryString(array $query, string $expectedResponse, int $ public static function mapQueryStringProvider(): iterable { - yield 'empty' => [ + yield 'empty query string mapping nullable attribute' => [ + 'uri' => '/map-query-string-to-nullable-attribute.json', 'query' => [], 'expectedResponse' => '', 'expectedStatusCode' => 204, ]; - yield 'valid' => [ + yield 'valid query string mapping nullable attribute' => [ + 'uri' => '/map-query-string-to-nullable-attribute.json', + 'query' => ['filter' => ['status' => 'approved', 'quantity' => '4']], + 'expectedResponse' => <<<'JSON' + { + "filter": { + "status": "approved", + "quantity": 4 + } + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'invalid query string mapping nullable attribute' => [ + 'uri' => '/map-query-string-to-nullable-attribute.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 mapping attribute with default value' => [ + 'uri' => '/map-query-string-to-attribute-with-default-value.json', + 'query' => [], + 'expectedResponse' => <<<'JSON' + { + "filter": { + "status": "approved", + "quantity": 5 + } + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'valid query string mapping attribute with default value' => [ + 'uri' => '/map-query-string-to-attribute-with-default-value.json', 'query' => ['filter' => ['status' => 'approved', 'quantity' => '4']], 'expectedResponse' => <<<'JSON' { @@ -59,7 +118,8 @@ public static function mapQueryStringProvider(): iterable 'expectedStatusCode' => 200, ]; - yield 'invalid' => [ + yield 'invalid query string mapping attribute with default value' => [ + 'uri' => '/map-query-string-to-attribute-with-default-value.json', 'query' => ['filter' => ['status' => 'approved', 'quantity' => '200']], 'expectedResponse' => <<<'JSON' { @@ -84,12 +144,92 @@ public static function mapQueryStringProvider(): iterable JSON, 'expectedStatusCode' => 404, ]; + + $expectedResponse = <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 404, + "detail": "filter: This value should be of type Symfony\\Bundle\\FrameworkBundle\\Tests\\Functional\\Filter.", + "violations": [ + { + "parameters": { + "hint": "Failed to create object because the class misses the \"filter\" property.", + "{{ type }}": "Symfony\\Bundle\\FrameworkBundle\\Tests\\Functional\\Filter" + }, + "propertyPath": "filter", + "template": "This value should be of type {{ type }}.", + "title": "This value should be of type Symfony\\Bundle\\FrameworkBundle\\Tests\\Functional\\Filter." + } + ] + } + JSON; + + $httpKernelVersion = InstalledVersions::getVersion('symfony/http-kernel'); + if ($httpKernelVersion && version_compare($httpKernelVersion, '7.2.0', '<')) { + $expectedResponse = <<<'JSON' + { + "type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10", + "title": "An error occurred", + "status": 404, + "detail": "Not Found" + } + JSON; + } + + yield 'empty query string mapping non-nullable attribute without default value' => [ + 'uri' => '/map-query-string-to-non-nullable-attribute-without-default-value.json', + 'query' => [], + 'expectedResponse' => $expectedResponse, + 'expectedStatusCode' => 404, + ]; + + yield 'valid query string mapping non-nullable attribute without default value' => [ + 'uri' => '/map-query-string-to-non-nullable-attribute-without-default-value.json', + 'query' => ['filter' => ['status' => 'approved', 'quantity' => '4']], + 'expectedResponse' => <<<'JSON' + { + "filter": { + "status": "approved", + "quantity": 4 + } + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'invalid query string mapping non-nullable attribute without default value' => [ + 'uri' => '/map-query-string-to-non-nullable-attribute-without-default-value.json', + 'query' => ['filter' => ['status' => 'approved', 'quantity' => '11']], + '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 }}": "11", + "{{ compared_value }}": "10", + "{{ compared_value_type }}": "int" + }, + "type": "urn:uuid:079d7420-2d13-460c-8756-de810eeb37d2" + } + ] + } + JSON, + 'expectedStatusCode' => 404, + ]; } /** * @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 +242,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 +263,8 @@ public function testMapRequestPayload(string $format, array $parameters, ?string public static function mapRequestPayloadProvider(): iterable { - yield 'empty' => [ + yield 'empty request mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.json', 'format' => 'json', 'parameters' => [], 'content' => '', @@ -131,7 +272,8 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 204, ]; - yield 'valid json' => [ + yield 'valid request with json content mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -149,7 +291,41 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 200, ]; - yield 'malformed json' => [ + yield 'valid request with xml content mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.xml', + 'format' => 'xml', + 'parameters' => [], + 'content' => <<<'XML' + + Hello everyone! + true + + XML, + 'expectedResponse' => <<<'XML' + + Hello everyone! + 1 + + XML, + 'expectedStatusCode' => 200, + ]; + + yield 'valid request mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.json', + 'format' => 'json', + 'parameters' => ['comment' => 'Hello everyone!', 'approved' => '0'], + 'content' => null, + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'malformed json request mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -169,7 +345,8 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 400, ]; - yield 'unsupported format' => [ + yield 'request with unsupported format mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.dummy', 'format' => 'dummy', 'parameters' => [], 'content' => 'Hello', @@ -177,25 +354,8 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 415, ]; - yield 'valid xml' => [ - 'format' => 'xml', - 'parameters' => [], - 'content' => <<<'XML' - - Hello everyone! - true - - XML, - 'expectedResponse' => <<<'XML' - - Hello everyone! - 1 - - XML, - 'expectedStatusCode' => 200, - ]; - - yield 'invalid type' => [ + yield 'request with invalid type mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -225,7 +385,8 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 422, ]; - yield 'validation error json' => [ + yield 'invalid request with json content mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.json', 'format' => 'json', 'parameters' => [], 'content' => <<<'JSON' @@ -267,7 +428,8 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 422, ]; - yield 'validation error xml' => [ + yield 'invalid request with xml content mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.xml', 'format' => 'xml', 'parameters' => [], 'content' => <<<'XML' @@ -299,22 +461,10 @@ public static function mapRequestPayloadProvider(): iterable 'expectedStatusCode' => 422, ]; - yield 'valid input' => [ - 'format' => 'json', - 'input' => ['comment' => 'Hello everyone!', 'approved' => '0'], - 'content' => null, - 'expectedResponse' => <<<'JSON' - { - "comment": "Hello everyone!", - "approved": false - } - JSON, - 'expectedStatusCode' => 200, - ]; - - yield 'validation error input' => [ + yield 'invalid request mapping nullable attribute' => [ + 'uri' => '/map-request-to-nullable-attribute.json', 'format' => 'json', - 'input' => ['comment' => '', 'approved' => '1'], + 'parameters' => ['comment' => '', 'approved' => '1'], 'content' => null, 'expectedResponse' => <<<'JSON' { @@ -348,32 +498,590 @@ public static function mapRequestPayloadProvider(): iterable JSON, 'expectedStatusCode' => 422, ]; - } -} -class WithMapQueryStringController -{ - public function __invoke(#[MapQueryString] ?QueryString $query): Response - { - if (!$query) { - return new Response('', Response::HTTP_NO_CONTENT); - } + yield 'empty request mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.json', + 'format' => 'json', + 'parameters' => [], + 'content' => '', + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; - return new JsonResponse( - ['filter' => ['status' => $query->filter->status, 'quantity' => $query->filter->quantity]], - ); - } -} + yield 'valid request with json content mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-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, + ]; -class WithMapRequestPayloadController -{ - public function __invoke(#[MapRequestPayload] ?RequestBody $body, Request $request): Response - { - if ('json' === $request->getPreferredFormat('json')) { - if (!$body) { - return new Response('', Response::HTTP_NO_CONTENT); - } + yield 'valid request with xml content mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.xml', + 'format' => 'xml', + 'parameters' => [], + 'content' => <<<'XML' + + Hello everyone! + true + + XML, + 'expectedResponse' => <<<'XML' + + Hello everyone! + 1 + + XML, + 'expectedStatusCode' => 200, + ]; + + yield 'valid request mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.json', + 'format' => 'json', + 'parameters' => ['comment' => 'Hello everyone!', 'approved' => '0'], + 'content' => null, + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + yield 'malformed json request mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-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 'request with unsupported format mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.dummy', + 'format' => 'dummy', + 'parameters' => [], + 'content' => 'Hello', + 'expectedResponse' => '415 Unsupported Media Type', + 'expectedStatusCode' => 415, + ]; + + yield 'request with invalid type mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.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 request with json content mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.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 'invalid request with xml content mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.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 'invalid request mapping attribute with default value' => [ + 'uri' => '/map-request-to-attribute-with-default-value.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, + ]; + + $expectedStatusCode = 400; + $expectedResponse = <<<'JSON' + { + "type":"https:\/\/tools.ietf.org\/html\/rfc2616#section-10", + "title":"An error occurred", + "status":400, + "detail":"Bad Request" + } + JSON; + + $httpKernelVersion = InstalledVersions::getVersion('symfony/http-kernel'); + if ($httpKernelVersion && version_compare($httpKernelVersion, '7.2.0', '<')) { + $expectedStatusCode = 422; + $expectedResponse = <<<'JSON' + { + "type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10", + "title": "An error occurred", + "status": 422, + "detail": "Unprocessable Content" + } + JSON; + } + + yield 'empty request mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.json', + 'format' => 'json', + 'parameters' => [], + 'content' => '', + 'expectedResponse' => $expectedResponse, + 'expectedStatusCode' => $expectedStatusCode, + ]; + + yield 'valid request with json content mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-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 'valid request with xml content mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.xml', + 'format' => 'xml', + 'parameters' => [], + 'content' => <<<'XML' + + Hello everyone! + true + + XML, + 'expectedResponse' => <<<'XML' + + Hello everyone! + 1 + + XML, + 'expectedStatusCode' => 200, + ]; + + yield 'valid request mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.json', + 'format' => 'json', + 'parameters' => ['comment' => 'Hello everyone!', 'approved' => '0'], + 'content' => null, + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'malformed json request mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-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 'request with unsupported format mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.dummy', + 'format' => 'dummy', + 'parameters' => [], + 'content' => 'Hello', + 'expectedResponse' => '415 Unsupported Media Type', + 'expectedStatusCode' => 415, + ]; + + yield 'request with invalid type mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.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 request with json content mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.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 'invalid request with xml content mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.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 'invalid request mapping non-nullable attribute without default value' => [ + 'uri' => '/map-request-to-non-nullable-attribute-without-default-value.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, + ]; + } +} + +class WithMapQueryStringToNullableAttributeController +{ + public function __invoke(#[MapQueryString] ?QueryString $query): Response + { + if (!$query) { + return new Response('', Response::HTTP_NO_CONTENT); + } + + return new JsonResponse( + ['filter' => ['status' => $query->filter->status, 'quantity' => $query->filter->quantity]], + ); + } +} + +class WithMapQueryStringToAttributeWithDefaultValueController +{ + 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 WithMapQueryStringToNonNullableAttributeWithoutDefaultValueController +{ + public function __invoke(#[MapQueryString] QueryString $query): Response + { + return new JsonResponse( + ['filter' => ['status' => $query->filter->status, 'quantity' => $query->filter->quantity]], + ); + } +} + +class WithMapRequestToNullableAttributeController +{ + public function __invoke(#[MapRequestPayload] ?RequestBody $body, Request $request): Response + { + if ('json' === $request->getPreferredFormat('json')) { + if (!$body) { + return new Response('', Response::HTTP_NO_CONTENT); + } + + return new JsonResponse(['comment' => $body->comment, 'approved' => $body->approved]); + } + + return new Response( + << + {$body->comment} + {$body->approved} + + XML + ); + } +} + +class WithMapRequestToAttributeWithDefaultValueController +{ + 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 WithMapRequestToNonNullableAttributeWithoutDefaultValueController +{ + public function __invoke(Request $request, #[MapRequestPayload] RequestBody $body): Response + { + if ('json' === $request->getPreferredFormat('json')) { return new JsonResponse(['comment' => $body->comment, 'approved' => $body->approved]); } 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..a2827eb3d07b5 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,23 @@ -map_query_string: - path: /map-query-string.{_format} - controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapQueryStringController +map_query_string_to_nullable_attribute: + path: /map-query-string-to-nullable-attribute.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapQueryStringToNullableAttributeController -map_request_body: - path: /map-request-body.{_format} - controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestPayloadController +map_query_string_to_attribute_with_default_value: + path: /map-query-string-to-attribute-with-default-value.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapQueryStringToAttributeWithDefaultValueController + +map_query_string_to_non_nullable_attribute_without_default_value: + path: /map-query-string-to-non-nullable-attribute-without-default-value.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapQueryStringToNonNullableAttributeWithoutDefaultValueController + +map_request_to_nullable_attribute: + path: /map-request-to-nullable-attribute.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestToNullableAttributeController + +map_request_to_attribute_with_default_value: + path: /map-request-to-attribute-with-default-value.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestToAttributeWithDefaultValueController + +map_request_to_non_nullable_attribute_without_default_value: + path: /map-request-to-non-nullable-attribute-without-default-value.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestToNonNullableAttributeWithoutDefaultValueController diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php index 7ccff6f7584b1..94d04bfe4ec28 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -185,7 +185,7 @@ public static function getSubscribedEvents(): array private function mapQueryString(Request $request, ArgumentMetadata $argument, MapQueryString $attribute): ?object { - if (!$data = $request->query->all()) { + if (!($data = $request->query->all()) && ($argument->isNullable() || $argument->hasDefaultValue())) { return null; } @@ -212,7 +212,7 @@ private function mapRequestPayload(Request $request, ArgumentMetadata $argument, return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ('form' === $format ? ['filter_bool' => true] : [])); } - if ('' === $data = $request->getContent()) { + if ('' === ($data = $request->getContent()) && ($argument->isNullable() || $argument->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 7b830b041bd34..8b26767f9ea94 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -116,7 +116,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(), @@ -138,9 +138,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'); @@ -160,7 +160,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(), @@ -175,7 +175,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()); } } @@ -185,9 +185,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'); @@ -230,7 +230,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);