From a9acc0bb83f71545e0722a82f5518bbba7879332 Mon Sep 17 00:00:00 2001 From: Hugh Downer Date: Fri, 9 Sep 2022 09:02:42 +0100 Subject: [PATCH] Handle validation of nested query paramaters e.g. ?filter[groupId]= --- src/Validation/RequestValidator.php | 24 ++++++++++++++-- tests/Fixtures/Test.v1.json | 44 ++++++++++++++++++++++++++++- tests/RequestValidatorTest.php | 16 +++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/Validation/RequestValidator.php b/src/Validation/RequestValidator.php index 45e6272..719cea6 100644 --- a/src/Validation/RequestValidator.php +++ b/src/Validation/RequestValidator.php @@ -3,6 +3,7 @@ namespace Spectator\Validation; use cebe\openapi\spec\Operation; +use cebe\openapi\spec\Parameter; use cebe\openapi\spec\PathItem; use Illuminate\Database\Eloquent\Model; use Illuminate\Http\Request; @@ -94,7 +95,7 @@ protected function validateParameters() // Verify presence, if required. if ($parameter->in === 'path' && ! $route->hasParameter($parameter->name)) { throw new RequestValidationException("Missing required parameter {$parameter->name} in URL path."); - } elseif ($parameter->in === 'query' && ! $this->request->query->has($parameter->name)) { + } elseif ($parameter->in === 'query' && ! $this->hasQueryParam($parameter->name)) { throw new RequestValidationException("Missing required query parameter [?{$parameter->name}=]."); } elseif ($parameter->in === 'header' && ! $this->request->headers->has($parameter->name)) { throw new RequestValidationException("Missing required header [{$parameter->name}]."); @@ -117,8 +118,8 @@ protected function validateParameters() if ($parameter_value instanceof Model) { $parameter_value = $route->originalParameters()[$parameter->name]; } - } elseif ($parameter->in === 'query' && $this->request->query->has($parameter->name)) { - $parameter_value = $this->request->query->get($parameter->name); + } elseif ($parameter->in === 'query' && $this->hasQueryParam($parameter->name)) { + $parameter_value = $this->getQueryParam($parameter->name); } elseif ($parameter->in === 'header' && $this->request->headers->has($parameter->name)) { $parameter_value = $this->request->headers->get($parameter->name); } elseif ($parameter->in === 'cookie' && $this->request->cookies->has($parameter->name)) { @@ -221,4 +222,21 @@ private function toObject($data) return array_map([$this, 'toObject'], $data); } } + + private function hasQueryParam(string $parameterName): bool + { + return Arr::has($this->request->query->all(), $this->convertQueryParameterToDotted($parameterName)); + } + + private function getQueryParam(string $parameterName) + { + return Arr::get($this->request->query->all(), $this->convertQueryParameterToDotted($parameterName)); + } + + private function convertQueryParameterToDotted(string $parameterName): string + { + parse_str($parameterName, $parsedParameterName); + + return key(Arr::dot($parsedParameterName)); + } } diff --git a/tests/Fixtures/Test.v1.json b/tests/Fixtures/Test.v1.json index a61b479..cc47409 100644 --- a/tests/Fixtures/Test.v1.json +++ b/tests/Fixtures/Test.v1.json @@ -378,7 +378,7 @@ ], "get": { "summary": "Get Order", - "description": "Orders are made by organizations to fund tree planting, and when trees are planted those orders will be marked as fullfilled. Orders may be partially fulfilled, and it is up to you if you wish to import those. Make sure to deduplicate the trees if you do.", + "description": "Orders are made by organizations to fund tree planting, and when trees are planted those orders will be marked as fulfilled. Orders may be partially fulfilled, and it is up to you if you wish to import those. Make sure to deduplicate the trees if you do.", "tags": [], "responses": { "200": { @@ -414,6 +414,48 @@ }, "operationId": "get-orders-uuid" } + }, + "/orders": { + "parameters": [ + { + "schema": { + "type": "string", + "format": "uuid", + "example": "53f7bd7b-2ee2-4be4-a15b-e09c76a150f9" + }, + "name": "filter[groupId]", + "in": "query", + "required": true + } + ], + "get": { + "summary": "Filter Orders", + "description": "Orders are made by organizations to fund tree planting, and when trees are planted those orders will be marked as fulfilled. Orders may be partially fulfilled, and it is up to you if you wish to import those. Make sure to deduplicate the trees if you do.", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Generic_Problem" + } + } + } + } + }, + "operationId": "filter-orders-by-group" + } } }, "components": { diff --git a/tests/RequestValidatorTest.php b/tests/RequestValidatorTest.php index ea87e56..3b074af 100644 --- a/tests/RequestValidatorTest.php +++ b/tests/RequestValidatorTest.php @@ -503,6 +503,22 @@ public function test_handles_query_parameters(): void $this->get('/users?order=email,name') ->assertValidationMessage('The data should match one item from enum') ->assertInvalidRequest(); + + // Test it handles nested query parameters + Route::get('/orders', function () { + return []; + })->middleware(Middleware::class); + + $this->get('/orders') + ->assertValidationMessage('Missing required query parameter [?filter[groupId]=].') + ->assertInvalidRequest(); + + $this->get('/orders?filter[groupId]=1') + ->assertValidationMessage('The data must match the \'uuid\' format') + ->assertInvalidRequest(); + + $this->get('/orders?filter[groupId]=cc8936c7-d681-4c42-9410-c50488f43736') + ->assertValid(); } public function test_handles_query_parameters_int(): void