Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand Down
37 changes: 37 additions & 0 deletions src/Adapters/Guzzle/OpenApiVerifierMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Verifier\Adapters\Guzzle;

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Radebatz\OpenApi\Verifier\OpenApiVerifier;

/**
* Guzzle handler middleware verifying every response against the specification.
*
* A mismatch rejects the response's promise, so a synchronous request throws
* `OpenApiSchemaMismatchException`.
*
* ```php
* $stack = HandlerStack::create();
* $stack->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;
}
);
}
}
32 changes: 32 additions & 0 deletions src/Adapters/Psr18/OpenApiVerifyingClient.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Verifier\Adapters\Psr18;

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Radebatz\OpenApi\Verifier\OpenApiVerifier;

/**
* A PSR-18 client verifying every response it receives against the specification.
*
* Throws `OpenApiSchemaMismatchException` on a mismatch, which is not a PSR-18 client exception:
* the request was sent, the response just does not match.
*/
final class OpenApiVerifyingClient implements ClientInterface
{
public function __construct(
private readonly ClientInterface $client,
private readonly OpenApiVerifier $verifier,
) {
}

public function sendRequest(RequestInterface $request): ResponseInterface
{
$response = $this->client->sendRequest($request);

$this->verifier->verify($request, $response);

return $response;
}
}
9 changes: 8 additions & 1 deletion src/OpenApiVerifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
Expand Down
86 changes: 86 additions & 0 deletions tests/Adapters/ClientAdapterTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php declare(strict_types=1);

namespace Radebatz\OpenApi\Verifier\Tests\Adapters;

use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Request;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Radebatz\OpenApi\Verifier\Adapters\Guzzle\OpenApiVerifierMiddleware;
use Radebatz\OpenApi\Verifier\Adapters\Psr18\OpenApiVerifyingClient;
use Radebatz\OpenApi\Verifier\OpenApiSchemaMismatchException;
use Radebatz\OpenApi\Verifier\OpenApiVerifier;

class ClientAdapterTest extends TestCase
{
protected function verifier(): OpenApiVerifier
{
return OpenApiVerifier::fromSpecification(__DIR__ . '/../specifications/users.yaml');
}

public static function responses(): iterable
{
// path, body, expected error (null: valid)
yield 'users' => ['/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);
}
}
Loading