diff --git a/composer.json b/composer.json index 4af90615..cd14edf8 100644 --- a/composer.json +++ b/composer.json @@ -52,11 +52,13 @@ "fakerphp/faker": "^1.9", "firebase/php-jwt": "^6.0.0", "guzzlehttp/promises": "^1.4.0", - "illuminate/collections": "8.x-dev", + "illuminate/collections": "^8.0", "monolog/monolog": "^1.25.1 || ^2.8.0 || ^3.0.0", "myclabs/deep-copy": "^1.11", "nesbot/carbon": "^2.48", "nyholm/psr7": "1.*", + "league/openapi-psr7-validator": "^0.18", + "openclassrooms/openapi-psr7-validator": "dev-master", "opis/json-schema": "^2.3", "php-http/httplug": "^2.2", "phpdocumentor/reflection-docblock": "^5.3", @@ -101,6 +103,10 @@ { "type": "vcs", "url": "https://github.com/sidux/collections" + }, + { + "type": "vcs", + "url": "https://github.com/OpenClassrooms/openapi-psr7-validator" } ], "scripts": { diff --git a/config/phpstan-baseline.neon b/config/phpstan-baseline.neon new file mode 100644 index 00000000..bca07137 --- /dev/null +++ b/config/phpstan-baseline.neon @@ -0,0 +1,86 @@ +parameters: + ignoreErrors: + - + message: "#^PHPDoc tag @var with type Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), APITester\\\\Definition\\\\Security\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Definition/Api.php + + - + message: "#^PHPDoc tag @var with type Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\), string\\> is not subtype of type Illuminate\\\\Support\\\\Collection\\\\.$#" + count: 1 + path: ../src/Definition/Api.php + + - + message: "#^Cannot cast APITester\\\\Definition\\\\Tag to string\\.$#" + count: 1 + path: ../src/Definition/Collection/Operations.php + + - + message: "#^PHPDoc tag @var with type APITester\\\\Definition\\\\Example\\\\OperationExample is not subtype of type \\$this\\(APITester\\\\Definition\\\\Example\\\\OperationExample\\)\\.$#" + count: 2 + path: ../src/Definition/Example/OperationExample.php + + - + message: "#^Cannot cast APITester\\\\Definition\\\\Example\\\\OperationExample\\|null to string\\.$#" + count: 1 + path: ../src/Definition/Operation.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/Error400Preparator.php + + - + message: "#^Parameter \\#1 \\$items of method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),APITester\\\\Definition\\\\Scope\\>\\:\\:intersect\\(\\) expects Illuminate\\\\Contracts\\\\Support\\\\Arrayable\\<\\(int\\|string\\), APITester\\\\Definition\\\\Scope\\>\\|iterable\\<\\(int\\|string\\), APITester\\\\Definition\\\\Scope\\>, array\\ given\\.$#" + count: 1 + path: ../src/Preparator/Error403Preparator.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/Error404Preparator.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/Error405Preparator.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/Error406Preparator.php + + - + message: "#^Parameter \\#1 \\$items of method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),APITester\\\\Definition\\\\Response\\>\\:\\:compare\\(\\) expects iterable\\<\\(int\\|string\\), APITester\\\\Definition\\\\Response\\>, array\\ given\\.$#" + count: 1 + path: ../src/Preparator/Error406Preparator.php + + - + message: "#^Parameter \\#1 \\$items of method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),APITester\\\\Definition\\\\Response\\>\\:\\:intersect\\(\\) expects Illuminate\\\\Contracts\\\\Support\\\\Arrayable\\<\\(int\\|string\\), APITester\\\\Definition\\\\Response\\>\\|iterable\\<\\(int\\|string\\), APITester\\\\Definition\\\\Response\\>, array\\ given\\.$#" + count: 1 + path: ../src/Preparator/Error406Preparator.php + + - + message: "#^Method APITester\\\\Preparator\\\\ExamplesPreparator\\:\\:prepareTestCases\\(\\) should return iterable\\ but returns APITester\\\\Definition\\\\Collection\\\\OperationExamples\\.$#" + count: 1 + path: ../src/Preparator/ExamplesPreparator.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/ExamplesPreparator.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/PaginationErrorPreparator.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/RandomPreparator.php + + - + message: "#^PHPDoc tag @var with type iterable\\<\\(int\\|string\\), APITester\\\\Test\\\\TestCase\\> is not subtype of type APITester\\\\Definition\\\\Collection\\\\Operations\\.$#" + count: 1 + path: ../src/Preparator/SecurityErrorPreparator.php diff --git a/config/phpstan.neon b/config/phpstan.neon index 32f439cf..c62836d8 100644 --- a/config/phpstan.neon +++ b/config/phpstan.neon @@ -4,7 +4,7 @@ includes: - ../vendor/spaze/phpstan-disallowed-calls/disallowed-execution-calls.neon - ../vendor/spaze/phpstan-disallowed-calls/disallowed-insecure-calls.neon - ../vendor/spaze/phpstan-disallowed-calls/disallowed-loose-calls.neon - + - phpstan-baseline.neon parameters: treatPhpDocTypesAsCertain: false bootstrapFiles: diff --git a/src/Definition/Api.php b/src/Definition/Api.php index a4d1e72a..7266d71b 100644 --- a/src/Definition/Api.php +++ b/src/Definition/Api.php @@ -23,6 +23,8 @@ final class Api private Tags $tags; + private ?ApiSpecification $specification = null; + public function __construct() { $this->operations = new Operations(); @@ -30,6 +32,18 @@ public function __construct() $this->tags = new Tags(); } + public function getSpecification(): ?ApiSpecification + { + return $this->specification; + } + + public function setSpecification(ApiSpecification $specification): self + { + $this->specification = $specification; + + return $this; + } + public static function create(): self { return new self(); diff --git a/src/Definition/ApiSpecification.php b/src/Definition/ApiSpecification.php new file mode 100644 index 00000000..8a86f05d --- /dev/null +++ b/src/Definition/ApiSpecification.php @@ -0,0 +1,10 @@ +setServers($this->getServers($openApi->servers)) ->setTags($this->getTags($openApi->tags)) + ->setSpecification(new OpenApiSpecification($openApi)) ; } diff --git a/src/Definition/OpenApiSpecification.php b/src/Definition/OpenApiSpecification.php new file mode 100644 index 00000000..763c1f9d --- /dev/null +++ b/src/Definition/OpenApiSpecification.php @@ -0,0 +1,20 @@ +document; + } +} diff --git a/src/Test/Suite.php b/src/Test/Suite.php index 998882ff..6d17aa12 100644 --- a/src/Test/Suite.php +++ b/src/Test/Suite.php @@ -167,6 +167,7 @@ private function prepareTestCases(): void $testCase->setLogger($this->logger); $testCase->setBeforeCallbacks($this->beforeTestCaseCallbacks); $testCase->setAfterCallbacks($this->afterTestCaseCallbacks); + $testCase->setSpecification($this->api->getSpecification()); $allTests->add($testCase); } } catch (PreparatorLoadingException $e) { diff --git a/src/Test/TestCase.php b/src/Test/TestCase.php index 1eb9e8f3..1179cd99 100644 --- a/src/Test/TestCase.php +++ b/src/Test/TestCase.php @@ -4,9 +4,11 @@ namespace APITester\Test; +use APITester\Definition\ApiSpecification; use APITester\Definition\Body; use APITester\Definition\Example\OperationExample; use APITester\Definition\Example\ResponseExample; +use APITester\Definition\OpenApiSpecification; use APITester\Requester\Requester; use APITester\Requester\SymfonyKernelRequester; use APITester\Test\Exception\InvalidResponseSchemaException; @@ -20,6 +22,10 @@ use Carbon\Carbon; use cebe\openapi\spec\Schema; use Nyholm\Psr7\Stream; +use OpenClassrooms\OpenAPIValidation\PSR7\Exception\ValidationFailed; +use OpenClassrooms\OpenAPIValidation\PSR7\OperationAddress; +use OpenClassrooms\OpenAPIValidation\PSR7\ResponseValidator; +use OpenClassrooms\OpenAPIValidation\Schema\Exception\SchemaMismatch; use Opis\JsonSchema\Errors\ErrorFormatter; use Opis\JsonSchema\Validator; use PHPUnit\Framework\ExpectationFailedException; @@ -74,9 +80,7 @@ final class TestCase implements \JsonSerializable, Filterable private string $preparator; - private Validator $validator; - - private ErrorFormatter $errorFormatter; + private ?ApiSpecification $specification = null; /** * @param array $excludedFields @@ -95,8 +99,6 @@ public function __construct( $this->operation = $nameParts[1] ?? null; $this->request = $operationExample->getPsrRequest(); $this->schemaValidation = $schemaValidation; - $this->validator = new Validator(); - $this->errorFormatter = new ErrorFormatter(); } /** @@ -113,6 +115,11 @@ public function setSchemaValidation(bool $schemaValidation): void $this->schemaValidation = $schemaValidation; } + public function setSpecification(?ApiSpecification $specification = null): void + { + $this->specification = $specification; + } + /** * @throws ClientExceptionInterface * @throws ExceptionInterface @@ -270,6 +277,52 @@ public function jsonSerialize(): array ]; } + /** + * @throws InvalidResponseSchemaException + */ + public function validateOpenApiSchema(): void + { + if (!$this->specification instanceof OpenApiSpecification) { + throw new InvalidResponseSchemaException( + 'Unexpected error during schema validation: OpenApi document missing', + 0, + ); + } + + $operationAddress = new OperationAddress( + $this->operationExample->getPath(), + mb_strtolower($this->operationExample->getMethod()) + ); + + try { + $validator = new ResponseValidator($this->specification->getDocument()); + $validator->validate($operationAddress, $this->response); + } catch (ValidationFailed $e) { + $exception = $e->getPrevious(); + + if ($exception instanceof SchemaMismatch) { + $breadcrumb = implode( + '.', + $exception->dataBreadCrumb() !== null ? $exception->dataBreadCrumb() + ->buildChain() : [] + ); + $message = sprintf('Invalid field: %s. %s', $breadcrumb, $exception->getMessage()); + } + throw new InvalidResponseSchemaException( + $message ?? + sprintf('Response schema validation failed: %s', $exception?->getMessage()), + 0, + $e + ); + } catch (\Throwable $e) { + throw new InvalidResponseSchemaException( + 'Unexpected error during schema validation: ' . $e->getMessage(), + 0, + $e + ); + } + } + /** * @throws ExceptionInterface */ @@ -321,6 +374,24 @@ public function test(): void } } + /** + * @throws InvalidResponseSchemaException + */ + private function checkSchemaResponse(): void + { + if (!$this->schemaValidation) { + return; + } + + if ($this->specification instanceof OpenApiSpecification) { + $this->validateOpenApiSchema(); + + return; + } + + $this->validateSchema(); + } + private function getSchemaResponseForStatusCode(int $statusCode): ?Schema { if ($this->operationExample->getParent() === null) { @@ -339,12 +410,8 @@ private function getSchemaResponseForStatusCode(int $statusCode): ?Schema /** * @throws InvalidResponseSchemaException */ - private function checkSchemaResponse(): void + private function validateSchema(): void { - if (!$this->schemaValidation) { - return; - } - $schema = $this->getSchemaResponseForStatusCode($this->response->getStatusCode()); if ($schema === null) { @@ -354,11 +421,14 @@ private function checkSchemaResponse(): void $data = json_decode((string) ResponseExample::fromPsrResponse($this->response)->getContent()); $schemaData = (object) $schema->getSerializableData(); - $result = $this->validator->validate($data, $schemaData); + $validator = new Validator(); + $errorFormatter = new ErrorFormatter(); + + $result = $validator->validate($data, $schemaData); if (!$result->isValid()) { if ($result->error() !== null) { - $errorDescription = (string) json_encode($this->errorFormatter->format($result->error())); + $errorDescription = (string) json_encode($errorFormatter->format($result->error())); $this->logger->error($errorDescription); } throw new InvalidResponseSchemaException(); diff --git a/tests/Fixtures/FixturesLocation.php b/tests/Fixtures/FixturesLocation.php index 6d6fadc7..f75a9276 100644 --- a/tests/Fixtures/FixturesLocation.php +++ b/tests/Fixtures/FixturesLocation.php @@ -17,4 +17,6 @@ final class FixturesLocation public const CONFIG_OPENAPI = __DIR__ . '/Config/api-tester.yaml'; public const CONFIG_EXAMPLES_EXTENSION = __DIR__ . '/Examples/petstore/examples.new.yml'; + + public const OPEN_API_WITH_EXAMPLES = __DIR__ . '/OpenAPI/openapi-with-examples.yaml'; } diff --git a/tests/Fixtures/OpenAPI/openapi-with-examples.yaml b/tests/Fixtures/OpenAPI/openapi-with-examples.yaml index 62c66093..20cf3ea9 100644 --- a/tests/Fixtures/OpenAPI/openapi-with-examples.yaml +++ b/tests/Fixtures/OpenAPI/openapi-with-examples.yaml @@ -173,6 +173,25 @@ components: type: string tag: type: string + nullable: true + location: + required: + - city + - country + type: object + properties: + city: + type: string + enum: + - Paris + - Lyon + - Marseille + example: 'Paris' + country: + type: string + enum: + - France + example: 'France' Pets: type: array items: diff --git a/tests/Test/TestCaseTest.php b/tests/Test/TestCaseTest.php index a10c7b71..24dbce7f 100644 --- a/tests/Test/TestCaseTest.php +++ b/tests/Test/TestCaseTest.php @@ -6,9 +6,12 @@ use APITester\Definition\Collection\Responses; use APITester\Definition\Example\OperationExample; +use APITester\Definition\OpenApiSpecification; use APITester\Definition\Operation; use APITester\Test\Exception\InvalidResponseSchemaException; use APITester\Test\TestCase; +use APITester\Tests\Fixtures\FixturesLocation; +use cebe\openapi\Reader; use cebe\openapi\spec\Schema; use Nyholm\Psr7\Response; use PHPUnit\Framework\TestCase as UnitTestCase; @@ -17,108 +20,192 @@ final class TestCaseTest extends UnitTestCase { private TestCase $testCase; - public function testGivenValidResponseRegardingSchemaAndShouldValidateSchemaResponseOptionIsDisabledWhenAssertThenNoErrorIsThrown( - ): void { + public function testValidSchemaWithoutValidationShouldPass(): void + { $this->testCase = $this->givenTestCase( 200, - $this->getValidSchema(), $this->getValidResponseContent(), false ); - $this->whenAssert(); + $this->testCase->assert(); } - public function testGivenInvalidValidResponseRegardingSchemaAndShouldValidateSchemaResponseOptionIsDisabledWhenAssertThenNoErrorIsThrown( - ): void { + public function testInvalidSchemaWithoutValidationShouldPass(): void + { $this->testCase = $this->givenTestCase( 200, - $this->getValidSchema(), $this->getInvalidResponseContent(), false ); - $this->whenAssert(); + $this->testCase->assert(); } - public function testGivenInvalidResponseRegardingSchemaAndShouldValidateSchemaResponseOptionIsEnabledWhenAssertThenAnErrorIsThrown( - ): void { + public function testInvalidSchemaWithValidationShouldFail(): void + { $this->expectException(InvalidResponseSchemaException::class); $this->testCase = $this->givenTestCase( 200, - $this->getValidSchema(), $this->getInvalidResponseContent() ); - $this->whenAssert(); + $this->testCase->assert(); + } + + public function testNullablePropertiesWithSchemaValidationShouldPass(): void + { + $this->testCase = $this->givenTestCase( + 200, + '{ "name" : "Rex", "id" : 1, "tag" : null }' + ); + + $this->testCase->assert(); + } + + public function testInvalidSubObjectShouldFail(): void + { + $this->expectException(InvalidResponseSchemaException::class); + $this->expectExceptionMessage('Invalid field: location.city. Keyword validation failed: Value cannot be null'); + $this->testCase = $this->givenTestCase( + 200, + '{ "name" : "Rex", "id" : 1, "tag" : null, "location" : { "city" : null, "country" : "France" } }' + ); + + $this->testCase->assert(); + } + + public function testWrongEnum(): void + { + $this->expectException(InvalidResponseSchemaException::class); + $this->expectExceptionMessage( + 'Invalid field: location.city. Keyword validation failed: Value must be present in the enum' + ); + $this->testCase = $this->givenTestCase( + 200, + '{ "name" : "Rex", "id" : 1, "tag" : null, "location" : { "city" : "Montreuil", "country" : "France" } }' + ); + + $this->testCase->assert(); + } + + public function testNullRequiredPropertyWithSchemaValidationShouldFail(): void + { + $this->expectException(InvalidResponseSchemaException::class); + + $this->testCase = $this->givenTestCase( + 200, + '{ "name" : 1, "id" : 1, "tag" : "vaccinated" }' + ); + + $this->testCase->assert(); } public function testGivenSchemaResponseExistsButForDifferentStatusCodeAndShouldValidateSchemaResponseOptionIsEnabledWhenAssertThenNoErrorIsThrown( ): void { $this->testCase = $this->givenTestCase( 201, - $this->getValidSchema(), $this->getValidResponseContent() ); - $this->whenAssert(); + $this->testCase->assert(); } public function testGivenValidResponseRegardingSchemaAndShouldValidateSchemaResponseOptionIsEnabledWhenAssertThenNoErrorIsThrown( ): void { $this->testCase = $this->givenTestCase( 200, - $this->getValidSchema(), $this->getValidResponseContent() ); - $this->whenAssert(); + $this->testCase->assert(); + } + + public function testValidContentWithoutSpecificationJsonSchemaValidatorShouldPass(): void + { + $this->testCase = $this->givenTestCase( + 200, + $this->getValidResponseContent(), + true, + false + ); + $this->testCase->assert(); + } + + public function testInvalidContentWithoutSpecificationJsonSchemaValidatorShouldFail(): void + { + $this->expectException(InvalidResponseSchemaException::class); + $this->testCase = $this->givenTestCase( + 200, + $this->getInvalidResponseContent(), + true, + false + ); + $this->testCase->assert(); } - /** - * Privates - */ private function getValidSchema(): Schema { return new Schema([ 'type' => 'object', + 'required' => ['id', 'name'], 'properties' => [ - 'foo' => [ + 'id' => [ + 'type' => 'integer', + 'format' => 'int64', + ], + 'name' => [ 'type' => 'string', ], + 'tag' => [ + 'type' => 'string', + 'nullable' => true, + ], + 'location' => [ + 'type' => 'object', + 'required' => ['city', 'country'], + 'properties' => [ + 'city' => [ + 'type' => 'string', + 'enum' => ['Paris', 'Lyon', 'Marseille'], + ], + 'country' => [ + 'type' => 'string', + 'enum' => ['France'], + ], + ], + ], ], ]); } private function getValidResponseContent(): string { - return '{ "foo" : "bar" }'; + return '{ "name" : "Rex", "id" : 1, "tag" : "vaccinated", "location" : { "city" : "Paris", "country" : "France" }}'; } private function getInvalidResponseContent(): string { - return '{ "foo" : 42 }'; + return '{ "name" : 42, "id" : "1", "tag" : 1, "location" : { "city" : null, "country" : null }}'; } private function givenTestCase( int $operationResponseStatusCode, - Schema $operationResponseBody, string $responseContent, - bool $shouldValidateResponseSchema = true + bool $shouldValidateResponseSchema = true, + bool $openApiSpecification = true ): TestCase { $testCase = new TestCase( 'test1', OperationExample::create( name: 'test1_example', - operation: Operation::create('test1', '/test1') + operation: Operation::create('showPetById', '/pets/{petId}') ->setResponses( new Responses([ \APITester\Definition\Response::create($operationResponseStatusCode) ->setMediaType('application/json') - ->setBody( - $operationResponseBody - ), + ->setBody($this->getValidSchema()), ]) ), statusCode: 200 @@ -127,13 +214,14 @@ private function givenTestCase( $shouldValidateResponseSchema ); - $testCase->setResponse(new Response(200, [], $responseContent)); + if ($openApiSpecification) { + $openApi = Reader::readFromYamlFile(FixturesLocation::OPEN_API_WITH_EXAMPLES); + $testCase->setSpecification(new OpenApiSpecification($openApi)); + } + $testCase->setResponse(new Response(200, [ + 'Content-Type' => 'application/json', + ], $responseContent)); return $testCase; } - - private function whenAssert(): void - { - $this->testCase->assert(); - } }