diff --git a/README.md b/README.md index 2508f5d..a80b2fb 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,18 @@ class UsersTest extends TestCase } ``` +### PSR-15 middleware +Any PSR-15 stack (Slim, Mezzio and others) can verify its responses with the middleware. A response that does not +match throws `OpenApiSchemaMismatchException`. An optional callback is called with the exception and the response +first, to report the mismatch in a way of your own. + +```php +use Radebatz\OpenApi\Verifier\Adapters\Psr15\OpenApiVerifierMiddleware; +use Radebatz\OpenApi\Verifier\OpenApiVerifier; + +$app->add(new OpenApiVerifierMiddleware(OpenApiVerifier::fromSpecification('openapi.yaml'))); +``` + ### Laravel adapter The adapter will try to resolve the specification dynamically in this order: * filename passed into `registerOpenApiVerifier()` @@ -171,7 +183,7 @@ The adapter will try to resolve the specification dynamically in this order: Simplest way is to register the verifier in the `Tests\Functional\BaseTestCase`. -**Attention**: In order to be able to resolve routes with placeholders (i.e. something like `/user/{id}`) it is required to register the verifier **before** the routing middleware. +The adapter adds the PSR-15 middleware below, and a mismatch fails the test with the error summary and the response body. ```php registerOpenApiVerifier($app, __DIR__ . '/../specifications/users.yaml'); $app->addRoutingMiddleware(); diff --git a/composer.json b/composer.json index 0981971..6783bbd 100644 --- a/composer.json +++ b/composer.json @@ -58,6 +58,7 @@ "nyholm/psr7-server": "^1.0", "psr/http-client": "^1.0", "psr/http-message": "^2.0", + "psr/http-server-middleware": "^1.0", "symfony/psr-http-message-bridge": "^6.0 || ^7.0" }, "require-dev": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 49af4a1..db4e533 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -6,54 +6,12 @@ parameters: count: 1 path: src/Adapters/AbstractOpenApiResponseVerifier.php - - - message: '#^Call to method failSchemaMismatch\(\) on an unknown class Radebatz\\OpenApi\\Verifier\\VerifiesOpenApi\.$#' - identifier: class.notFound - count: 1 - path: src/Adapters/Laravel/OpenApiVerifierMiddleware.php - - - - message: '#^Call to method verifyOpenApi\(\) on an unknown class Radebatz\\OpenApi\\Verifier\\VerifiesOpenApi\.$#' - identifier: class.notFound - count: 1 - path: src/Adapters/Laravel/OpenApiVerifierMiddleware.php - - - - message: '#^PHPDoc tag @var for variable \$verifier has invalid type Radebatz\\OpenApi\\Verifier\\VerifiesOpenApi\.$#' - identifier: varTag.trait - count: 1 - path: src/Adapters/Laravel/OpenApiVerifierMiddleware.php - - - - message: '#^Call to method failSchemaMismatch\(\) on an unknown class Radebatz\\OpenApi\\Verifier\\VerifiesOpenApi\.$#' - identifier: class.notFound - count: 1 - path: src/Adapters/Slim/OpenApiVerifierMiddleware.php - - - - message: '#^Call to method verifyOpenApi\(\) on an unknown class Radebatz\\OpenApi\\Verifier\\VerifiesOpenApi\.$#' - identifier: class.notFound - count: 1 - path: src/Adapters/Slim/OpenApiVerifierMiddleware.php - - - - message: '#^PHPDoc tag @var for variable \$verifier has invalid type Radebatz\\OpenApi\\Verifier\\VerifiesOpenApi\.$#' - identifier: varTag.trait - count: 1 - path: src/Adapters/Slim/OpenApiVerifierMiddleware.php - - message: '#^Negated boolean expression is always false\.$#' identifier: booleanNot.alwaysFalse count: 1 path: tests/Adapters/LaravelAdapterTest.php - - - message: '#^Access to an undefined property Radebatz\\OpenApi\\Verifier\\Tests\\Adapters\\SlimAdapterTest\:\:\$app\.$#' - identifier: property.notFound - count: 1 - path: tests/Adapters/SlimAdapterTest.php - - message: '#^Right side of \|\| is always false\.$#' identifier: booleanOr.rightAlwaysFalse diff --git a/src/Adapters/AbstractOpenApiResponseVerifier.php b/src/Adapters/AbstractOpenApiResponseVerifier.php index 2fdf6b7..0a4d22e 100644 --- a/src/Adapters/AbstractOpenApiResponseVerifier.php +++ b/src/Adapters/AbstractOpenApiResponseVerifier.php @@ -3,11 +3,10 @@ namespace Radebatz\OpenApi\Verifier\Adapters; use OpenApi\Generator; -use Radebatz\OpenApi\Verifier\OpenApiSpecificationLoader; trait AbstractOpenApiResponseVerifier { - protected ?string $openapiSpecification; + protected object|string|null $openapiSpecification = null; protected function prepareOpenApiSpecificationLoader(string $srcDir, ?string $specification = null) { @@ -38,9 +37,8 @@ protected function prepareOpenApiSpecificationLoader(string $srcDir, ?string $sp if (!$specificationLoader) { $appRoot = $appRoot ?: $this->getAppRoot(); - // the generated document is an object, which the ?string property cannot hold $openApi = (new Generator())->generate([$appRoot . '/' . $srcDir]); - $this->openapiSpecificationLoader = new OpenApiSpecificationLoader(json_decode($openApi->toJson())); + $this->openapiSpecification = json_decode($openApi->toJson()); } } diff --git a/src/Adapters/Laravel/OpenApiResponseVerifier.php b/src/Adapters/Laravel/OpenApiResponseVerifier.php index 48e93ea..2b7833a 100644 --- a/src/Adapters/Laravel/OpenApiResponseVerifier.php +++ b/src/Adapters/Laravel/OpenApiResponseVerifier.php @@ -4,7 +4,9 @@ use Illuminate\Contracts\Http\Kernel; use Illuminate\Foundation\Application; +use Psr\Http\Message\ResponseInterface; use Radebatz\OpenApi\Verifier\Adapters\AbstractOpenApiResponseVerifier; +use Radebatz\OpenApi\Verifier\OpenApiSchemaMismatchException; use Radebatz\OpenApi\Verifier\VerifiesOpenApi; /** @@ -14,14 +16,18 @@ trait OpenApiResponseVerifier { use AbstractOpenApiResponseVerifier, VerifiesOpenApi; - public function registerOpenApiVerifier(?Application $app = null, ?string $specification = null) + public function registerOpenApiVerifier(?Application $app = null, ?string $specification = null): void { $app = $app ?: $this->app; $this->prepareOpenApiSpecificationLoader('app', $specification); if ($this->getOpenApiSpecificationLoader()) { - $app->instance(OpenApiVerifierMiddleware::OPENAPI_VERFIER_CONTAINER_KEY, $this); + // the kernel resolves the middleware by class name, for `handle()` and `terminate()` alike + $app->instance(OpenApiVerifierMiddleware::class, new OpenApiVerifierMiddleware( + $this->getOpenApiVerifier(), + fn (OpenApiSchemaMismatchException $oasme, ResponseInterface $response) => $this->failSchemaMismatch($oasme, $response) + )); $app[Kernel::class]->pushMiddleware(OpenApiVerifierMiddleware::class); } } diff --git a/src/Adapters/Laravel/OpenApiVerifierMiddleware.php b/src/Adapters/Laravel/OpenApiVerifierMiddleware.php index b23eafd..767ffb4 100644 --- a/src/Adapters/Laravel/OpenApiVerifierMiddleware.php +++ b/src/Adapters/Laravel/OpenApiVerifierMiddleware.php @@ -2,43 +2,51 @@ namespace Radebatz\OpenApi\Verifier\Adapters\Laravel; -use Radebatz\OpenApi\Verifier\Adapters\PSR17Middleware; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; use Radebatz\OpenApi\Verifier\OpenApiSchemaMismatchException; -use Radebatz\OpenApi\Verifier\VerifiesOpenApi; +use Radebatz\OpenApi\Verifier\OpenApiVerifier; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; /** - * Terminating middleware to verify OpenApi responses,. + * Terminating middleware verifying every response against the specification. + * + * Laravel is not PSR-15, so the request and response are converted to PSR-7 first. The route's + * uri is passed as the path template, since Laravel has already matched it. */ -class OpenApiVerifierMiddleware extends PSR17Middleware +final class OpenApiVerifierMiddleware { - public const OPENAPI_VERFIER_CONTAINER_KEY = 'openapi-verifier'; + private readonly PsrHttpFactory $psrHttpFactory; + + /** + * @param (\Closure(OpenApiSchemaMismatchException, ResponseInterface): void)|null $onMismatch + */ + public function __construct( + private readonly OpenApiVerifier $verifier, + private readonly ?\Closure $onMismatch = null, + ) { + $psr17Factory = new Psr17Factory(); + $this->psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); + } public function handle($request, \Closure $next) { return $next($request); } - public function terminate($request, $response) + public function terminate($request, $response): void { - /** @var VerifiesOpenApi $verifier */ - $verifier = app(OpenApiVerifierMiddleware::OPENAPI_VERFIER_CONTAINER_KEY); + $psrRequest = $this->psrHttpFactory->createRequest($request); + $psrResponse = $this->psrHttpFactory->createResponse($response); try { - $psrRequest = $this->psrHttpFactory->createRequest($request); - $psrResponse = $this->psrHttpFactory->createResponse($response); - - $routePath = null; - if ($route = $request->route()) { - $routePath = $route->uri; - } - - $verifier->verifyOpenApi($psrRequest, $psrResponse, $routePath); + $this->verifier->verify($psrRequest, $psrResponse, $request->route()?->uri); } catch (OpenApiSchemaMismatchException $oasme) { - $verifier->failSchemaMismatch($oasme, $psrResponse); + if ($this->onMismatch instanceof \Closure) { + ($this->onMismatch)($oasme, $psrResponse); + } throw $oasme; } - - return $response; } } diff --git a/src/Adapters/PSR17Middleware.php b/src/Adapters/PSR17Middleware.php deleted file mode 100644 index 32d7463..0000000 --- a/src/Adapters/PSR17Middleware.php +++ /dev/null @@ -1,17 +0,0 @@ -psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); - } -} diff --git a/src/Adapters/Psr15/OpenApiVerifierMiddleware.php b/src/Adapters/Psr15/OpenApiVerifierMiddleware.php new file mode 100644 index 0000000..f562792 --- /dev/null +++ b/src/Adapters/Psr15/OpenApiVerifierMiddleware.php @@ -0,0 +1,45 @@ +handle($request); + + try { + $this->verifier->verify($request, $response); + } catch (OpenApiSchemaMismatchException $oasme) { + if ($this->onMismatch instanceof \Closure) { + ($this->onMismatch)($oasme, $response); + } + + throw $oasme; + } + + return $response; + } +} diff --git a/src/Adapters/Slim/OpenApiResponseVerifier.php b/src/Adapters/Slim/OpenApiResponseVerifier.php index ed83570..e74919a 100644 --- a/src/Adapters/Slim/OpenApiResponseVerifier.php +++ b/src/Adapters/Slim/OpenApiResponseVerifier.php @@ -2,7 +2,10 @@ namespace Radebatz\OpenApi\Verifier\Adapters\Slim; +use Psr\Http\Message\ResponseInterface; use Radebatz\OpenApi\Verifier\Adapters\AbstractOpenApiResponseVerifier; +use Radebatz\OpenApi\Verifier\Adapters\Psr15\OpenApiVerifierMiddleware; +use Radebatz\OpenApi\Verifier\OpenApiSchemaMismatchException; use Radebatz\OpenApi\Verifier\VerifiesOpenApi; use Slim\App; @@ -10,27 +13,15 @@ trait OpenApiResponseVerifier { use AbstractOpenApiResponseVerifier, VerifiesOpenApi; - public function registerOpenApiVerifier(?App $app, ?string $specification = null) + public function registerOpenApiVerifier(App $app, ?string $specification = null): void { - /** @var App $app */ - $app = $app ?: $this->app; - $this->prepareOpenApiSpecificationLoader('src', $specification); if ($this->getOpenApiSpecificationLoader()) { - $container = $app->getContainer(); - - // deal with different container implementations - if ($container instanceof \ArrayAccess) { - $container[OpenApiVerifierMiddleware::OPENAPI_VERFIER_CONTAINER_KEY] = $this; - } elseif (method_exists($container, 'set')) { - $container->set(OpenApiVerifierMiddleware::OPENAPI_VERFIER_CONTAINER_KEY, $this); - } elseif (method_exists($container, 'add')) { - $container->add(OpenApiVerifierMiddleware::OPENAPI_VERFIER_CONTAINER_KEY, $this); - } else { - throw new \RuntimeException('Unusable container'); - } - $app->add(new OpenApiVerifierMiddleware($container)); + $app->add(new OpenApiVerifierMiddleware( + $this->getOpenApiVerifier(), + fn (OpenApiSchemaMismatchException $oasme, ResponseInterface $response) => $this->failSchemaMismatch($oasme, $response) + )); } } } diff --git a/src/Adapters/Slim/OpenApiVerifierMiddleware.php b/src/Adapters/Slim/OpenApiVerifierMiddleware.php deleted file mode 100644 index f5c226e..0000000 --- a/src/Adapters/Slim/OpenApiVerifierMiddleware.php +++ /dev/null @@ -1,45 +0,0 @@ -container = $container; - } - - public function __invoke($request, $response, $next = null) - { - $response = $next ? $next($request, $response) : $response; - $response = ($response instanceof RequestHandlerInterface) ? $response->handle($request) : $response; - - $routePath = null; - if (($route = RouteContext::fromRequest($request)->getRoute()) instanceof \Slim\Interfaces\RouteInterface) { - $routePath = $route->getPattern(); - } - - /** @var VerifiesOpenApi $verifier */ - $verifier = $this->container->get(OpenApiVerifierMiddleware::OPENAPI_VERFIER_CONTAINER_KEY); - - try { - $verifier->verifyOpenApi($request, $response, $routePath); - } catch (OpenApiSchemaMismatchException $oasme) { - $verifier->failSchemaMismatch($oasme, $response); - - throw $oasme; - } - - return $response; - } -} diff --git a/src/VerifiesOpenApi.php b/src/VerifiesOpenApi.php index 9c08fda..dd01269 100644 --- a/src/VerifiesOpenApi.php +++ b/src/VerifiesOpenApi.php @@ -58,20 +58,20 @@ public function getOpenApiSpecificationLoader(): ?OpenApiSpecificationLoader return $this->openapiSpecificationLoader; } - public function failSchemaMismatch(OpenApiSchemaMismatchException $oasme, ResponseInterface $response) + /** + * Fail the current test with the mismatch, its error summary and the response body. + * + * Does nothing outside a PHPUnit test case. + */ + public function failSchemaMismatch(OpenApiSchemaMismatchException $oasme, ResponseInterface $response): void { if ($this instanceof TestCase) { - $this->fail(sprintf( - '%s:%s%s%s%s%s%s%s', - $oasme->getMessage(), - PHP_EOL, - $oasme->getErrorSummary(), - PHP_EOL, + $this->fail(implode(PHP_EOL, [ + $oasme->getMessage() . ':', + (string) $oasme->getErrorSummary(), '', - PHP_EOL, (string) $response->getBody(), - PHP_EOL - )); + ])); } } } diff --git a/tests/Adapters/Psr15AdapterTest.php b/tests/Adapters/Psr15AdapterTest.php new file mode 100644 index 0000000..8c9219c --- /dev/null +++ b/tests/Adapters/Psr15AdapterTest.php @@ -0,0 +1,70 @@ + 'application/json'], $this->body); + } + }; + + return $middleware->process(new ServerRequest('GET', '/user/1'), $handler); + } + + #[Test] + public function passesAMatchingResponseOn(): void + { + $body = '{"data":{"id":1,"name":"joe","email":"joe@cool.com"}}'; + + $this->assertSame($body, (string) $this->handle($body)->getBody()); + } + + #[Test] + public function throwsOnAMismatch(): void + { + $this->expectException(OpenApiSchemaMismatchException::class); + + $this->handle('{"data":{"id":1,"name":"joe"}}'); + } + + #[Test] + public function callsOnMismatchFirst(): void + { + $seen = null; + + try { + $this->handle('{"data":{"id":1,"name":"joe"}}', function (OpenApiSchemaMismatchException $oasme) use (&$seen): void { + $seen = $oasme->getErrorSummary(); + }); + $this->fail('No exception thrown'); + } catch (OpenApiSchemaMismatchException) { + } + + $this->assertStringContainsString('The property email is required', (string) $seen); + } +} diff --git a/tests/Adapters/SlimAdapterTest.php b/tests/Adapters/SlimAdapterTest.php index 5ccf97a..32bdca1 100644 --- a/tests/Adapters/SlimAdapterTest.php +++ b/tests/Adapters/SlimAdapterTest.php @@ -58,7 +58,17 @@ public function failVerificationUser(): void $this->runApp('GET', '/user/1', valid: false); } - protected function runApp(string $requestMethod, string $requestUri, bool $valid) + #[Test] + public function verifierRegisteredAfterRouting(): void + { + // the path is matched against the specification's templates, so the order no longer matters + $this->expectException(AssertionFailedError::class); + $this->expectExceptionMessageMatches('/required - The property email is required/'); + + $this->runApp('GET', '/user/1', valid: false, verifierFirst: false); + } + + protected function runApp(string $requestMethod, string $requestUri, bool $valid, bool $verifierFirst = true) { $request = (new Psr17Factory())->createServerRequest($requestMethod, $requestUri); @@ -89,9 +99,13 @@ protected function runApp(string $requestMethod, string $requestUri, bool $valid }); } - // register OpenApi verifier BEFORE routing middleware - $this->registerOpenApiVerifier($app, __DIR__ . '/../specifications/users.yaml'); - $app->addRoutingMiddleware(); + if ($verifierFirst) { + $this->registerOpenApiVerifier($app, __DIR__ . '/../specifications/users.yaml'); + $app->addRoutingMiddleware(); + } else { + $app->addRoutingMiddleware(); + $this->registerOpenApiVerifier($app, __DIR__ . '/../specifications/users.yaml'); + } return $app->handle($request); }