Repository: hotmeteor/spectator (php)

Issue:
Arrays of objects are not correctly parsed in request parameters
When having an array of objects in query string, Spectator cannot validate it properly even if the data is perfectly correct: 

```
$this->getJson("api/route?" . Arr::query([
            'periods' => [
                [
                    'date' => '2024-03-11',
                    'start_time' => '08:00:00',
                    'end_time' => '10:00:00',
                ],
                [
                    'date' => '2024-03-12',
                    'start_time' => '10:00:00',
                    'end_time' => '12:00:00',
                ],
            ],
        ]))
        ->assertValidContract();
```

```
All array items must match schema
The data ({type}) must match the type: object

array <== All array items must match schema
    object++ <== The data ({type}) must match the type: object
        date*: string
        start_time*: string
        end_time*: string
```

It looks like 
```diff
-$result = $validator->validate($parameterValue, $expectedParameterSchema);
+$result = $validator->validate($this->toObject($parameterValue), $expectedParameterSchema);
```
fixes it (in `RequestValidator::validateParameters`)

I don't have the time to send a PR fixing it right now but will do it soon unless someone else does it faster !

Pull request:
Improve validation and casting of request parameters
Fixes #187 

Array query parameters containing objects are not correctly validated because the validator requires the objects to be actual objects and not associative arrays.

The parameter value is now correctly casted.

I also needed to improve parameter casting in order to recursively cast the parameter (in case of objects and arrays). It is not perfect but it's better than before. We could improve it if needed in the future.

Patch:
diff --git a/src/Validation/RequestValidator.php b/src/Validation/RequestValidator.php
index 155aba1..36a7cd4 100644
--- a/src/Validation/RequestValidator.php
+++ b/src/Validation/RequestValidator.php
@@ -107,25 +107,9 @@ class RequestValidator extends AbstractValidator
                 }
 
                 if ($parameterValue) {
-                    if (isset($expectedParameterSchema->type) && gettype($parameterValue) !== $expectedParameterSchema->type) {
-                        $expectedType = $expectedParameterSchema->type;
-
-                        $expectedType = match ($expectedType) {
-                            'integer' => 'int',
-                            'number' => 'float',
-                            default => $expectedType,
-                        };
-
-                        if (is_numeric($parameterValue)) {
-                            $parameterValue = match ($expectedType) {
-                                'int' => (int) $parameterValue,
-                                'float' => (float) $parameterValue,
-                                default => $parameterValue,
-                            };
-                        }
-                    }
+                    $parameterValue = $this->castParameterValue($parameterValue, $expectedParameterSchema);
 
-                    $result = $validator->validate($parameterValue, $expectedParameterSchema);
+                    $result = $validator->validate($this->toObject($parameterValue), $expectedParameterSchema);
 
                     // If the result is not valid, then display failure reason.
                     if ($result->isValid() === false) {
@@ -213,8 +197,33 @@ class RequestValidator extends AbstractValidator
         return $this->toObject($body);
     }
 
+    private function castParameterValue(mixed $parameterValue, ?stdClass $expectedSchema): mixed
+    {
+        if ($expectedSchema === null || ! isset($expectedSchema->type)) {
+            return $parameterValue;
+        }
+
+        if ($expectedSchema->type === 'integer' && is_numeric($parameterValue)) {
+            return (int) $parameterValue;
+        } elseif ($expectedSchema->type === 'number' && is_numeric($parameterValue)) {
+            return (float) $parameterValue;
+        } elseif ($expectedSchema->type === 'object' && Arr::isAssoc($parameterValue)) {
+            return Arr::map(
+                $parameterValue,
+                fn (mixed $value, string $key) => $this->castParameterValue($value, $expectedSchema->properties?->{$key})
+            );
+        } elseif ($expectedSchema->type === 'array' && is_array($parameterValue)) {
+            return Arr::map(
+                $parameterValue,
+                fn (mixed $value) => $this->castParameterValue($value, $expectedSchema->items)
+            );
+        }
+
+        return $parameterValue;
+    }
+
     /**
-     * @return ($data is array<string, mixed> ? object : ($data is array<int, mixed> ? array<int, mixed> : mixed))
+     * @return ($data is array<string, mixed> ? \stdClass : ($data is array<int, mixed> ? array<int, mixed> : mixed))
      */
     private function toObject(mixed $data): mixed
     {


Test patch:
diff --git a/tests/Fixtures/Arrays.v1.yml b/tests/Fixtures/Arrays.v1.yml
index a666a23..e1819cf 100644
--- a/tests/Fixtures/Arrays.v1.yml
+++ b/tests/Fixtures/Arrays.v1.yml
@@ -101,5 +101,27 @@ paths:
       responses:
         '204':
           description: No Content
+  /parameter-as-array-of-objects:
+    parameters:
+      - name: arrayParam
+        in: query
+        schema:
+          type: array
+          items:
+            type: object
+            properties:
+              id:
+                type: integer
+              name:
+                type: string
+            required:
+              - id
+              - name
+    get:
+      summary: Get arrays of objects
+      tags: [ ]
+      responses:
+        '204':
+          description: No Content
 components:
   schemas: {}
diff --git a/tests/RequestValidatorTest.php b/tests/RequestValidatorTest.php
index c346889..9627f55 100644
--- a/tests/RequestValidatorTest.php
+++ b/tests/RequestValidatorTest.php
@@ -536,6 +536,22 @@ class RequestValidatorTest extends TestCase
             ->assertValidRequest();
     }
 
+    public function test_handles_array_of_object_query_parameters(): void
+    {
+        Spectator::using('Arrays.v1.yml');
+
+        Route::get('/parameter-as-array-of-objects', function () {
+            return response()->noContent();
+        })->middleware(Middleware::class);
+
+        $this->get('/parameter-as-array-of-objects?arrayParam[0][id]=1&arrayParam[0][name]=foo&arrayParam[1][id]=2')
+            ->assertValidationMessage('The required properties (name) are missing')
+            ->assertInvalidRequest();
+
+        $this->get('/parameter-as-array-of-objects?arrayParam[0][id]=1&arrayParam[0][name]=foo&arrayParam[1][id]=2&arrayParam[1][name]=bar')
+            ->assertValidRequest();
+    }
+
     public function test_handles_query_parameters_int(): void
     {
         Spectator::using('Test.v1.json');