From 5e00ec4aa098902a52dced0791dd58a8e8ced3db Mon Sep 17 00:00:00 2001 From: Bastien Philippe Date: Fri, 8 Mar 2024 10:30:25 +0100 Subject: [PATCH] Allow placeholders in response media types --- src/Validation/ResponseValidator.php | 61 +++++++++++++++++++--------- tests/Fixtures/ContentType.yml | 36 ++++++++++++++++ tests/ResponseValidatorTest.php | 22 ++++++++++ 3 files changed, 99 insertions(+), 20 deletions(-) create mode 100644 tests/Fixtures/ContentType.yml diff --git a/src/Validation/ResponseValidator.php b/src/Validation/ResponseValidator.php index 974b4df..d91296d 100644 --- a/src/Validation/ResponseValidator.php +++ b/src/Validation/ResponseValidator.php @@ -5,6 +5,7 @@ use cebe\openapi\spec\Operation; use cebe\openapi\spec\Response; use cebe\openapi\spec\Schema; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use Opis\JsonSchema\Validator; use Spectator\Exceptions\ResponseValidationException; @@ -51,34 +52,20 @@ protected function handle(): void protected function parseResponse(Response $response): void { $contentType = $this->contentType(); - - // This is a bit hacky, but will allow resolving other JSON responses like application/problem+json - // when returning standard JSON responses from frameworks (See hotmeteor/spectator#114) - - $specTypes = array_combine( - array_keys($response->content), - array_map( - fn ($type) => $contentType === 'application/json' && Str::endsWith($type, '+json') - ? 'application/json' - : $type, - array_keys($response->content) - ) - ); + $specTypes = array_keys($response->content); // Does the response match any of the specified media types? - if (! in_array($contentType, $specTypes)) { + $matchingType = $this->findMatchingType($contentType, $specTypes); + if ($matchingType === null) { $message = 'Response did not match any specified content type.'; - $message .= PHP_EOL.PHP_EOL.' Expected: '.implode(', ', array_values($specTypes)); + $message .= PHP_EOL.PHP_EOL.' Expected: '.implode(', ', $specTypes); $message .= PHP_EOL.' Actual: '.$contentType; $message .= PHP_EOL.PHP_EOL.' ---'; throw new ResponseValidationException($message); } - // Lookup the content type specified in the spec that match the application/json content type - $contentType = array_flip($specTypes)[$contentType]; - - $schema = $response->content[$contentType]->schema; + $schema = $response->content[$matchingType]->schema; $this->validateResponse( $schema, @@ -155,7 +142,7 @@ protected function body(?string $contentType, ?string $schemaType): mixed $body = $this->responseContent(); if (in_array($schemaType, ['object', 'array', 'allOf', 'anyOf', 'oneOf'], true)) { - if (in_array($contentType, ['application/json', 'application/vnd.api+json', 'application/problem+json'], true)) { + if ($this->isJsonContentType($contentType)) { return json_decode($body); } else { throw new ResponseValidationException("Unable to map [{$contentType}] to schema type [object]."); @@ -186,4 +173,38 @@ protected function streamedContent(): string return $content; } + + /** + * @param array $specTypes + */ + private function findMatchingType(?string $contentType, array $specTypes): ?string + { + if ($contentType === null) { + return null; + } + if ($this->isJsonContentType($contentType)) { + $contentType = 'application/json'; + } + + // This is a bit hacky, but will allow resolving other JSON responses like application/problem+json + // when returning standard JSON responses from frameworks (See hotmeteor/spectator#114) + + $normalizedSpecTypes = Collection::make($specTypes)->mapWithKeys(fn (string $type) => [ + $type => $this->isJsonContentType($type) ? 'application/json' : $type, + ])->all(); + + $matchingTypes = [$contentType, Str::before($contentType, '/').'/*', '*/*']; + foreach ($matchingTypes as $matchingType) { + if (in_array($matchingType, $normalizedSpecTypes, true)) { + return array_flip($normalizedSpecTypes)[$matchingType]; + } + } + + return null; + } + + private function isJsonContentType(string $contentType): bool + { + return $contentType === 'application/json' || Str::endsWith($contentType, '+json'); + } } diff --git a/tests/Fixtures/ContentType.yml b/tests/Fixtures/ContentType.yml new file mode 100644 index 0000000..f7ed83e --- /dev/null +++ b/tests/Fixtures/ContentType.yml @@ -0,0 +1,36 @@ +openapi: 3.0.0 +info: + title: ContentType + version: '1.0' +servers: + - url: 'http://localhost:3000' +paths: + /partial-match: + get: + summary: Get object + tags: [ ] + responses: + '200': + description: OK + content: + application/*: + schema: + type: object + properties: + id: + type: integer + name: + type: string + email: + type: string + /joker: + get: + summary: Get string + tags: [ ] + responses: + '200': + description: OK + content: + '*/*': + schema: + type: string diff --git a/tests/ResponseValidatorTest.php b/tests/ResponseValidatorTest.php index 3cb0ddf..5ebed2e 100644 --- a/tests/ResponseValidatorTest.php +++ b/tests/ResponseValidatorTest.php @@ -241,6 +241,28 @@ public function test_validates_invalid_problem_json_response() ->assertValidationMessage('All array items must match schema'); } + public function test_with_partial_content_type_matching() + { + Spectator::using('ContentType.yml'); + Route::get('/partial-match', function () { + return [ + 'id' => 1, + 'name' => 'Jim', + 'email' => 'test@test.test', + ]; + })->middleware(Middleware::class); + + $this->getJson('/partial-match') + ->assertValidResponse(200); + + Route::get('/joker', function () { + return response('Hello world!', 200, ['Content-Type' => 'text/html']); + })->middleware(Middleware::class); + + $this->getJson('/joker') + ->assertValidResponse(200); + } + public function test_validates_problem_json_response_using_components() { $this->withoutExceptionHandling([NotFoundHttpException::class]);