From 67e9d6ae3362844e6f3e1b999987e29d14f19459 Mon Sep 17 00:00:00 2001 From: "luis.dominguez" Date: Fri, 27 Oct 2023 15:42:59 +0200 Subject: [PATCH 1/4] feat: added validation of the requests using the openapi schemas --- composer.json | 5 ++ src/Behat/ResponseValidatorOpenApiContext.php | 67 +++++++++++++++++-- .../MinkResponseValidatorOpenApiContext.php | 17 ++++- ...HistoryResponseValidatorOpenApiContext.php | 16 ++++- src/OpenApi/OpenApiSchemaParser.php | 47 +++++++++++-- 5 files changed, 136 insertions(+), 16 deletions(-) diff --git a/composer.json b/composer.json index d7db7df..4496b75 100644 --- a/composer.json +++ b/composer.json @@ -44,5 +44,10 @@ "dealerdirect/phpcodesniffer-composer-installer": true }, "sort-packages": true + }, + "extra": { + "branch-alias": { + "dev-master": "4.0-dev" + } } } diff --git a/src/Behat/ResponseValidatorOpenApiContext.php b/src/Behat/ResponseValidatorOpenApiContext.php index 3b36e03..66c70bd 100644 --- a/src/Behat/ResponseValidatorOpenApiContext.php +++ b/src/Behat/ResponseValidatorOpenApiContext.php @@ -26,7 +26,7 @@ public function theJsonResponseShouldBeValidAccordingToOpenApiSchema($dumpPath, $path = \realpath($this->rootPath . '/' . $dumpPath); $this->checkSchemaFile($path); - $responseJson = $this->extractContent(); + $responseJson = $this->extractResponseContent(); $allSpec = $this->cacheAdapter->get( \md5($path), @@ -52,6 +52,45 @@ function (ItemInterface $item) use ($path) { } } + /** @Then the request should be valid according to OpenApi :dumpPath with path :openApiPath */ + public function theRequestShouldBeValidAccordingToOpenApiWithPath(string $dumpPath, string $openApiPath): void + { + $path = \realpath($this->rootPath . '/' . $dumpPath); + $this->checkSchemaFile($path); + + $method = \strtolower($this->extractMethod()); + $contentType = $this->extractRequestContentType(); + + $requestJson = $this->extractRequestContent(); + + $allSpec = $this->cacheAdapter->get( + \md5($path), + function (ItemInterface $item) use ($path) { + $item->expiresAfter(null); + + $allSpec = Yaml::parse(file_get_contents($path)); + + return $this->getDataExternalReferences($allSpec, $path); + }, + ); + + $schemaSpec = (new OpenApiSchemaParser($allSpec))->fromRequestBody( + $openApiPath, + $method, + $contentType, + ); + + $validator = new JsonValidator( + $requestJson, + new JsonSchema(\json_decode(\json_encode($schemaSpec), false)), + ); + $validation = $validator->validate(); + + if ($validation->hasError()) { + throw new JsonValidationException($validation->errorMessage()); + } + } + /** @Then the response should be valid according to OpenApi :dumpPath with path :openApiPath */ public function theResponseShouldBeValidAccordingToOpenApiWithPath(string $dumpPath, string $openApiPath): void { @@ -60,9 +99,9 @@ public function theResponseShouldBeValidAccordingToOpenApiWithPath(string $dumpP $statusCode = $this->extractStatusCode(); $method = \strtolower($this->extractMethod()); - $contentType = $this->contentType(); + $contentType = $this->responseContentType(); - $responseJson = $this->extractContent(); + $responseJson = $this->extractResponseContent(); $allSpec = $this->cacheAdapter->get( \md5($path), @@ -93,21 +132,35 @@ function (ItemInterface $item) use ($path) { } } + /** @Then the request and response should be valid according to OpenApi :dumpPath with path :openApiPath */ + public function theRequestAndResponseShouldBeValidAccordingToOpenApiWithPath( + string $dumpPath, + string $openApiPath, + ): void { + $this->theRequestShouldBeValidAccordingToOpenApiWithPath($dumpPath, $openApiPath); + + $this->theResponseShouldBeValidAccordingToOpenApiWithPath($dumpPath, $openApiPath); + } + abstract protected function extractMethod(): string; - abstract protected function extractContentType(): ?string; + abstract protected function extractRequestContentType(): ?string; + + abstract protected function extractResponseContentType(): ?string; abstract protected function extractStatusCode(): int; - abstract protected function extractContent(): string; + abstract protected function extractRequestContent(): string; + + abstract protected function extractResponseContent(): string; - private function contentType(): string + private function responseContentType(): string { if (self::HTTP_NO_CONTENT_CODE === $this->extractStatusCode()) { return ''; } - $contentType = $this->extractContentType(); + $contentType = $this->extractResponseContentType(); if (null === $contentType) { throw new \RuntimeException( diff --git a/src/Behat/ResponseValidatorOpenApiContext/MinkResponseValidatorOpenApiContext.php b/src/Behat/ResponseValidatorOpenApiContext/MinkResponseValidatorOpenApiContext.php index b0a4435..585fb77 100644 --- a/src/Behat/ResponseValidatorOpenApiContext/MinkResponseValidatorOpenApiContext.php +++ b/src/Behat/ResponseValidatorOpenApiContext/MinkResponseValidatorOpenApiContext.php @@ -7,6 +7,9 @@ use Behat\MinkExtension\Context\MinkContext; use PcComponentes\OpenApiMessagingContext\Behat\ResponseValidatorOpenApiContext; +/** + * @deprecated No longer used. Open an issue to let us know if you do, and kindly implement extractRequestContentType() and extractRequestContent() + */ final class MinkResponseValidatorOpenApiContext extends ResponseValidatorOpenApiContext { private const CONTENT_TYPE_RESPONSE_HEADER_KEY = 'content-type'; @@ -26,7 +29,12 @@ protected function extractMethod(): string return $requestClient->getHistory()->current()->getMethod(); } - protected function extractContentType(): ?string + protected function extractRequestContentType(): ?string + { + throw new \LogicException('Not implemented'); + } + + protected function extractResponseContentType(): ?string { return $this->minkContext->getSession()->getResponseHeader(self::CONTENT_TYPE_RESPONSE_HEADER_KEY); } @@ -36,7 +44,12 @@ protected function extractStatusCode(): int return $this->minkContext->getSession()->getStatusCode(); } - protected function extractContent(): string + protected function extractRequestContent(): string + { + throw new \LogicException('Not implemented'); + } + + protected function extractResponseContent(): string { return $this->minkContext->getSession()->getPage()->getContent(); } diff --git a/src/Behat/ResponseValidatorOpenApiContext/RequestHistoryResponseValidatorOpenApiContext.php b/src/Behat/ResponseValidatorOpenApiContext/RequestHistoryResponseValidatorOpenApiContext.php index ed71a78..c1646b0 100644 --- a/src/Behat/ResponseValidatorOpenApiContext/RequestHistoryResponseValidatorOpenApiContext.php +++ b/src/Behat/ResponseValidatorOpenApiContext/RequestHistoryResponseValidatorOpenApiContext.php @@ -21,7 +21,14 @@ protected function extractMethod(): string return $this->requestHistory->getLastRequest()->getMethod(); } - protected function extractContentType(): ?string + protected function extractRequestContentType(): ?string + { + $request = $this->requestHistory->getLastRequest(); + + return $request->getMimeType($request->getContentType()); + } + + protected function extractResponseContentType(): ?string { return $this->requestHistory->getLastResponse()->headers->get(self::CONTENT_TYPE_RESPONSE_HEADER_KEY); } @@ -31,7 +38,12 @@ protected function extractStatusCode(): int return $this->requestHistory->getLastResponse()->getStatusCode(); } - protected function extractContent(): string + protected function extractRequestContent(): string + { + return $this->requestHistory->getLastRequest()->getContent(); + } + + protected function extractResponseContent(): string { return $this->requestHistory->getLastResponse()->getContent(); } diff --git a/src/OpenApi/OpenApiSchemaParser.php b/src/OpenApi/OpenApiSchemaParser.php index 6da2b93..0c7b4cc 100644 --- a/src/OpenApi/OpenApiSchemaParser.php +++ b/src/OpenApi/OpenApiSchemaParser.php @@ -20,6 +20,25 @@ public function parse($name): array return $this->extractData($schemaSpec); } + public function fromRequestBody(string $path, string $method, string $contentType): array + { + $rootPaths = $this->originalContent['paths']; + $this->assertPathRoot($path, $rootPaths); + $pathRoot = $rootPaths[$path]; + + $this->assertMethodRoot($path, $method, $pathRoot); + $methodRoot = $pathRoot[$method]; + + if (false === \array_key_exists('requestBody', $methodRoot)) { + return []; + } + + $requestBodyRoot = $methodRoot['requestBody']; + $this->assertRequestContentTypeRoot($path, $method, $contentType, $requestBodyRoot); + + return $this->extractData($requestBodyRoot['content'][$contentType]['schema']); + } + public function fromResponse(string $path, string $method, int $statusCode, string $contentType): array { $rootPaths = $this->originalContent['paths']; @@ -36,7 +55,7 @@ public function fromResponse(string $path, string $method, int $statusCode, stri return []; } - $this->assertContentTypeRoot($path, $method, $statusCode, $contentType, $statusCodeRoot); + $this->assertResponseContentTypeRoot($path, $method, $statusCode, $contentType, $statusCodeRoot); return $this->extractData($statusCodeRoot['content'][$contentType]['schema']); } @@ -105,16 +124,34 @@ private function assertMethodRoot(string $path, string $method, $pathRoot): void private function assertStatusCodeRoot(string $path, string $method, int $statusCode, $methodRoot): void { if (false === \array_key_exists('responses', $methodRoot) || false === \array_key_exists( - $statusCode, - $methodRoot['responses'], - )) { + $statusCode, + $methodRoot['responses'], + )) { throw new \InvalidArgumentException( \sprintf('%s response not found on %s path with %s method', $statusCode, $path, $method), ); } } - private function assertContentTypeRoot( + private function assertRequestContentTypeRoot( + string $path, + string $method, + string $contentType, + $statusCodeRoot, + ): void { + if (false === \array_key_exists($contentType, $statusCodeRoot['content'])) { + throw new \InvalidArgumentException( + \sprintf( + '%s content-type not found on %s path with %s method', + $contentType, + $path, + $method, + ), + ); + } + } + + private function assertResponseContentTypeRoot( string $path, string $method, int $statusCode, From ab5753ecb904f00f9090ccfec5d114d37877e8e9 Mon Sep 17 00:00:00 2001 From: "luis.dominguez" Date: Mon, 30 Oct 2023 10:03:33 +0100 Subject: [PATCH 2/4] feat: added validation of requests to the readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index f739c09..84d34e9 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,18 @@ Configuration: ``` ## ResponseValidatorOpenApiContext +Check if http requests are documented in your openapi file: +```gherkin +Then the request should be valid according to OpenApi "docs/openapi.yml" with path "/your/openapi/path/" +``` Check if http responses are documented in your openapi file: ```gherkin Then the response should be valid according to OpenApi "docs/openapi.yml" with path "/your/openapi/path/" ``` +Additionally, you can check both the request and response with: +```gherkin +Then the request and response should be valid according to OpenApi "docs/openapi.yml" with path "/your/openapi/path/" +``` Configuration: ```yaml - PcComponentes\OpenApiMessagingContext\Behat\ResponseValidatorOpenApiContext: From a8b76b55d2fcfa388a1a4af2c14600273d3f7b30 Mon Sep 17 00:00:00 2001 From: "luis.dominguez" Date: Mon, 30 Oct 2023 10:13:49 +0100 Subject: [PATCH 3/4] fix: branch alias --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 4496b75..49015a8 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,7 @@ }, "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-feature/add-request-validation-using-openapi": "4.0-dev" } } } From 0b5806b0815fb2355b87110823af404eb5ac9b99 Mon Sep 17 00:00:00 2001 From: "luis.dominguez" Date: Mon, 30 Oct 2023 10:31:07 +0100 Subject: [PATCH 4/4] fix: removed extra/branch-alias --- composer.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/composer.json b/composer.json index 49015a8..d7db7df 100644 --- a/composer.json +++ b/composer.json @@ -44,10 +44,5 @@ "dealerdirect/phpcodesniffer-composer-installer": true }, "sort-packages": true - }, - "extra": { - "branch-alias": { - "dev-feature/add-request-validation-using-openapi": "4.0-dev" - } } }