Skip to content

Commit

Permalink
Allow placeholders in response media types
Browse files Browse the repository at this point in the history
  • Loading branch information
bastien-phi committed Mar 8, 2024
1 parent 349951d commit 5e00ec4
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 20 deletions.
61 changes: 41 additions & 20 deletions src/Validation/ResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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].");
Expand Down Expand Up @@ -186,4 +173,38 @@ protected function streamedContent(): string

return $content;
}

/**
* @param array<int, string> $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');
}
}
36 changes: 36 additions & 0 deletions tests/Fixtures/ContentType.yml
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions tests/ResponseValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => '[email protected]',
];
})->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]);
Expand Down

0 comments on commit 5e00ec4

Please sign in to comment.