From 83cf2c5e6171106b99d8e55a8ded4d4b82c68759 Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Fri, 11 Dec 2020 10:51:03 -0600 Subject: [PATCH 1/4] Fix validation of request paths --- src/Assertions.php | 13 ++++++- src/Middleware.php | 4 +- tests/Fixtures/AssertionsTest.php | 37 +++++++++++++++++++ tests/Fixtures/Nullable.3.0.json | 61 +++++++++++++++++++++++++++++++ tests/ResponseValidatorTest.php | 21 ++++++++++- 5 files changed, 130 insertions(+), 6 deletions(-) create mode 100644 tests/Fixtures/AssertionsTest.php create mode 100644 tests/Fixtures/Nullable.3.0.json diff --git a/src/Assertions.php b/src/Assertions.php index 97c8692..8ea0424 100644 --- a/src/Assertions.php +++ b/src/Assertions.php @@ -4,6 +4,7 @@ use Illuminate\Support\Arr; use PHPUnit\Framework\Assert as PHPUnit; +use Spectator\Exceptions\InvalidPathException; use Spectator\Exceptions\RequestValidationException; use Spectator\Exceptions\ResponseValidationException; use cebe\openapi\exceptions\UnresolvableReferenceException; @@ -16,7 +17,11 @@ public function assertValidRequest() $contents = $this->getContent() ? $contents = (array) $this->json() : []; PHPUnit::assertFalse( - in_array(Arr::get($contents, 'exception'), [RequestValidationException::class, UnresolvableReferenceException::class]), + in_array(Arr::get($contents, 'exception'), [ + RequestValidationException::class, + UnresolvableReferenceException::class, + InvalidPathException::class, + ]), $this->decodeExceptionMessage($contents) ); @@ -30,7 +35,11 @@ public function assertInvalidRequest() $contents = (array) $this->json(); PHPUnit::assertTrue( - !in_array(Arr::get($contents, 'exception'), [RequestValidationException::class, UnresolvableReferenceException::class]), + in_array(Arr::get($contents, 'exception'), [ + RequestValidationException::class, + UnresolvableReferenceException::class, + InvalidPathException::class, + ]), $this->decodeExceptionMessage($contents) ); diff --git a/src/Middleware.php b/src/Middleware.php index c4b33df..d995d76 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -32,14 +32,14 @@ public function handle(Request $request, Closure $next) try { $response = $this->validate($request, $next); + } catch (InvalidPathException $exception) { + return $this->formatResponse($exception, 422); } catch (RequestValidationException $exception) { return $this->formatResponse($exception, 400); } catch (ResponseValidationException $exception) { return $this->formatResponse($exception, 400); } catch (InvalidMethodException $exception) { return $this->formatResponse($exception, 405); - } catch (InvalidPathException $exception) { - return $this->formatResponse($exception, 422); } catch (MissingSpecException $exception) { return $this->formatResponse($exception, 500); } catch (UnresolvableReferenceException $exception) { diff --git a/tests/Fixtures/AssertionsTest.php b/tests/Fixtures/AssertionsTest.php new file mode 100644 index 0000000..ca8f6ad --- /dev/null +++ b/tests/Fixtures/AssertionsTest.php @@ -0,0 +1,37 @@ +app->register(SpectatorServiceProvider::class); + + Spectator::using('Test.v1.json'); + } + + public function test_asserts_invalid_path() + { + Route::get('/invalid', function () { + return [ + [ + 'id' => 1, + 'name' => 'Jim', + 'email' => 'test@test.test', + ], + ]; + })->middleware(Middleware::class); + + $this->getJson('/invalid') + ->assertInvalidRequest(); + } +} diff --git a/tests/Fixtures/Nullable.3.0.json b/tests/Fixtures/Nullable.3.0.json new file mode 100644 index 0000000..a1ec8f1 --- /dev/null +++ b/tests/Fixtures/Nullable.3.0.json @@ -0,0 +1,61 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Nullable.3.0", + "version": "1.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/users/{user}": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "user", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Get single user", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "first_name": { + "type": "string", + "example": "Joe" + }, + "last_name": { + "type": "string", + "example": "Bloggs" + }, + "favourite_colour": { + "type": "string", + "example": "Red", + "nullable": true + } + } + } + } + } + } + }, + "operationId": "get-users-user" + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file diff --git a/tests/ResponseValidatorTest.php b/tests/ResponseValidatorTest.php index 262b2da..3838b26 100644 --- a/tests/ResponseValidatorTest.php +++ b/tests/ResponseValidatorTest.php @@ -97,8 +97,7 @@ public function test_cannot_locate_path_without_path_prefix() })->middleware(Middleware::class); $this->getJson('/api/v2/users') - ->assertValidRequest() - ->assertValidResponse(422) + ->assertInvalidRequest() ->assertValidationMessage('Path [GET /api/v2/users] not found in spec.'); Config::set('spectator.path_prefix', '/api/v2/'); @@ -107,4 +106,22 @@ public function test_cannot_locate_path_without_path_prefix() ->assertValidRequest() ->assertValidResponse(200); } + + public function test_handle_nullable_in_oa3dot0() + { + Spectator::using('Nullable.3.0.json'); + + Route::get('/api/v1/users/1', function () { + return [ + [ + 'first_name' => 'Joe', + 'last_name' => 'Bloggs', + ], + ]; + })->middleware(Middleware::class); + + $this->getJson('/api/v1/users/1') + ->assertValidRequest() + ->assertValidResponse(); + } } From 6f4e7baea2930d3208f7137a0e97a0d46a5442af Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Fri, 11 Dec 2020 10:51:29 -0600 Subject: [PATCH 2/4] Refactor ResponseValidator code --- src/Validation/ResponseValidator.php | 141 +++++++++++++++++++-------- 1 file changed, 103 insertions(+), 38 deletions(-) diff --git a/src/Validation/ResponseValidator.php b/src/Validation/ResponseValidator.php index d63176c..146e73a 100644 --- a/src/Validation/ResponseValidator.php +++ b/src/Validation/ResponseValidator.php @@ -2,14 +2,17 @@ namespace Spectator\Validation; +use Exception; +use cebe\openapi\spec\Schema; use Opis\JsonSchema\Validator; +use cebe\openapi\spec\Response; use cebe\openapi\spec\Operation; +use Opis\JsonSchema\ValidationResult; use Opis\JsonSchema\Exception\SchemaKeywordException; use Spectator\Exceptions\ResponseValidationException; class ResponseValidator { - /** @var string request uri */ protected $uri; protected $response; @@ -30,57 +33,119 @@ public static function validate(string $uri, $response, Operation $operation) $instance->handle(); } + /** + * @throws ResponseValidationException + */ protected function handle() { - $contentType = $this->response->headers->get('Content-Type'); - $body = $this->response->getContent(); - $responses = $this->operation->responses; + $responseObject = $this->response(); - $shortHandler = class_basename($this->operation->operationId) ?: $this->uri; + if ($responseObject->content) { + $this->parseResponse($responseObject); + } + } - // Get matching response object based on status code. - if ($responses[$this->response->getStatusCode()] !== null) { - $responseObject = $responses[$this->response->getStatusCode()]; - } elseif ($responses['default'] !== null) { - $responseObject = $responses['default']; - } else { - throw new ResponseValidationException("No response object matching returned status code [{$this->response->getStatusCode()}]."); + /** + * @throws ResponseValidationException + */ + protected function parseResponse(Response $response) + { + $contentType = $this->contentType(); + + if (!array_key_exists($contentType, $response->content)) { + throw new ResponseValidationException('Response did not match any specified media type.'); } - if ($responseObject->content) { - if (!array_key_exists($contentType, $responseObject->content)) { - throw new ResponseValidationException('Response did not match any specified media type.'); - } + $schema = $response->content[$contentType]->schema; - $schema = $responseObject->content[$contentType]->schema; + $this->validateResponse( + $schema, $this->body($contentType, $schema->type) + ); + } - if ($schema->type === 'object' || $schema->type === 'array') { - if (in_array($contentType, ['application/json', 'application/vnd.api+json'])) { - $body = json_decode($body); - } else { - throw new ResponseValidationException("Unable to map [{$contentType}] to schema type [object]."); - } - } + /** + * @param $body + * + * @throws ResponseValidationException + */ + protected function validateResponse(Schema $schema, $body) + { + $result = null; + $validator = $this->validator(); + $shortHandler = $this->shortHandler(); + + try { + $result = $validator->dataValidation($body, $schema->getSerializableData(), -1); + } catch (SchemaKeywordException $exception) { + throw ResponseValidationException::withError("{$shortHandler} has invalid schema: [ {$exception->getMessage()} ]"); + } catch (Exception $exception) { + throw ResponseValidationException::withError($exception->getMessage()); + } - $validator = $this->validator(); - $result = null; + if ($result instanceof ValidationResult && $result->isValid() === false) { + $error = $result->getFirstError(); + $args = json_encode($error->keywordArgs()); + $dataPointer = implode('.', $error->dataPointer()); - try { - $result = $validator->dataValidation($body, $schema->getSerializableData(), -1); - } catch (SchemaKeywordException $exception) { - throw ResponseValidationException::withError("{$shortHandler} has invalid schema: [ {$exception->getMessage()} ]"); - } catch (\Exception $exception) { - throw ResponseValidationException::withError($exception->getMessage()); - } + throw ResponseValidationException::withError("{$shortHandler} json response field {$dataPointer} does not match the spec: [ {$error->keyword()}: {$args} ]", $result->getErrors()); + } + } + + /** + * @throws ResponseValidationException + */ + protected function response(): Response + { + $responses = $this->operation->responses; + + if ($responses[$this->response->getStatusCode()] !== null) { + return $responses[$this->response->getStatusCode()]; + } + + if ($responses['default'] !== null) { + return $responses['default']; + } + + throw new ResponseValidationException("No response object matching returned status code [{$this->response->getStatusCode()}]."); + } - if (optional($result)->isValid() === false) { - $error = $result->getFirstError(); - $args = json_encode($error->keywordArgs()); - $dataPointer = implode('.', $error->dataPointer()); + /** + * @return string + */ + protected function contentType() + { + return $this->response->headers->get('Content-Type'); + } + + /** + * @param $contentType + * @param $schemaType + * + * @return mixed + * + * @throws ResponseValidationException + */ + protected function body($contentType, $schemaType) + { + $body = $this->response->getContent(); - throw ResponseValidationException::withError("{$shortHandler} json response field {$dataPointer} does not match the spec: [ {$error->keyword()}: {$args} ]", $result->getErrors()); + if (in_array($schemaType, ['object', 'array'], true)) { + if (in_array($contentType, ['application/json', 'application/vnd.api+json'])) { + return json_decode($body); + } else { + throw new ResponseValidationException("Unable to map [{$contentType}] to schema type [object]."); } } + + return $body; + } + + /** + * @return string + */ + protected function shortHandler() + { + return class_basename($this->operation->operationId) ?: $this->uri; } protected function validator(): Validator From 62f7531d88d2c06fb6332d83af432b5ac51d6089 Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Fri, 11 Dec 2020 13:00:53 -0600 Subject: [PATCH 3/4] #19 Handle nullable value in 3.0.x by modifying type --- src/Assertions.php | 7 +- src/Middleware.php | 6 +- src/Validation/ResponseValidator.php | 35 ++++++- tests/Fixtures/Nullable.3.0.json | 4 +- tests/Fixtures/Nullable.3.1.json | 60 ++++++++++++ tests/ResponseValidatorTest.php | 133 ++++++++++++++++++++++++--- 6 files changed, 223 insertions(+), 22 deletions(-) create mode 100644 tests/Fixtures/Nullable.3.1.json diff --git a/src/Assertions.php b/src/Assertions.php index 8ea0424..39ef42a 100644 --- a/src/Assertions.php +++ b/src/Assertions.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use PHPUnit\Framework\Assert as PHPUnit; use Spectator\Exceptions\InvalidPathException; +use Spectator\Exceptions\MissingSpecException; use Spectator\Exceptions\RequestValidationException; use Spectator\Exceptions\ResponseValidationException; use cebe\openapi\exceptions\UnresolvableReferenceException; @@ -18,9 +19,10 @@ public function assertValidRequest() PHPUnit::assertFalse( in_array(Arr::get($contents, 'exception'), [ + InvalidPathException::class, + MissingSpecException::class, RequestValidationException::class, UnresolvableReferenceException::class, - InvalidPathException::class, ]), $this->decodeExceptionMessage($contents) ); @@ -36,9 +38,10 @@ public function assertInvalidRequest() PHPUnit::assertTrue( in_array(Arr::get($contents, 'exception'), [ + InvalidPathException::class, + MissingSpecException::class, RequestValidationException::class, UnresolvableReferenceException::class, - InvalidPathException::class, ]), $this->decodeExceptionMessage($contents) ); diff --git a/src/Middleware.php b/src/Middleware.php index d995d76..bc7d46e 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -19,6 +19,8 @@ class Middleware { protected $spectator; + protected $version = '3.0'; + public function __construct(RequestFactory $spectator) { $this->spectator = $spectator; @@ -73,7 +75,7 @@ protected function validate(Request $request, Closure $next) $response = $next($request); - ResponseValidator::validate($request_path, $response, $operation); + ResponseValidator::validate($request_path, $response, $operation, $this->version); $this->spectator->reset(); @@ -88,6 +90,8 @@ protected function operation($request_path, $request_method) $openapi = $this->spectator->resolve(); + $this->version = $openapi->openapi; + foreach ($openapi->paths as $path => $pathItem) { if ($this->resolvePath($path) === $request_path) { $methods = array_keys($pathItem->getOperations()); diff --git a/src/Validation/ResponseValidator.php b/src/Validation/ResponseValidator.php index 146e73a..3a180ae 100644 --- a/src/Validation/ResponseValidator.php +++ b/src/Validation/ResponseValidator.php @@ -3,6 +3,8 @@ namespace Spectator\Validation; use Exception; +use Illuminate\Support\Arr; +use Illuminate\Support\Str; use cebe\openapi\spec\Schema; use Opis\JsonSchema\Validator; use cebe\openapi\spec\Response; @@ -19,16 +21,19 @@ class ResponseValidator protected $operation; - public function __construct(string $uri, $response, Operation $operation) + protected $version; + + public function __construct(string $uri, $response, Operation $operation, $version = '3.0') { $this->uri = $uri; $this->response = $response; $this->operation = $operation; + $this->version = $version; } - public static function validate(string $uri, $response, Operation $operation) + public static function validate(string $uri, $response, Operation $operation, $version = '3.0') { - $instance = new self($uri, $response, $operation); + $instance = new self($uri, $response, $operation, $version); $instance->handle(); } @@ -75,7 +80,7 @@ protected function validateResponse(Schema $schema, $body) $shortHandler = $this->shortHandler(); try { - $result = $validator->dataValidation($body, $schema->getSerializableData(), -1); + $result = $validator->dataValidation($body, $this->prepareData($schema->getSerializableData()), -1); } catch (SchemaKeywordException $exception) { throw ResponseValidationException::withError("{$shortHandler} has invalid schema: [ {$exception->getMessage()} ]"); } catch (Exception $exception) { @@ -154,4 +159,26 @@ protected function validator(): Validator return $validator; } + + protected function prepareData($data) + { + if (!isset($data->properties)) { + return $data; + } + + $clone = clone $data; + + $v30 = Str::startsWith($this->version, '3.0'); + + foreach ($clone->properties as $key => $attributes) { + if ($v30 && isset($attributes->nullable)) { + $type = Arr::wrap($attributes->type); + $type[] = 'null'; + $attributes->type = array_unique($type); + unset($attributes->nullable); + } + } + + return $clone; + } } diff --git a/tests/Fixtures/Nullable.3.0.json b/tests/Fixtures/Nullable.3.0.json index a1ec8f1..f9121e9 100644 --- a/tests/Fixtures/Nullable.3.0.json +++ b/tests/Fixtures/Nullable.3.0.json @@ -40,9 +40,9 @@ "type": "string", "example": "Bloggs" }, - "favourite_colour": { + "email": { "type": "string", - "example": "Red", + "example": "test@test.com", "nullable": true } } diff --git a/tests/Fixtures/Nullable.3.1.json b/tests/Fixtures/Nullable.3.1.json new file mode 100644 index 0000000..7b83026 --- /dev/null +++ b/tests/Fixtures/Nullable.3.1.json @@ -0,0 +1,60 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Nullable.3.1", + "version": "1.0" + }, + "servers": [ + { + "url": "http://localhost:3000" + } + ], + "paths": { + "/users/{user}": { + "parameters": [ + { + "schema": { + "type": "string" + }, + "name": "user", + "in": "path", + "required": true + } + ], + "get": { + "summary": "Get single user", + "tags": [], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "first_name": { + "type": "string", + "example": "Joe" + }, + "last_name": { + "type": "string", + "example": "Bloggs" + }, + "email": { + "type": ["string", "null"], + "example": "test@test.com" + } + } + } + } + } + } + }, + "operationId": "get-users-user" + } + } + }, + "components": { + "schemas": {} + } +} \ No newline at end of file diff --git a/tests/ResponseValidatorTest.php b/tests/ResponseValidatorTest.php index 3838b26..1534eb8 100644 --- a/tests/ResponseValidatorTest.php +++ b/tests/ResponseValidatorTest.php @@ -10,6 +10,12 @@ class ResponseValidatorTest extends TestCase { + const NULLABLE_MISSING = 0; + const NULLABLE_EMPTY = 1; + const NULLABLE_VALID = 2; + const NULLABLE_INVALID = 3; + const NULLABLE_NULL = 4; + public function setUp(): void { parent::setUp(); @@ -107,21 +113,122 @@ public function test_cannot_locate_path_without_path_prefix() ->assertValidResponse(200); } - public function test_handle_nullable_in_oa3dot0() - { - Spectator::using('Nullable.3.0.json'); - - Route::get('/api/v1/users/1', function () { - return [ - [ - 'first_name' => 'Joe', - 'last_name' => 'Bloggs', - ], + /** + * @dataProvider nullableProvider + */ + public function test_handle_nullables( + $version, + $state, + $is_valid + ) { + Spectator::using("Nullable.{$version}.json"); + + Route::get('/users/{user}', function () use ($state) { + $return = [ + 'first_name' => 'Joe', + 'last_name' => 'Bloggs', ]; + + if ($state === self::NULLABLE_EMPTY) { + $return['email'] = ''; + } + + if ($state === self::NULLABLE_VALID) { + $return['email'] = 'test@test.com'; + } + + if ($state === self::NULLABLE_INVALID) { + $return['email'] = [1, 2, 3]; + } + + if ($state === self::NULLABLE_NULL) { + $return['email'] = null; + } + + return $return; })->middleware(Middleware::class); - $this->getJson('/api/v1/users/1') - ->assertValidRequest() - ->assertValidResponse(); + if ($is_valid) { + $this->getJson('/users/1') + ->assertValidRequest() + ->assertValidResponse(); + } else { + $this->getJson('/users/1') + ->assertValidRequest() + ->assertInvalidResponse(); + } + } + + public function nullableProvider() + { + $validResponse = true; + $invalidResponse = false; + + $v30 = '3.0'; + $v31 = '3.1'; + + return [ + // OA 3.0.0 + '3.0, missing' => [ + $v30, + self::NULLABLE_MISSING, + $validResponse, + ], + + '3.0, empty' => [ + $v30, + self::NULLABLE_EMPTY, + $validResponse, + ], + + '3.0, null' => [ + $v30, + self::NULLABLE_NULL, + $validResponse, + ], + + '3.0, valid' => [ + $v30, + self::NULLABLE_VALID, + $validResponse, + ], + + '3.0, invalid' => [ + $v30, + self::NULLABLE_INVALID, + $invalidResponse, + ], + + // OA 3.1.0 + '3.1, missing' => [ + $v31, + self::NULLABLE_MISSING, + $validResponse, + ], + + '3.1, empty' => [ + $v31, + self::NULLABLE_EMPTY, + $validResponse, + ], + + '3.1, null' => [ + $v31, + self::NULLABLE_NULL, + $validResponse, + ], + + '3.1, valid' => [ + $v31, + self::NULLABLE_VALID, + $validResponse, + ], + + '3.1, invalid' => [ + $v31, + self::NULLABLE_INVALID, + $invalidResponse, + ], + ]; } } From ef41b537c736671987f666216471f55b7480701a Mon Sep 17 00:00:00 2001 From: Adam Campbell Date: Fri, 11 Dec 2020 13:09:26 -0600 Subject: [PATCH 4/4] #19 Fix test path and add message validation --- tests/{Fixtures => }/AssertionsTest.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename tests/{Fixtures => }/AssertionsTest.php (83%) diff --git a/tests/Fixtures/AssertionsTest.php b/tests/AssertionsTest.php similarity index 83% rename from tests/Fixtures/AssertionsTest.php rename to tests/AssertionsTest.php index ca8f6ad..bda28d0 100644 --- a/tests/Fixtures/AssertionsTest.php +++ b/tests/AssertionsTest.php @@ -1,10 +1,9 @@ middleware(Middleware::class); $this->getJson('/invalid') - ->assertInvalidRequest(); + ->assertInvalidRequest() + ->assertValidationMessage('Path [GET /invalid] not found in spec.'); } }