diff --git a/README.md b/README.md index 724d0ef..2508f5d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,37 @@ How the response is looked up: * **Media type**: the response's `Content-Type`, including `+json` types such as `application/problem+json`. Without a match it falls back to `application/json`. Only JSON bodies are validated. +### Guzzle and PSR-18 clients +Verifying on the client side needs no framework. It works against a running server, a mock +handler or anything else a client can talk to. + +**Guzzle**: push the middleware onto the handler stack. A response that does not match makes the +request throw `OpenApiSchemaMismatchException`. + +```php +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; +use Radebatz\OpenApi\Verifier\Adapters\Guzzle\OpenApiVerifierMiddleware; +use Radebatz\OpenApi\Verifier\OpenApiVerifier; + +$stack = HandlerStack::create(); +$stack->push(new OpenApiVerifierMiddleware(OpenApiVerifier::fromSpecification('openapi.yaml')), 'openapi-verifier'); + +$client = new Client(['handler' => $stack, 'base_uri' => 'http://localhost:8080']); +$client->get('/user/1'); // verified against `/user/{id}` +``` + +**PSR-18**: wrap any `Psr\Http\Client\ClientInterface`, Guzzle's included. + +```php +use Radebatz\OpenApi\Verifier\Adapters\Psr18\OpenApiVerifyingClient; + +$client = new OpenApiVerifyingClient($psr18Client, OpenApiVerifier::fromSpecification('openapi.yaml')); +$response = $client->sendRequest($request); +``` + +Either way the response body is left at its start, so it can be read as usual afterwards. + ### Manual verification The `VerifiesOpenApi` trait can be used directly and customized in 3 ways in order to provide the reqired OpenApi specifications: * Overriding the method `getOpenApiSpecificationLoader()` as shown below diff --git a/composer.json b/composer.json index 5eecde5..0981971 100644 --- a/composer.json +++ b/composer.json @@ -56,11 +56,13 @@ "justinrainbow/json-schema": "^5.2.13 || ^6", "nyholm/psr7": "^1.1", "nyholm/psr7-server": "^1.0", + "psr/http-client": "^1.0", "psr/http-message": "^2.0", "symfony/psr-http-message-bridge": "^6.0 || ^7.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.62.0", + "guzzlehttp/guzzle": "^7.8", "laravel/laravel": "^12.0 || ^13.0", "league/container": "^4.2", "phpstan/phpstan": "^2.1", @@ -71,6 +73,7 @@ "zircote/swagger-php": "^4.8 || ^5.0 || ^6.0" }, "suggest": { + "guzzlehttp/guzzle": "To verify responses in a Guzzle client, with the Guzzle middleware", "laravel/framework": "To use the Laravel adapter", "slim/slim": "To use the Slim adapter", "zircote/swagger-php": "To generate the specification from PHP attributes when none is given" diff --git a/src/Adapters/Guzzle/OpenApiVerifierMiddleware.php b/src/Adapters/Guzzle/OpenApiVerifierMiddleware.php new file mode 100644 index 0000000..77f6476 --- /dev/null +++ b/src/Adapters/Guzzle/OpenApiVerifierMiddleware.php @@ -0,0 +1,37 @@ +push(new OpenApiVerifierMiddleware($verifier), 'openapi-verifier'); + * $client = new Client(['handler' => $stack]); + * ``` + */ +final class OpenApiVerifierMiddleware +{ + public function __construct(private readonly OpenApiVerifier $verifier) + { + } + + public function __invoke(callable $handler): callable + { + return fn (RequestInterface $request, array $options) => $handler($request, $options)->then( + function (ResponseInterface $response) use ($request): ResponseInterface { + $this->verifier->verify($request, $response); + + return $response; + } + ); + } +} diff --git a/src/Adapters/Psr18/OpenApiVerifyingClient.php b/src/Adapters/Psr18/OpenApiVerifyingClient.php new file mode 100644 index 0000000..309f779 --- /dev/null +++ b/src/Adapters/Psr18/OpenApiVerifyingClient.php @@ -0,0 +1,32 @@ +client->sendRequest($request); + + $this->verifier->verify($request, $response); + + return $response; + } +} diff --git a/src/OpenApiVerifier.php b/src/OpenApiVerifier.php index 8eeadbb..ca0c4f4 100644 --- a/src/OpenApiVerifier.php +++ b/src/OpenApiVerifier.php @@ -43,11 +43,18 @@ public function getSpecificationLoader(): OpenApiSpecificationLoader */ public function verify(RequestInterface $request, ResponseInterface $response, ?string $pathTemplate = null): bool { + $body = $response->getBody(); + $content = (string) $body; + if ($body->isSeekable()) { + // the caller reads the body next, and casting left the stream at its end + $body->rewind(); + } + return $this->verifyResponseBody( $request->getMethod(), $pathTemplate ?? $request->getUri()->getPath(), $response->getStatusCode(), - (string) $response->getBody(), + $content, $response->getHeaderLine('Content-Type') ?: null ); } diff --git a/tests/Adapters/ClientAdapterTest.php b/tests/Adapters/ClientAdapterTest.php new file mode 100644 index 0000000..c650112 --- /dev/null +++ b/tests/Adapters/ClientAdapterTest.php @@ -0,0 +1,86 @@ + ['/users', '{"data":[{"id":1,"name":"joe","email":"joe@cool.com"}]}', null]; + yield 'users mismatch' => ['/users', '{"data":[{"id":1,"email":"joe@cool.com"}]}', 'The property name is required']; + yield 'user, by template' => ['/user/1', '{"data":{"id":1,"name":"joe","email":"joe@cool.com"}}', null]; + yield 'user mismatch, by template' => ['/user/1', '{"data":{"id":1,"name":"joe"}}', 'The property email is required']; + yield 'not in the specification' => ['/nope', 'anything', null]; + } + + protected function send(callable $send, string $body, ?string $error): void + { + if ($error) { + $this->expectException(OpenApiSchemaMismatchException::class); + } + + try { + /** @var ResponseInterface $response */ + $response = $send(); + } catch (OpenApiSchemaMismatchException $oasme) { + $this->assertStringContainsString($error, (string) $oasme->getErrorSummary()); + + throw $oasme; + } + + // verifying must leave the body for the caller to read + $this->assertSame($body, $response->getBody()->getContents()); + } + + #[Test] + #[DataProvider('responses')] + public function guzzleMiddleware(string $path, string $body, ?string $error): void + { + $stack = HandlerStack::create(new MockHandler([new Response(200, ['Content-Type' => 'application/json'], $body)])); + $stack->push(new OpenApiVerifierMiddleware($this->verifier()), 'openapi-verifier'); + $client = new Client(['handler' => $stack, 'base_uri' => 'http://localhost']); + + $this->send(fn () => $client->get($path), $body, $error); + } + + #[Test] + #[DataProvider('responses')] + public function guzzleMiddlewareAsync(string $path, string $body, ?string $error): void + { + $stack = HandlerStack::create(new MockHandler([new Response(200, ['Content-Type' => 'application/json'], $body)])); + $stack->push(new OpenApiVerifierMiddleware($this->verifier()), 'openapi-verifier'); + $client = new Client(['handler' => $stack, 'base_uri' => 'http://localhost']); + + $this->send(fn () => $client->getAsync($path)->wait(), $body, $error); + } + + #[Test] + #[DataProvider('responses')] + public function psr18Client(string $path, string $body, ?string $error): void + { + $inner = new Client(['handler' => HandlerStack::create(new MockHandler([new Response(200, ['Content-Type' => 'application/json'], $body)]))]); + $client = new OpenApiVerifyingClient($inner, $this->verifier()); + + $this->send(fn () => $client->sendRequest(new Request('GET', 'http://localhost' . $path)), $body, $error); + } +}