Repository: hotmeteor/spectator (php)

Issue:
Nested query parameters always fail validation
Query string params such as `?filter[groupId]` always fail validation because retrieving them via `$this->request->query->has` and `$this->request->query->get` doesn't work. 

Instead I think the parameter names need to become dot notated, e.g. 

`filter[groupId] => filter.groupId`

So that they can be retrieved this way. 

This issue should be fixed by: #129 



Pull request description:
Handle validation of nested query parameters e.g. ?filter[groupId]=
Nested query parameters such as `?filter[groupId]` always return null during the validation. 

This is due to `$this->request->query->has` being a pretty simple Symfony implementation (as documented https://github.com/laravel/framework/issues/33376) 

This adds some support to getting these nested query parameters in the recommended way from the query string. 

Fixes #130 

Patch:
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 @@ class RequestValidator extends AbstractValidator
             // 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 @@ class RequestValidator extends AbstractValidator
                     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 @@ class RequestValidator extends AbstractValidator
             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));
+    }
 }


Test patch:
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 @@ class RequestValidatorTest extends TestCase
         $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