Repository: hotmeteor/spectator (php)

Issue:
Streamed responses are interpreted as a boolean body type
We currently have some endpoints that stream their response, in our case the content is usually a CSV or PDF but it's also possible to stream JSON responses.

The `ResponseValidator::body` method is responsible for returning the body of a response, decoding the response if it is expected to be JSON. 

https://github.com/hotmeteor/spectator/blob/f89e213c5ef3bac82c50c933f0b8156d83c332a1/src/Validation/ResponseValidator.php#L148-L161

It retrieves the body of the response using `getContent`. For normal responses, this behaves as expected. For streamed responses, `getContent` will return `false`. This then causes failing tests as our API spec documents the response as being a string and Spectator views it as a boolean.

The body of a streamed response must be captured using `sendContent` and an output buffer e.g.

```php
ob_start();
$this->response->sendContent();
$body = ob_get_clean();
```

I propose that we add a check to verify whether the response is a streamed response. If it is, we need to fetch the response contents using the method above. If it isn't, we can just fallback to using `getBody` as normal.

Pull request description:
Add support for streamed response
Fixes #127 

Patch:
diff --git a/src/Validation/AbstractValidator.php b/src/Validation/AbstractValidator.php
index 3115d58..c25d65f 100644
--- a/src/Validation/AbstractValidator.php
+++ b/src/Validation/AbstractValidator.php
@@ -60,6 +60,10 @@ abstract class AbstractValidator
                 return $data;
         }
 
+        if (! isset($data->properties)) {
+            return $data;
+        }
+
         /**
          * Create a new array of properties that need to be filtered out.
          */
diff --git a/src/Validation/RequestValidator.php b/src/Validation/RequestValidator.php
index 8eb6dfe..5f78fb4 100644
--- a/src/Validation/RequestValidator.php
+++ b/src/Validation/RequestValidator.php
@@ -95,6 +95,8 @@ class RequestValidator extends AbstractValidator
                     $parameter_value = $route->parameters()[$parameter->name];
                     if ($parameter_value instanceof Model) {
                         $parameter_value = $route->originalParameters()[$parameter->name];
+                    } elseif ($parameter_value instanceof \BackedEnum) {
+                        $parameter_value = $route->originalParameters()[$parameter->name];
                     }
                 } elseif ($parameter->in === 'query' && $this->hasQueryParam($parameter->name)) {
                     $parameter_value = $this->getQueryParam($parameter->name);
@@ -109,7 +111,7 @@ class RequestValidator extends AbstractValidator
                 }
 
                 if ($parameter_value) {
-                    if ($expected_parameter_schema->type && gettype($parameter_value) !== $expected_parameter_schema->type) {
+                    if (isset($expected_parameter_schema->type) && gettype($parameter_value) !== $expected_parameter_schema->type) {
                         $expected_type = $expected_parameter_schema->type;
 
                         if ($expected_type === 'number') {
diff --git a/src/Validation/ResponseValidator.php b/src/Validation/ResponseValidator.php
index b58d233..1c8b2c7 100644
--- a/src/Validation/ResponseValidator.php
+++ b/src/Validation/ResponseValidator.php
@@ -9,6 +9,7 @@ use Illuminate\Support\Str;
 use Opis\JsonSchema\Validator;
 use Spectator\Exceptions\ResponseValidationException;
 use Spectator\Exceptions\SchemaValidationException;
+use Symfony\Component\HttpFoundation\StreamedResponse;
 
 class ResponseValidator extends AbstractValidator
 {
@@ -163,7 +164,7 @@ class ResponseValidator extends AbstractValidator
      */
     protected function body($contentType, $schemaType)
     {
-        $body = $this->response->getContent();
+        $body = $this->response instanceof StreamedResponse ? $this->streamedContent() : $this->response->getContent();
 
         if (in_array($schemaType, ['object', 'array', 'allOf', 'anyOf', 'oneOf'], true)) {
             if (in_array($contentType, ['application/json', 'application/vnd.api+json', 'application/problem+json'])) {
@@ -176,6 +177,23 @@ class ResponseValidator extends AbstractValidator
         return $body;
     }
 
+    protected function streamedContent(): string
+    {
+        $content = '';
+
+        ob_start(function (string $buffer) use (&$content): string {
+            $content .= $buffer;
+
+            return '';
+        });
+
+        $this->response->sendContent();
+
+        ob_end_clean();
+
+        return $content;
+    }
+
     /**
      * @return string
      */


Test patch:
diff --git a/tests/Fixtures/Arrays.v1.yaml b/tests/Fixtures/Arrays.v1.yaml
index 10aa6ac..e1f91f7 100644
--- a/tests/Fixtures/Arrays.v1.yaml
+++ b/tests/Fixtures/Arrays.v1.yaml
@@ -71,5 +71,21 @@ paths:
           name: orgUuid
           in: path
           required: true
+  /array-of-strings:
+    get:
+      summary: Get arrays of string
+      tags: []
+      responses:
+        '200':
+          description: OK
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  data:
+                    type: array
+                    items:
+                      type: string
 components:
   schemas: {}
diff --git a/tests/Fixtures/Enum.yml b/tests/Fixtures/Enum.yml
new file mode 100644
index 0000000..a41ef23
--- /dev/null
+++ b/tests/Fixtures/Enum.yml
@@ -0,0 +1,46 @@
+openapi: 3.1.0
+info:
+  title: Enum
+  version: '1.0'
+servers:
+  - url: 'http://localhost:3000'
+paths:
+  /enum-in-path/{type}:
+    parameters:
+      - in: path
+        name: type
+        required: true
+        schema:
+          type: string
+          enum:
+            - name
+            - email
+    get:
+      summary: Request with enum in path
+      tags: []
+      responses:
+        '204':
+          description: No Content
+  /enum-in-path-via-reference/{type}:
+    parameters:
+      - in: path
+        name: type
+        required: true
+        schema:
+          $refs: '#/components/schemas/TestEnum'
+    get:
+      summary: Request with enum in path via reference
+      tags: []
+      responses:
+        '204':
+          description: No Content
+
+components:
+  schemas:
+    TestEnum:
+      type: string
+      enum:
+        - name
+        - email
+
+
diff --git a/tests/RequestValidatorTest.php b/tests/RequestValidatorTest.php
index 55207f6..685b41c 100644
--- a/tests/RequestValidatorTest.php
+++ b/tests/RequestValidatorTest.php
@@ -4,6 +4,8 @@ namespace Spectator\Tests;
 
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Http\UploadedFile;
+use Illuminate\Routing\Exceptions\BackedEnumCaseNotFoundException;
+use Illuminate\Routing\Middleware\SubstituteBindings;
 use Illuminate\Support\Facades\Config;
 use Illuminate\Support\Facades\Route;
 use Illuminate\Support\Str;
@@ -786,8 +788,72 @@ class RequestValidatorTest extends TestCase
             ],
         ];
     }
+
+    /**
+     * @dataProvider enumProvider
+     */
+    public function test_enum_in_path(string $type, bool $isValid): void
+    {
+        Spectator::using('Enum.yml');
+
+        $this->withoutExceptionHandling([BackedEnumCaseNotFoundException::class]);
+
+        Route::get('/enum-in-path/{type}', function (TestEnum $type) {
+            return response()->noContent();
+        })->middleware([SubstituteBindings::class, Middleware::class]);
+
+        if ($isValid) {
+            $this->getJson("/enum-in-path/{$type}")
+                ->assertValidRequest();
+        } else {
+            $this->getJson("/enum-in-path/{$type}")
+                ->assertInvalidRequest();
+        }
+    }
+
+    /**
+     * @dataProvider enumProvider
+     */
+    public function test_enum_in_path_via_reference(string $type, bool $isValid): void
+    {
+        Spectator::using('Enum.yml');
+
+        $this->withoutExceptionHandling([BackedEnumCaseNotFoundException::class]);
+
+        Route::get('/enum-in-path-via-reference/{type}', function (TestEnum $type) {
+            return response()->noContent();
+        })->middleware([SubstituteBindings::class, Middleware::class]);
+
+        if ($isValid) {
+            $this->getJson("/enum-in-path-via-reference/{$type}")
+                ->assertValidRequest();
+        } else {
+            $this->getJson("/enum-in-path-via-reference/{$type}")
+                ->assertInvalidRequest();
+        }
+    }
+
+    public static function enumProvider(): array
+    {
+        return [
+            'valid enum' => [
+                'name',
+                true,
+            ],
+            'invalid enum' => [
+                'foo',
+                false,
+            ],
+        ];
+    }
 }
 
 class TestUser extends Model
 {
 }
+
+enum TestEnum: string
+{
+    case name = 'name';
+    case email = 'email';
+}
diff --git a/tests/ResponseValidatorTest.php b/tests/ResponseValidatorTest.php
index 4618cea..3b58030 100644
--- a/tests/ResponseValidatorTest.php
+++ b/tests/ResponseValidatorTest.php
@@ -69,6 +69,25 @@ class ResponseValidatorTest extends TestCase
             ->assertValidationMessage('All array items must match schema');
     }
 
+    public function test_validates_valid_streamed_json_response(): void
+    {
+        Route::get('/users', static function () {
+            return response()->stream(function () {
+                echo json_encode([
+                    [
+                        'id' => 1,
+                        'name' => 'Jim',
+                        'email' => 'test@test.test',
+                    ],
+                ]);
+            }, 200, ['Content-Type' => 'application/json']);
+        })->middleware(Middleware::class);
+
+        $this->getJson('/users')
+            ->assertValidRequest()
+            ->assertValidResponse();
+    }
+
     public function test_validates_valid_problem_json_response()
     {
         Route::get('/users', function () {
@@ -683,6 +702,44 @@ class ResponseValidatorTest extends TestCase
             ]);
     }
 
+    /**
+     * @dataProvider arrayOfStringsProvider
+     */
+    public function test_array_of_strings(mixed $payload, bool $isValid): void
+    {
+        Spectator::using('Arrays.v1.yaml');
+
+        Route::get('/array-of-strings', static function () use ($payload) {
+            return ['data' => $payload];
+        })->middleware(Middleware::class);
+
+        if ($isValid) {
+            $this->getJson('/array-of-strings')
+                ->assertValidResponse();
+        } else {
+            $this->getJson('/array-of-strings')
+                ->assertInvalidResponse();
+        }
+    }
+
+    public static function arrayOfStringsProvider(): array
+    {
+        return [
+            'valid' => [
+                ['foo', 'bar'],
+                true,
+            ],
+            'invalid as string' => [
+                'foo',
+                false,
+            ],
+            'invalid as object' => [
+                ['foo' => 'bar'],
+                false,
+            ],
+        ];
+    }
+
     public function test_array_any_of(): void
     {
         Spectator::using('ArrayAnyOf.v1.yml');