Repository: hotmeteor/spectator (php)

Issue:
'additionalProperties' are not supported by Spectator?
I've got this in my OpenAPI spec:
```
    CartResponse:
      type: object
      properties:
        success:
          type: boolean
        data:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/CartItem'
        message:
          type: string
```

And the tests using Spectator pass though they must fall because I introduce an error into specs for testing purposes (in CartItem object replace one of its properties type to 'integer' from 'string')

Moreover, even if I replace **$ref:** value with totally another object the tests pass nevertheless!
Dictionary objects break validation
## Overview
When the schema contains a dictionary or free-form object the validation does not work. Any response/request to a valid path with a valid status code passes tests independent of any violations of the provided openapi schema.

Free-form objects or map/dictionary like objects are valid openapi: [
https://spec.openapis.org/oas/latest.html#model-with-map-dictionary-properties](
https://spec.openapis.org/oas/latest.html#model-with-map-dictionary-properties)

The desired outcome would be for the schema validation to work as intended but almost more importantly to not have tests that should have failed pass only due to an internal unhandled exception. Any such exception should probably cause the tests to fail to alert the developer of an issue

## Example
Consider the following schema
```yaml
openapi: 3.1.0
info:
  title: FreeFormObject
  version: '1.0'
servers:
  - url: 'http://localhost:3000'
paths:
  /api/test:
    get:
      summary: Test endpoint with free form object response
      tags: [ ]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: string

```
with the following route defined in Laravel
```php
Route::get('/api/test', static function () {
    return response('string response', 200, ['content-type' => 'application/json']);
});
```
then the following PestPHP test passes
```php
it('fails appropriately', function () {
    Spectator::using('FreeFormObject.yaml');

    $response = $this->getJson('api/test');

    // response is: "string response"
    $response->dump();

    // have a single error: "Undefined array key "properties"
    dump($response->collectExceptionMessages());

    // but both these assertions pass
    $response->assertValidRequest()->assertValidResponse(200);
});
```
with the following output

![image](https://github.com/hotmeteor/spectator/assets/65945591/f1d4150d-f86f-4c6e-96f2-8b7569fe5163)

Clearly a string response does not comply with the openapi spec and the `assertValidResponse(200)` should have failed.

## Analysis
On the following lines of code
https://github.com/hotmeteor/spectator/blob/9ee692b34f619ebf0ea168b468abc2a06d59eb11/src/Exceptions/SchemaValidationException.php#L249-L256
an assumption is made that every schema of type `object` has a `properties` key. This causes an exception which wipes the genuine validation errors, but is ignored by the `assertValidResponse()` assertion.

Pull request:
Add support for dictionaries
Dictionaries are objects in which the keys are not known but we can still validate the format of the values.

For example : 
```json
{
    "data": {
        "2024-01-27": 6,
        "2024-01-28": 7
    }
}
```

Before this fix, we had the error 
```
ErrorException: Undefined property: stdClass::$properties
/spectator/vendor/laravel/framework/src/Illuminate/Foundation/Bootstrap/HandleExceptions.php:255
/spectator/src/Validation/AbstractValidator.php:72
/spectator/src/Validation/AbstractValidator.php:114
....
```

Fixes #143 
Fixes #167

Patch:
diff --git a/src/Validation/AbstractValidator.php b/src/Validation/AbstractValidator.php
index c25d65f..60d64c5 100644
--- a/src/Validation/AbstractValidator.php
+++ b/src/Validation/AbstractValidator.php
@@ -60,6 +60,10 @@ abstract class AbstractValidator
                 return $data;
         }
 
+        if (isset($data->type) && $data->type === 'object') {
+            $data->properties ??= new \stdClass();
+        }
+
         if (! isset($data->properties)) {
             return $data;
         }


Test patch:
diff --git a/tests/Fixtures/Dictionary.v1.yml b/tests/Fixtures/Dictionary.v1.yml
new file mode 100644
index 0000000..1c8fcf5
--- /dev/null
+++ b/tests/Fixtures/Dictionary.v1.yml
@@ -0,0 +1,25 @@
+openapi: 3.0.0
+info:
+  title: Dictionary.v1
+  version: '1.0'
+servers:
+  - url: 'http://localhost:3000'
+paths:
+  /dictionary-of-integers:
+    get:
+      summary: Get dictionary of integers
+      tags: [ ]
+      responses:
+        '200':
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  data:
+                    type: object
+                    additionalProperties:
+                      type: integer
+components:
+  schemas: {}
diff --git a/tests/ResponseValidatorTest.php b/tests/ResponseValidatorTest.php
index 3b58030..f7b8e43 100644
--- a/tests/ResponseValidatorTest.php
+++ b/tests/ResponseValidatorTest.php
@@ -830,4 +830,46 @@ class ResponseValidatorTest extends TestCase
             ],
         ];
     }
+
+    /**
+     * @dataProvider objectAsDictionaryProvider
+     */
+    public function test_object_as_dictionary(mixed $payload, bool $isValid): void
+    {
+        Spectator::using('Dictionary.v1.yml');
+
+        Route::get('/dictionary-of-integers', static function () use ($payload) {
+            return ['data' => $payload];
+        })->middleware(Middleware::class);
+
+        if ($isValid) {
+            $this->getJson('/dictionary-of-integers')
+                ->assertValidResponse();
+        } else {
+            $this->getJson('/dictionary-of-integers')
+                ->assertInvalidResponse();
+        }
+    }
+
+    public static function objectAsDictionaryProvider(): array
+    {
+        return [
+            'valid' => [
+                ['foo' => 1, 'bar' => -1],
+                true,
+            ],
+            'invalid as string' => [
+                'foo',
+                false,
+            ],
+            'invalid as array' => [
+                [1, 2],
+                false,
+            ],
+            'invalid as dictionary of string' => [
+                ['foo' => 'foo', 'bar' => 'bar'],
+                false,
+            ],
+        ];
+    }
 }