diff --git a/.editorconfig b/.editorconfig index 27e2667..91aebb8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,8 +11,5 @@ indent_size = 4 trim_trailing_whitespace = true insert_final_newline = true -[*.md] -trim_trailing_whitespace = false - [*.yml] indent_size = 2 diff --git a/.gitignore b/.gitignore index 86bfd55..ac89ebd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ -.php_cs.cache -.phpunit.result.cache -composer.lock -coverage.xml -phpbench.json -phpcs.xml -phpunit.xml -vendor/ +/.php_cs.cache +/.phpunit.result.cache +/composer.lock +/coverage.xml +/phpbench.json +/phpcs.xml +/phpunit.xml +/vendor/ diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..d9676f0 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,21 @@ +build: + environment: + php: + version: '8.0' + pecl_extensions: + - apcu + ini: + 'apc.enable_cli': '1' + 'xdebug.mode': 'coverage' + nodes: + analysis: + tests: + override: + - php-scrutinizer-run + coverage: + tests: + override: + - command: php vendor/bin/phpunit --coverage-clover coverage.xml + coverage: + file: coverage.xml + format: clover diff --git a/composer.json b/composer.json index 93b7dd7..b9e639b 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "require-dev": { "phpunit/phpunit": "7.5.20||9.5.0", "sunrise/coding-standard": "1.0.0", + "sunrise/http-factory": "1.1.0", "justinrainbow/json-schema": "5.2.10" }, "autoload": { diff --git a/src/Test/OpenApiAssertKitTrait.php b/src/Test/OpenApiAssertKitTrait.php new file mode 100644 index 0000000..891346b --- /dev/null +++ b/src/Test/OpenApiAssertKitTrait.php @@ -0,0 +1,78 @@ + + * @copyright Copyright (c) 2019, Anatoly Fenric + * @license https://github.com/sunrise-php/http-router-openapi/blob/master/LICENSE + * @link https://github.com/sunrise-php/http-router-openapi + */ + +namespace Sunrise\Http\Router\OpenApi\Test; + +/** + * Import classes + */ +use JsonSchema\Validator as JsonSchemaValidator; +use Psr\Http\Message\ResponseInterface; +use Sunrise\Http\Router\OpenApi\Utility\JsonSchemaBuilder; +use Sunrise\Http\Router\RouteInterface; +use ReflectionClass; + +/** + * Import functions + */ +use function json_decode; +use function json_encode; +use function json_last_error; +use function json_last_error_msg; + +/** + * Import constants + */ +use const JSON_ERROR_NONE; +use const JSON_PRETTY_PRINT; +use const JSON_UNESCAPED_SLASHES; +use const JSON_UNESCAPED_UNICODE; + +/** + * OpenApiAssertKitTrait + */ +trait OpenApiAssertKitTrait +{ + + /** + * @param RouteInterface $route + * @param ResponseInterface $response + * + * @return void + */ + protected function assertResponseBodyMatchesDescription(RouteInterface $route, ResponseInterface $response) : void + { + $body = (string) $response->getBody(); + if ('' === $body) { + $this->fail('Response body MUST be non-empty.'); + } + + $data = json_decode($body); + if (JSON_ERROR_NONE !== json_last_error()) { + $this->fail('Response body MUST contain valid JSON: ' . json_last_error_msg()); + } + + $jsonSchemaBuilder = new JsonSchemaBuilder(new ReflectionClass($route->getRequestHandler())); + $jsonSchema = $jsonSchemaBuilder->forResponseBody($response->getStatusCode(), 'application/json'); + if (null === $jsonSchema) { + $this->fail('No JSON schema found.'); + } + + $jsonSchemaValidator = new JsonSchemaValidator(); + $jsonSchemaValidator->validate($data, $jsonSchema); + if (false === $jsonSchemaValidator->isValid()) { + $flags = JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES|JSON_UNESCAPED_UNICODE; + $this->fail('Invalid body: ' . json_encode($jsonSchemaValidator->getErrors(), $flags)); + } + + $this->assertTrue(true); + } +} diff --git a/src/Utility/JsonSchemaBuilder.php b/src/Utility/JsonSchemaBuilder.php index d5df664..942d0d4 100644 --- a/src/Utility/JsonSchemaBuilder.php +++ b/src/Utility/JsonSchemaBuilder.php @@ -30,6 +30,7 @@ /** * Import functions */ +use function is_array; use function array_keys; use function array_walk; use function array_walk_recursive; @@ -213,6 +214,7 @@ public function forRequestBody(string $mediaType) : ?array } $jsonSchema += $requestBody->content[$mediaType]->schema->toArray(); + $jsonSchema = $this->fixNullable($jsonSchema); return $this->fixReferences($jsonSchema); } @@ -251,6 +253,7 @@ public function forResponseBody($statusCode, string $mediaType) : ?array } $jsonSchema += $response->content[$mediaType]->schema->toArray(); + $jsonSchema = $this->fixNullable($jsonSchema); return $this->fixReferences($jsonSchema); } @@ -314,6 +317,8 @@ private function forRequestParams(string $name) : ?array $schema = $schema->toArray(); }); + $jsonSchema = $this->fixNullable($jsonSchema); + return $this->fixReferences($jsonSchema); } @@ -332,4 +337,31 @@ private function fixReferences(array $jsonSchema) : array return $jsonSchema; } + + /** + * @param array $jsonSchema + * + * @return array + */ + private function fixNullable(array $jsonSchema) : array + { + $fixer = function (array &$array) use (&$fixer) { + foreach ($array as $key => &$value) { + if ('nullable' === $key && true === $value) { + $array['type'] = [$array['type'], 'null']; + unset($array[$key]); + continue; + } + + if (is_array($value)) { + $fixer($value); + continue; + } + } + }; + + $fixer($jsonSchema); + + return $jsonSchema; + } } diff --git a/tests/Test/OpenApiAssertKitTraitTest.php b/tests/Test/OpenApiAssertKitTraitTest.php new file mode 100644 index 0000000..a6cd212 --- /dev/null +++ b/tests/Test/OpenApiAssertKitTraitTest.php @@ -0,0 +1,129 @@ +createResponse(200); + $response->getBody()->write(''); + try { + $this->assertResponseBodyMatchesDescription($route, $response); + } catch (AssertionFailedError $e) { + $this->assertTrue(true); + $this->assertSame('Response body MUST be non-empty.', $e->getMessage()); + } + + $route = new Route('foo', '/foo', ['GET'], $rh1); + $response = (new ResponseFactory)->createResponse(200); + $response->getBody()->write('!'); + try { + $this->assertResponseBodyMatchesDescription($route, $response); + } catch (AssertionFailedError $e) { + $this->assertTrue(true); + $this->assertSame('Response body MUST contain valid JSON: Syntax error', $e->getMessage()); + } + + $route = new Route('foo', '/foo', ['GET'], $rh2); + $response = (new ResponseFactory)->createResponse(200); + $response->getBody()->write(json_encode([])); + try { + $this->assertResponseBodyMatchesDescription($route, $response); + } catch (AssertionFailedError $e) { + $this->assertTrue(true); + $this->assertSame('No JSON schema found.', $e->getMessage()); + } + + $route = new Route('foo', '/foo', ['GET'], $rh1); + $response = (new ResponseFactory)->createResponse(200); + $response->getBody()->write(json_encode(['foo', 'foo', 'bar' => 1])); + try { + $this->assertResponseBodyMatchesDescription($route, $response); + } catch (AssertionFailedError $e) { + $this->assertTrue(true); + $this->assertSame('Invalid body: [ + { + "property": "bar", + "pointer": "/bar", + "message": "Integer value found, but a string or a null is required", + "constraint": "type", + "context": 1 + } +]', $e->getMessage()); + } + + $route = new Route('foo', '/foo', ['GET'], $rh1); + $response = (new ResponseFactory)->createResponse(200); + $response->getBody()->write(json_encode(['foo', 'foo', 'bar' => 'bar'])); + $this->assertResponseBodyMatchesDescription($route, $response); + + $route = new Route('foo', '/foo', ['GET'], $rh1); + $response = (new ResponseFactory)->createResponse(200); + $response->getBody()->write(json_encode(['foo', 'foo', 'bar' => null])); + $this->assertResponseBodyMatchesDescription($route, $response); + } +} diff --git a/tests/Utility/JsonSchemaBuilderTest.php b/tests/Utility/JsonSchemaBuilderTest.php index 4410bae..f5fa0f4 100644 --- a/tests/Utility/JsonSchemaBuilderTest.php +++ b/tests/Utility/JsonSchemaBuilderTest.php @@ -733,4 +733,52 @@ public function testBuildJsonSchemaForResponseBodyWhenMediaTypeUnsupported() : v $this->assertNull($jsonSchema); } + + /** + * @return void + */ + public function testNullable() : void + { + /** + * @OpenApi\Operation( + * requestBody=@OpenApi\RequestBody( + * content={ + * "application/json"=@OpenApi\MediaType( + * schema=@OpenApi\Schema( + * type="object", + * properties={ + * "foo"=@OpenApi\Schema( + * type="string", + * nullable=true, + * ), + * }, + * ), + * ), + * }, + * ), + * responses={ + * 200=@OpenApi\Response( + * description="OK", + * ), + * }, + * ) + */ + $class = new class + { + }; + + $classReflection = new ReflectionClass($class); + $jsonSchemaBuilder = new JsonSchemaBuilder($classReflection); + $jsonSchema = $jsonSchemaBuilder->forRequestBody('application/json'); + + $this->assertSame([ + '$schema' => 'http://json-schema.org/draft-00/schema#', + 'properties' => [ + 'foo' => [ + 'type' => ['string', 'null'], + ], + ], + 'type' => 'object', + ], $jsonSchema); + } }