From 3640c6ab5263998b718d697e8422d19f0579ead1 Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Tue, 13 Oct 2026 17:25:52 +1300 Subject: [PATCH] Raise phpstan to level 7, with no baseline Every finding is fixed in the code, so `phpstan-baseline.neon` goes, and the config is `phpstan.neon.dist` as in the sibling projects. The framework checks in the adapter tests go too, since both frameworks are always installed. --- phpstan-baseline.neon | 31 ------------ phpstan.neon => phpstan.neon.dist | 5 +- .../AbstractOpenApiResponseVerifier.php | 50 +++++++++---------- .../Laravel/OpenApiVerifierMiddleware.php | 12 +++-- src/Adapters/Slim/OpenApiResponseVerifier.php | 5 ++ src/OpenApiSpecificationLoader.php | 24 +++++---- src/Schema/OpenApi30Translator.php | 3 +- .../AbstractOpenApiResponseVerifierTest.php | 8 +-- tests/Adapters/ClientAdapterTest.php | 3 ++ tests/Adapters/LaravelAdapterTest.php | 45 ++++++++--------- tests/Adapters/LaravelTestCase.php | 13 ----- tests/Adapters/SlimAdapterTest.php | 10 +--- tests/OpenApiSchemaMismatchExceptionTest.php | 6 +++ tests/OpenApiSpecificationLoaderTest.php | 17 +++++-- tests/OpenApiVerifierTest.php | 24 +++++++-- tests/VerifiesOpenApiTest.php | 9 ++-- 16 files changed, 127 insertions(+), 138 deletions(-) delete mode 100644 phpstan-baseline.neon rename phpstan.neon => phpstan.neon.dist (77%) delete mode 100644 tests/Adapters/LaravelTestCase.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index db4e533..0000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,31 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Ternary operator condition is always false\.$#' - identifier: ternary.alwaysFalse - count: 1 - path: src/Adapters/AbstractOpenApiResponseVerifier.php - - - - message: '#^Negated boolean expression is always false\.$#' - identifier: booleanNot.alwaysFalse - count: 1 - path: tests/Adapters/LaravelAdapterTest.php - - - - message: '#^Right side of \|\| is always false\.$#' - identifier: booleanOr.rightAlwaysFalse - count: 1 - path: tests/Adapters/SlimAdapterTest.php - - - - message: '#^Call to function is_object\(\) with Radebatz\\OpenApi\\Verifier\\OpenApiSpecificationLoader will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: tests/OpenApiSpecificationLoaderTest.php - - - - message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertTrue\(\) with true will always evaluate to true\.$#' - identifier: method.alreadyNarrowedType - count: 1 - path: tests/OpenApiSpecificationLoaderTest.php diff --git a/phpstan.neon b/phpstan.neon.dist similarity index 77% rename from phpstan.neon rename to phpstan.neon.dist index 135b750..6f7f7f4 100644 --- a/phpstan.neon +++ b/phpstan.neon.dist @@ -1,9 +1,6 @@ -includes: - - phpstan-baseline.neon - parameters: phpVersion: 80200 - level: 5 + level: 7 paths: - src - tests diff --git a/src/Adapters/AbstractOpenApiResponseVerifier.php b/src/Adapters/AbstractOpenApiResponseVerifier.php index 0a4d22e..d3ac2df 100644 --- a/src/Adapters/AbstractOpenApiResponseVerifier.php +++ b/src/Adapters/AbstractOpenApiResponseVerifier.php @@ -8,44 +8,44 @@ trait AbstractOpenApiResponseVerifier { protected object|string|null $openapiSpecification = null; - protected function prepareOpenApiSpecificationLoader(string $srcDir, ?string $specification = null) + /** + * Find the specification: the given one, then `tests/openapi.json` or `tests/openapi.yaml`, + * then one generated from `$srcDir`. + */ + protected function prepareOpenApiSpecificationLoader(string $srcDir, ?string $specification = null): void { - $appRoot = null; - if ($specification) { $this->openapiSpecification = $specification; } - // try loader - $specificationLoader = $this->getOpenApiSpecificationLoader(); + if ($this->getOpenApiSpecificationLoader()) { + return; + } + + $appRoot = $this->getAppRoot(); - if (!$specificationLoader) { - $appRoot = $appRoot ?: $this->getAppRoot(); + foreach (['openapi.json', 'openapi.yaml'] as $specfile) { + if (file_exists($filename = $appRoot . '/tests/' . $specfile)) { + $this->openapiSpecification = $filename; - // try some default filenames - foreach (['openapi.json', 'openapi.yaml'] as $specfile) { - if (file_exists($specification = $appRoot . '/tests/' . $specfile)) { - $this->openapiSpecification = $specification; - break; - } + return; } - - // try loader again - $specificationLoader = $this->getOpenApiSpecificationLoader(); } - if (!$specificationLoader) { - $appRoot = $appRoot ?: $this->getAppRoot(); - - $openApi = (new Generator())->generate([$appRoot . '/' . $srcDir]); - $this->openapiSpecification = json_decode($openApi->toJson()); - } + $openApi = (new Generator())->generate([$appRoot . '/' . $srcDir]); + $this->openapiSpecification = json_decode($openApi->toJson()); } - protected function getAppRoot() + /** + * The project root: three levels above Composer's `ClassLoader`, in `vendor/composer/`. + */ + protected function getAppRoot(): string { - $rc = new \ReflectionClass('\Composer\Autoload\ClassLoader'); + $classLoader = (new \ReflectionClass(\Composer\Autoload\ClassLoader::class))->getFileName(); + if (false === $classLoader) { + throw new \RuntimeException('Cannot locate the project root: Composer\'s ClassLoader has no file'); + } - return dirname(dirname(dirname($rc->getFileName()))); + return dirname($classLoader, 3); } } diff --git a/src/Adapters/Laravel/OpenApiVerifierMiddleware.php b/src/Adapters/Laravel/OpenApiVerifierMiddleware.php index 767ffb4..d31c979 100644 --- a/src/Adapters/Laravel/OpenApiVerifierMiddleware.php +++ b/src/Adapters/Laravel/OpenApiVerifierMiddleware.php @@ -2,11 +2,14 @@ namespace Radebatz\OpenApi\Verifier\Adapters\Laravel; +use Illuminate\Http\Request; +use Illuminate\Routing\Route; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ResponseInterface; use Radebatz\OpenApi\Verifier\OpenApiSchemaMismatchException; use Radebatz\OpenApi\Verifier\OpenApiVerifier; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Component\HttpFoundation\Response; /** * Terminating middleware verifying every response against the specification. @@ -29,18 +32,21 @@ public function __construct( $this->psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory); } - public function handle($request, \Closure $next) + public function handle(Request $request, \Closure $next): Response { return $next($request); } - public function terminate($request, $response): void + public function terminate(Request $request, Response $response): void { $psrRequest = $this->psrHttpFactory->createRequest($request); $psrResponse = $this->psrHttpFactory->createResponse($response); try { - $this->verifier->verify($psrRequest, $psrResponse, $request->route()?->uri); + // what `route()` calls; its docblock claims a route is always there, but a request that + // matched none, a 404 for example, has none + $route = ($request->getRouteResolver())(); + $this->verifier->verify($psrRequest, $psrResponse, $route instanceof Route ? $route->uri() : null); } catch (OpenApiSchemaMismatchException $oasme) { if ($this->onMismatch instanceof \Closure) { ($this->onMismatch)($oasme, $psrResponse); diff --git a/src/Adapters/Slim/OpenApiResponseVerifier.php b/src/Adapters/Slim/OpenApiResponseVerifier.php index e74919a..f5522c7 100644 --- a/src/Adapters/Slim/OpenApiResponseVerifier.php +++ b/src/Adapters/Slim/OpenApiResponseVerifier.php @@ -13,6 +13,11 @@ trait OpenApiResponseVerifier { use AbstractOpenApiResponseVerifier, VerifiesOpenApi; + /** + * @template TContainer of \Psr\Container\ContainerInterface|null + * + * @param App $app + */ public function registerOpenApiVerifier(App $app, ?string $specification = null): void { $this->prepareOpenApiSpecificationLoader('src', $specification); diff --git a/src/OpenApiSpecificationLoader.php b/src/OpenApiSpecificationLoader.php index be652b7..9180be4 100644 --- a/src/OpenApiSpecificationLoader.php +++ b/src/OpenApiSpecificationLoader.php @@ -7,22 +7,21 @@ class OpenApiSpecificationLoader { - protected $filename; - protected $specification; + protected \stdClass $specification; /** - * @param $specification object|string The specification object or filename + * @param mixed $specification the specification object, or the filename of a JSON or YAML document */ - public function __construct($specification) + public function __construct(mixed $specification) { $this->specification = $this->resolveSpecification($specification); } - protected function resolveSpecification($specification) + protected function resolveSpecification(mixed $specification): \stdClass { $resolved = null; if (is_string($specification)) { - if (false !== strpos($specification, '.yaml') || false !== strpos($specification, '.yml')) { + if (preg_match('/\.ya?ml$/i', $specification)) { try { $resolved = (object) Yaml::parseFile($specification, Yaml::PARSE_OBJECT_FOR_MAP); } catch (ParseException $parseException) { @@ -32,15 +31,18 @@ protected function resolveSpecification($specification) $parseException ); } - } elseif (file_exists($specification)) { - $resolved = json_decode(file_get_contents($specification)); + } elseif (file_exists($specification) && false !== ($json = file_get_contents($specification))) { + $resolved = json_decode($json); } - if (!$resolved) { + if (!$resolved instanceof \stdClass) { throw new \InvalidArgumentException(sprintf('Could not load specification: %s', $specification)); } - } elseif (is_object($specification)) { + } elseif ($specification instanceof \stdClass) { $resolved = $specification; + } elseif (is_object($specification)) { + // any other object, as plain data + $resolved = json_decode((string) json_encode($specification)); } else { throw new \InvalidArgumentException('Invalid specification'); } @@ -78,7 +80,7 @@ public function getResponseSchemaFor(string $method, string $path, int $statusCo } // copy, so the components do not end up inside the specification itself - $schema = is_object($schema) ? clone $schema : (object) $schema; + $schema = $schema instanceof \stdClass ? clone $schema : (object) (array) $schema; $schema->components = $this->property($this->specification, 'components') ?? new \stdClass(); return $schema; diff --git a/src/Schema/OpenApi30Translator.php b/src/Schema/OpenApi30Translator.php index 69702d4..2f924eb 100644 --- a/src/Schema/OpenApi30Translator.php +++ b/src/Schema/OpenApi30Translator.php @@ -47,7 +47,8 @@ private function translateNode(mixed $node): mixed return array_map($this->translateNode(...), $node); } - if (!is_object($node)) { + // the copy comes from `json_decode()`, so every object in it is a `stdClass` + if (!$node instanceof \stdClass) { return $node; } diff --git a/tests/Adapters/AbstractOpenApiResponseVerifierTest.php b/tests/Adapters/AbstractOpenApiResponseVerifierTest.php index 0c1060f..ed54b90 100644 --- a/tests/Adapters/AbstractOpenApiResponseVerifierTest.php +++ b/tests/Adapters/AbstractOpenApiResponseVerifierTest.php @@ -25,7 +25,7 @@ protected function tearDown(): void } } - protected function getAppRoot() + protected function getAppRoot(): string { return $this->appRoot; } @@ -42,7 +42,7 @@ protected function assertVerifiesUsers(): void } #[Test] - public function explicitSpecification() + public function explicitSpecification(): void { $this->appRoot = '/does/not/exist'; @@ -52,7 +52,7 @@ public function explicitSpecification() } #[Test] - public function discoveredSpecification() + public function discoveredSpecification(): void { $this->tmpDir = sys_get_temp_dir() . '/openapi-verifier-' . uniqid(); mkdir($this->tmpDir . '/tests', 0777, true); @@ -65,7 +65,7 @@ public function discoveredSpecification() } #[Test] - public function generatedSpecification() + public function generatedSpecification(): void { // no tests/openapi.{json,yaml} under tests/, so this generates from tests/Fixtures $this->appRoot = dirname(__DIR__); diff --git a/tests/Adapters/ClientAdapterTest.php b/tests/Adapters/ClientAdapterTest.php index b569f36..73ae048 100644 --- a/tests/Adapters/ClientAdapterTest.php +++ b/tests/Adapters/ClientAdapterTest.php @@ -23,6 +23,9 @@ protected function verifier(): OpenApiVerifier return OpenApiVerifier::fromSpecification(__DIR__ . '/../specifications/users.yaml'); } + /** + * @return iterable> + */ public static function responses(): iterable { // path, body, expected error (null: valid) diff --git a/tests/Adapters/LaravelAdapterTest.php b/tests/Adapters/LaravelAdapterTest.php index bc477db..b2a2d32 100644 --- a/tests/Adapters/LaravelAdapterTest.php +++ b/tests/Adapters/LaravelAdapterTest.php @@ -4,21 +4,18 @@ use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Application; +use Illuminate\Foundation\Testing\TestCase; use Illuminate\Support\Facades\Facade; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\Test; use Radebatz\OpenApi\Verifier\Adapters\Laravel\OpenApiResponseVerifier; -class LaravelAdapterTest extends LaravelTestCase +class LaravelAdapterTest extends TestCase { use OpenApiResponseVerifier; protected function setUp(): void { - if (!class_exists('\\Illuminate\\Foundation\\Application')) { - $this->markTestSkipped('Laravel not installed.'); - } - parent::setUp(); $this->registerOpenApiVerifier(null, __DIR__ . '/../specifications/users.yaml'); @@ -27,7 +24,7 @@ protected function setUp(): void #[Test] public function passVerificationUsers(): void { - $this->createApplication() + $this->app ->get('router')->get('/users', function () { return '{"data":[{"id":1,"name":"joe","email":"joe@cool.com"}]}'; }); @@ -40,7 +37,7 @@ public function passVerificationUsers(): void #[Test] public function failVerificationUsers(): void { - $this->createApplication() + $this->app ->get('router')->get('/users', function () { return '{"data":[{"id":1,"email":"joe@cool.com"}]}'; }); @@ -53,7 +50,7 @@ public function failVerificationUsers(): void #[Test] public function passVerificationUser(): void { - $this->createApplication() + $this->app ->get('router')->get('/user/{id}', function () { return '{"data":{"id":1,"name":"joe","email":"joe@cool.com"}}'; }); @@ -66,7 +63,7 @@ public function passVerificationUser(): void #[Test] public function failVerificationUser(): void { - $this->createApplication() + $this->app ->get('router')->get('/user/{id}', function () { return '{"data":{"id":1,"name":"joe"}}'; }); @@ -77,22 +74,20 @@ public function failVerificationUser(): void $this->get('/user/1'); } - /** @inheritdoc */ - public function createApplication() + /** + * Laravel's `setUp()` calls this and keeps the result in `$this->app`. + */ + public function createApplication(): Application { - if (!$this->app) { - /** @var Application $app */ - $app = require __DIR__ . '/../../vendor/laravel/laravel/bootstrap/app.php'; - $app->make(Kernel::class)->bootstrap(); - $app['config']->set([ - 'app.environment' => 'local', - 'app.debug' => true, - ]); - Facade::setFacadeApplication($app); - - $this->app = $app; - } - - return $this->app; + /** @var Application $app */ + $app = require __DIR__ . '/../../vendor/laravel/laravel/bootstrap/app.php'; + $app->make(Kernel::class)->bootstrap(); + $app['config']->set([ + 'app.environment' => 'local', + 'app.debug' => true, + ]); + Facade::setFacadeApplication($app); + + return $app; } } diff --git a/tests/Adapters/LaravelTestCase.php b/tests/Adapters/LaravelTestCase.php deleted file mode 100644 index 446348c..0000000 --- a/tests/Adapters/LaravelTestCase.php +++ /dev/null @@ -1,13 +0,0 @@ -markTestSkipped('Slim not installed.'); - } - } - #[Test] public function passVerificationUsers(): void { @@ -68,7 +60,7 @@ public function verifierRegisteredAfterRouting(): void $this->runApp('GET', '/user/1', valid: false, verifierFirst: false); } - protected function runApp(string $requestMethod, string $requestUri, bool $valid, bool $verifierFirst = true) + protected function runApp(string $requestMethod, string $requestUri, bool $valid, bool $verifierFirst = true): ResponseInterface { $request = (new Psr17Factory())->createServerRequest($requestMethod, $requestUri); diff --git a/tests/OpenApiSchemaMismatchExceptionTest.php b/tests/OpenApiSchemaMismatchExceptionTest.php index 237ddd5..69f2ebe 100644 --- a/tests/OpenApiSchemaMismatchExceptionTest.php +++ b/tests/OpenApiSchemaMismatchExceptionTest.php @@ -18,6 +18,9 @@ public function getErrorsReturnsSetData(): void $this->assertSame($errors, (new OpenApiSchemaMismatchException())->setErrors($errors)->getErrors()); } + /** + * @return iterable> + */ public static function errors(): iterable { yield 'no errors' => [[], null]; @@ -70,6 +73,9 @@ public static function errors(): iterable ]; } + /** + * @param list $errors + */ #[Test] #[DataProvider('errors')] public function getErrorSummary(array $errors, ?string $expected): void diff --git a/tests/OpenApiSpecificationLoaderTest.php b/tests/OpenApiSpecificationLoaderTest.php index 97606c5..a83b9e1 100644 --- a/tests/OpenApiSpecificationLoaderTest.php +++ b/tests/OpenApiSpecificationLoaderTest.php @@ -9,7 +9,10 @@ class OpenApiSpecificationLoaderTest extends TestCase { - public static function specifications() + /** + * @return iterable> + */ + public static function specifications(): iterable { return [ [__DIR__ . '/specifications/users.json', true], @@ -24,7 +27,7 @@ public static function specifications() #[Test] #[DataProvider('specifications')] - public function loadSpecification($filename, $valid) + public function loadSpecification(mixed $filename, bool $valid): void { if (!$valid) { $this->expectException(\InvalidArgumentException::class); @@ -32,10 +35,14 @@ public function loadSpecification($filename, $valid) $specificationLoader = new OpenApiSpecificationLoader($filename); - $this->assertTrue(is_object($specificationLoader)); + // the fixtures are 3.0.0, and a document that does not say counts as 3.0.0 too + $this->assertSame('3.0.0', $specificationLoader->getOpenApiVersion()); } - public static function responseSchemas() + /** + * @return iterable> + */ + public static function responseSchemas(): iterable { return [ 'ucmethod-json-ok' => [__DIR__ . '/specifications/users.json', 'GET', '/users', 200, true], @@ -49,7 +56,7 @@ public static function responseSchemas() #[Test] #[DataProvider('responseSchemas')] - public function getResponseSchemaFor($specification, $method, $path, $statusCode, $valid) + public function getResponseSchemaFor(string $specification, string $method, string $path, int $statusCode, bool $valid): void { $specificationLoader = new OpenApiSpecificationLoader($specification); diff --git a/tests/OpenApiVerifierTest.php b/tests/OpenApiVerifierTest.php index d9f5761..f8c71ff 100644 --- a/tests/OpenApiVerifierTest.php +++ b/tests/OpenApiVerifierTest.php @@ -14,11 +14,18 @@ class OpenApiVerifierTest extends TestCase { + /** + * @param array $schema + * @return array + */ protected static function json(array $schema): array { return ['content' => ['application/json' => ['schema' => $schema]]]; } + /** + * @return array + */ protected static function requiring(string $property): array { return ['type' => 'object', 'required' => [$property]]; @@ -47,9 +54,12 @@ protected function verifier(): OpenApiVerifier ], ]; - return OpenApiVerifier::fromSpecification(json_decode(json_encode($specification))); + return OpenApiVerifier::fromSpecification(json_decode((string) json_encode($specification))); } + /** + * @return iterable> + */ public static function responses(): iterable { // path, status, body, content type, expected: true verified, false nothing to verify, null mismatch @@ -83,6 +93,9 @@ public function verifyResponseBody(string $path, int $statusCode, string $body, $this->assertSame($expected, $this->verifier()->verifyResponseBody('get', $path, $statusCode, $body, $contentType)); } + /** + * @return iterable> + */ public static function dialects(): iterable { $ref = ['$ref' => '#/components/schemas/thing']; @@ -110,6 +123,9 @@ public static function dialects(): iterable yield '3.1 reference' => ['3.1.0', $ref, '{"id":1}', true]; } + /** + * @param array $schema + */ #[Test] #[DataProvider('dialects')] public function verifyDialect(string $version, array $schema, string $body, ?bool $expected): void @@ -124,14 +140,14 @@ public function verifyDialect(string $version, array $schema, string $body, ?boo $this->expectException(OpenApiSchemaMismatchException::class); } - $verifier = OpenApiVerifier::fromSpecification(json_decode(json_encode($specification))); + $verifier = OpenApiVerifier::fromSpecification(json_decode((string) json_encode($specification))); $this->assertSame($expected, $verifier->verifyResponseBody('get', '/thing', 200, $body)); } #[Test] public function translationLeavesTheSpecificationAlone(): void { - $specification = json_decode(json_encode([ + $specification = json_decode((string) json_encode([ 'openapi' => '3.0.0', 'paths' => ['/thing' => ['get' => ['responses' => ['200' => self::json(['$ref' => '#/components/schemas/thing'])]]]], 'components' => ['schemas' => ['thing' => ['type' => 'string', 'nullable' => true]]], @@ -162,7 +178,7 @@ public function validate(mixed $data, \stdClass $schema, string $openApiVersion) } }; - $specification = json_decode(json_encode([ + $specification = json_decode((string) json_encode([ 'openapi' => '3.0.3', 'paths' => ['/thing' => ['get' => ['responses' => ['200' => self::json(['type' => 'string', 'nullable' => true])]]]], ])); diff --git a/tests/VerifiesOpenApiTest.php b/tests/VerifiesOpenApiTest.php index 08e37de..059c54e 100644 --- a/tests/VerifiesOpenApiTest.php +++ b/tests/VerifiesOpenApiTest.php @@ -15,7 +15,10 @@ class VerifiesOpenApiTest extends TestCase { use VerifiesOpenApi; - public static function responses() + /** + * @return iterable> + */ + public static function responses(): iterable { return [ 'users-invalid-json' => ['get', '/users', 200, 'xxx', false, false], @@ -29,7 +32,7 @@ public static function responses() #[Test] #[DataProvider('responses')] - public function verifyBody($method, $path, $statusCode, $content, $isValid, $isVerified) + public function verifyBody(string $method, string $path, int $statusCode, string $content, bool $isValid, bool $isVerified): void { if (!$isValid) { $this->expectException(OpenApiSchemaMismatchException::class); @@ -47,7 +50,7 @@ public function verifyBody($method, $path, $statusCode, $content, $isValid, $isV } #[Test] - public function verifyRequestResponse() + public function verifyRequestResponse(): void { $psr17Factory = new Psr17Factory(); $creator = new ServerRequestCreator($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);