From 19f20012aee79c84019e9b8444a07e6c7369dadf Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 16 Mar 2026 20:56:21 +0900 Subject: [PATCH 1/6] feat(laravel): add #[OpenApiSpec] attribute for declarative spec resolution Introduce a PHP Attribute that allows test classes and methods to declaratively specify which OpenAPI spec to validate against, instead of relying solely on openApiSpec() overrides or the config default. Resolution priority: method attribute > class attribute > openApiSpec() override > config default_spec. --- src/Laravel/OpenApiSpec.php | 15 ++++ src/Laravel/ValidatesOpenApiSchema.php | 28 ++++++- .../ValidatesOpenApiSchemaAttributeTest.php | 77 +++++++++++++++++++ .../ValidatesOpenApiSchemaDefaultSpecTest.php | 3 +- 4 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 src/Laravel/OpenApiSpec.php create mode 100644 tests/Unit/ValidatesOpenApiSchemaAttributeTest.php diff --git a/src/Laravel/OpenApiSpec.php b/src/Laravel/OpenApiSpec.php new file mode 100644 index 0000000..3da65af --- /dev/null +++ b/src/Laravel/OpenApiSpec.php @@ -0,0 +1,15 @@ +openApiSpec(); + $specName = $this->resolveOpenApiSpec(); if ($specName === '') { $this->fail( 'openApiSpec() must return a non-empty spec name, but an empty string was returned. ' - . 'Either override openApiSpec() in your test class, or set the "default_spec" key ' + . 'Either add #[OpenApiSpec(\'your-spec\')] to your test class or method, ' + . 'override openApiSpec() in your test class, or set the "default_spec" key ' . 'in config/openapi-contract-testing.php.', ); } @@ -83,6 +86,27 @@ protected function assertResponseMatchesOpenApiSchema( ); } + private function resolveOpenApiSpec(): string + { + // 1. Method-level #[OpenApiSpec] attribute + $methodName = $this->name(); // @phpstan-ignore method.notFound + $refMethod = new ReflectionMethod($this, $methodName); + $methodAttrs = $refMethod->getAttributes(OpenApiSpec::class); + if ($methodAttrs !== []) { + return $methodAttrs[0]->newInstance()->name; + } + + // 2. Class-level #[OpenApiSpec] attribute + $refClass = new ReflectionClass($this); + $classAttrs = $refClass->getAttributes(OpenApiSpec::class); + if ($classAttrs !== []) { + return $classAttrs[0]->newInstance()->name; + } + + // 3. openApiSpec() method override / config default + return $this->openApiSpec(); + } + /** @return null|array */ private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array { diff --git a/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php new file mode 100644 index 0000000..f7a5b40 --- /dev/null +++ b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php @@ -0,0 +1,77 @@ + [['id' => 1, 'name' => 'Fido', 'tag' => null]]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + #[OpenApiSpec('petstore-3.1')] + public function method_level_attribute_overrides_class_level(): void + { + $body = (string) json_encode( + ['data' => [['id' => 1, 'name' => 'Fido', 'tag' => null]]], + JSON_THROW_ON_ERROR, + ); + $response = $this->makeTestResponse($body, 200); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + + // Verify coverage was recorded under the method-level spec name + $covered = OpenApiCoverageTracker::getCovered(); + $this->assertArrayHasKey('petstore-3.1', $covered); + } +} diff --git a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php index 63ec93c..d12d6b7 100644 --- a/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php @@ -77,7 +77,8 @@ public function empty_config_default_spec_fails_with_clear_message(): void $this->expectException(AssertionFailedError::class); $this->expectExceptionMessage( 'openApiSpec() must return a non-empty spec name, but an empty string was returned. ' - . 'Either override openApiSpec() in your test class, or set the "default_spec" key ' + . 'Either add #[OpenApiSpec(\'your-spec\')] to your test class or method, ' + . 'override openApiSpec() in your test class, or set the "default_spec" key ' . 'in config/openapi-contract-testing.php.', ); From 0274a7a2d5a9a5bb0f9a84543058eb7ed84a207f Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 16 Mar 2026 21:04:09 +0900 Subject: [PATCH 2/6] docs(readme): add #[OpenApiSpec] attribute usage documentation --- README.md | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e95d2b8..d7de3c0 100644 --- a/README.md +++ b/README.md @@ -106,22 +106,51 @@ class GetPetsTest extends TestCase } ``` -To use a different spec for a specific test class, override `openApiSpec()`: +To use a different spec for a specific test class, add the `#[OpenApiSpec]` attribute: ```php +use Studio\OpenApiContractTesting\Laravel\OpenApiSpec; +use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; + +#[OpenApiSpec('admin')] class AdminGetUsersTest extends TestCase { use ValidatesOpenApiSchema; - protected function openApiSpec(): string + // All tests in this class use the 'admin' spec +} +``` + +You can also specify the spec per test method. Method-level attributes take priority over class-level: + +```php +#[OpenApiSpec('front')] +class MixedApiTest extends TestCase +{ + use ValidatesOpenApiSchema; + + public function test_front_endpoint(): void { - return 'admin'; + // Uses 'front' from class-level attribute } - // ... + #[OpenApiSpec('admin')] + public function test_admin_endpoint(): void + { + // Uses 'admin' from method-level attribute (overrides class) + } } ``` +Resolution priority (highest to lowest): + +1. Method-level `#[OpenApiSpec]` attribute +2. Class-level `#[OpenApiSpec]` attribute +3. `openApiSpec()` method override +4. `config('openapi-contract-testing.default_spec')` + +> **Note:** You can still override `openApiSpec()` as before — it remains fully backward-compatible. + #### Framework-agnostic ```php From e3084343ad641be4dca68a31f4c3dc7c42a3296c Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 16 Mar 2026 21:14:54 +0900 Subject: [PATCH 3/6] refactor: move OpenApiSpec attribute to core namespace for framework-agnostic use Extract OpenApiSpec attribute from Laravel namespace to Studio\OpenApiContractTesting and introduce OpenApiSpecResolver trait with an openApiSpecFallback() hook. The Laravel trait now composes OpenApiSpecResolver and overrides the fallback for config-based defaults. --- README.md | 34 ++++++++++++++++- src/Laravel/ValidatesOpenApiSchema.php | 31 ++++------------ src/{Laravel => }/OpenApiSpec.php | 2 +- src/OpenApiSpecResolver.php | 37 +++++++++++++++++++ .../Unit/OpenApiSpecResolverFallbackTest.php | 25 +++++++++++++ tests/Unit/OpenApiSpecResolverTest.php | 37 +++++++++++++++++++ .../ValidatesOpenApiSchemaAttributeTest.php | 2 +- 7 files changed, 142 insertions(+), 26 deletions(-) rename src/{Laravel => }/OpenApiSpec.php (81%) create mode 100644 src/OpenApiSpecResolver.php create mode 100644 tests/Unit/OpenApiSpecResolverFallbackTest.php create mode 100644 tests/Unit/OpenApiSpecResolverTest.php diff --git a/README.md b/README.md index d7de3c0..660620e 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ class GetPetsTest extends TestCase To use a different spec for a specific test class, add the `#[OpenApiSpec]` attribute: ```php -use Studio\OpenApiContractTesting\Laravel\OpenApiSpec; +use Studio\OpenApiContractTesting\OpenApiSpec; use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; #[OpenApiSpec('admin')] @@ -153,6 +153,38 @@ Resolution priority (highest to lowest): #### Framework-agnostic +You can use the `#[OpenApiSpec]` attribute with the `OpenApiSpecResolver` trait in any PHPUnit test: + +```php +use Studio\OpenApiContractTesting\OpenApiSpec; +use Studio\OpenApiContractTesting\OpenApiSpecResolver; +use Studio\OpenApiContractTesting\OpenApiResponseValidator; + +#[OpenApiSpec('front')] +class GetPetsTest extends TestCase +{ + use OpenApiSpecResolver; + + public function test_list_pets(): void + { + $specName = $this->resolveOpenApiSpec(); // 'front' + $validator = new OpenApiResponseValidator(); + $result = $validator->validate( + specName: $specName, + method: 'GET', + requestPath: '/api/v1/pets', + statusCode: 200, + responseBody: $decodedJsonBody, + responseContentType: 'application/json', + ); + + $this->assertTrue($result->isValid(), $result->errorMessage()); + } +} +``` + +Or without the attribute, pass the spec name directly: + ```php use Studio\OpenApiContractTesting\OpenApiResponseValidator; use Studio\OpenApiContractTesting\OpenApiSpecLoader; diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 130777b..9e7dc91 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -6,11 +6,10 @@ use Illuminate\Testing\TestResponse; use JsonException; -use ReflectionClass; -use ReflectionMethod; use Studio\OpenApiContractTesting\HttpMethod; use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; +use Studio\OpenApiContractTesting\OpenApiSpecResolver; use function is_numeric; use function is_string; @@ -19,6 +18,8 @@ trait ValidatesOpenApiSchema { + use OpenApiSpecResolver; + protected function openApiSpec(): string { $spec = config('openapi-contract-testing.default_spec'); @@ -30,6 +31,11 @@ protected function openApiSpec(): string return $spec; } + protected function openApiSpecFallback(): string + { + return $this->openApiSpec(); + } + protected function assertResponseMatchesOpenApiSchema( TestResponse $response, ?HttpMethod $method = null, @@ -86,27 +92,6 @@ protected function assertResponseMatchesOpenApiSchema( ); } - private function resolveOpenApiSpec(): string - { - // 1. Method-level #[OpenApiSpec] attribute - $methodName = $this->name(); // @phpstan-ignore method.notFound - $refMethod = new ReflectionMethod($this, $methodName); - $methodAttrs = $refMethod->getAttributes(OpenApiSpec::class); - if ($methodAttrs !== []) { - return $methodAttrs[0]->newInstance()->name; - } - - // 2. Class-level #[OpenApiSpec] attribute - $refClass = new ReflectionClass($this); - $classAttrs = $refClass->getAttributes(OpenApiSpec::class); - if ($classAttrs !== []) { - return $classAttrs[0]->newInstance()->name; - } - - // 3. openApiSpec() method override / config default - return $this->openApiSpec(); - } - /** @return null|array */ private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array { diff --git a/src/Laravel/OpenApiSpec.php b/src/OpenApiSpec.php similarity index 81% rename from src/Laravel/OpenApiSpec.php rename to src/OpenApiSpec.php index 3da65af..fda63c1 100644 --- a/src/Laravel/OpenApiSpec.php +++ b/src/OpenApiSpec.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Studio\OpenApiContractTesting\Laravel; +namespace Studio\OpenApiContractTesting; use Attribute; diff --git a/src/OpenApiSpecResolver.php b/src/OpenApiSpecResolver.php new file mode 100644 index 0000000..10d0eec --- /dev/null +++ b/src/OpenApiSpecResolver.php @@ -0,0 +1,37 @@ +name(); // @phpstan-ignore method.notFound + $refMethod = new ReflectionMethod($this, $methodName); + $methodAttrs = $refMethod->getAttributes(OpenApiSpec::class); + if ($methodAttrs !== []) { + return $methodAttrs[0]->newInstance()->name; + } + + // 2. Class-level #[OpenApiSpec] attribute + $refClass = new ReflectionClass($this); + $classAttrs = $refClass->getAttributes(OpenApiSpec::class); + if ($classAttrs !== []) { + return $classAttrs[0]->newInstance()->name; + } + + // 3. Subclass hook (e.g. openApiSpec() in Laravel trait) + return $this->openApiSpecFallback(); + } +} diff --git a/tests/Unit/OpenApiSpecResolverFallbackTest.php b/tests/Unit/OpenApiSpecResolverFallbackTest.php new file mode 100644 index 0000000..e5dd36e --- /dev/null +++ b/tests/Unit/OpenApiSpecResolverFallbackTest.php @@ -0,0 +1,25 @@ +assertSame('from-fallback', $this->resolveOpenApiSpec()); + } + + protected function openApiSpecFallback(): string + { + return 'from-fallback'; + } +} diff --git a/tests/Unit/OpenApiSpecResolverTest.php b/tests/Unit/OpenApiSpecResolverTest.php new file mode 100644 index 0000000..10836e9 --- /dev/null +++ b/tests/Unit/OpenApiSpecResolverTest.php @@ -0,0 +1,37 @@ +assertSame('petstore-3.0', $this->resolveOpenApiSpec()); + } + + #[Test] + #[OpenApiSpec('petstore-3.1')] + public function method_level_attribute_overrides_class_level(): void + { + $this->assertSame('petstore-3.1', $this->resolveOpenApiSpec()); + } + + #[Test] + public function fallback_returns_empty_string_when_no_attribute(): void + { + // This test class has a class-level attribute, so we verify + // the fallback method itself returns empty by default. + $this->assertSame('', $this->openApiSpecFallback()); + } +} diff --git a/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php index f7a5b40..dc08d2c 100644 --- a/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaAttributeTest.php @@ -9,9 +9,9 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Studio\OpenApiContractTesting\HttpMethod; -use Studio\OpenApiContractTesting\Laravel\OpenApiSpec; use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; use Studio\OpenApiContractTesting\OpenApiCoverageTracker; +use Studio\OpenApiContractTesting\OpenApiSpec; use Studio\OpenApiContractTesting\OpenApiSpecLoader; use Studio\OpenApiContractTesting\Tests\Helpers\CreatesTestResponse; From 923cf438e2e304b98988da2d2ce093c8cbb0bbb8 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 16 Mar 2026 21:39:42 +0900 Subject: [PATCH 4/6] fix(phpstan): add ValidatesOpenApiSchemaAttributeTest to ignore paths --- phpstan.neon.dist | 3 +++ 1 file changed, 3 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 419275f..33c8c89 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -23,13 +23,16 @@ parameters: - src/Laravel/ValidatesOpenApiSchema.php - tests/Unit/ValidatesOpenApiSchemaTest.php - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php + - tests/Unit/ValidatesOpenApiSchemaAttributeTest.php - message: '#expects TResponse of Symfony\\Component\\HttpFoundation\\Response#' paths: - tests/Unit/ValidatesOpenApiSchemaTest.php - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php + - tests/Unit/ValidatesOpenApiSchemaAttributeTest.php - message: '#class@anonymous/tests/Helpers/CreatesTestResponse\.php.*no value type specified in iterable type array#' paths: - tests/Unit/ValidatesOpenApiSchemaTest.php - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php + - tests/Unit/ValidatesOpenApiSchemaAttributeTest.php From 62c1599fc5b5292afb250fb8fdc12dcc9c748431 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 16 Mar 2026 21:51:38 +0900 Subject: [PATCH 5/6] fix(phpstan): use real Response class in test helper instead of anonymous classes Replace anonymous class stubs in CreatesTestResponse with Symfony\Component\HttpFoundation\Response, fixing 3 PHPStan errors (missing generics, type mismatch, iterable type) at the root cause rather than suppressing them via ignore rules. --- composer.json | 3 +- phpstan.neon.dist | 18 +--------- tests/Helpers/CreatesTestResponse.php | 48 +++------------------------ 3 files changed, 8 insertions(+), 61 deletions(-) diff --git a/composer.json b/composer.json index 3a8395a..931264e 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,8 @@ "require-dev": { "friendsofphp/php-cs-fixer": "^3.94", "illuminate/testing": "^11.0 || ^12.0", - "phpstan/phpstan": "^2.0" + "phpstan/phpstan": "^2.0", + "symfony/http-foundation": "^8.0" }, "suggest": { "illuminate/testing": "Required for the Laravel adapter (ValidatesOpenApiSchema trait)" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 33c8c89..a851626 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -19,20 +19,4 @@ parameters: path: src/Laravel/OpenApiContractTestingServiceProvider.php - message: '#does not specify its types: TResponse#' - paths: - - src/Laravel/ValidatesOpenApiSchema.php - - tests/Unit/ValidatesOpenApiSchemaTest.php - - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php - - tests/Unit/ValidatesOpenApiSchemaAttributeTest.php - - - message: '#expects TResponse of Symfony\\Component\\HttpFoundation\\Response#' - paths: - - tests/Unit/ValidatesOpenApiSchemaTest.php - - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php - - tests/Unit/ValidatesOpenApiSchemaAttributeTest.php - - - message: '#class@anonymous/tests/Helpers/CreatesTestResponse\.php.*no value type specified in iterable type array#' - paths: - - tests/Unit/ValidatesOpenApiSchemaTest.php - - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php - - tests/Unit/ValidatesOpenApiSchemaAttributeTest.php + path: src/Laravel/ValidatesOpenApiSchema.php diff --git a/tests/Helpers/CreatesTestResponse.php b/tests/Helpers/CreatesTestResponse.php index 15f6bbd..66cb0dc 100644 --- a/tests/Helpers/CreatesTestResponse.php +++ b/tests/Helpers/CreatesTestResponse.php @@ -4,58 +4,20 @@ namespace Studio\OpenApiContractTesting\Tests\Helpers; -use const CASE_LOWER; - use Illuminate\Testing\TestResponse; - -use function array_change_key_case; -use function strtolower; +use Symfony\Component\HttpFoundation\Response; trait CreatesTestResponse { /** * @param array $headers + * + * @return TestResponse */ private function makeTestResponse(string $content, int $statusCode, array $headers = []): TestResponse { - $headerBag = new class ($headers) { - /** @var array */ - private readonly array $headers; - - /** @param array $headers */ - public function __construct(array $headers) - { - $this->headers = array_change_key_case($headers, CASE_LOWER); - } - - public function get(string $key, ?string $default = null): ?string - { - return $this->headers[strtolower($key)] ?? $default; - } - }; - - $baseResponse = new class ($content, $statusCode, $headerBag) { - public readonly object $headers; - - public function __construct( - private readonly string $content, - private readonly int $statusCode, - object $headers, - ) { - $this->headers = $headers; - } - - public function getContent(): string - { - return $this->content; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - }; + $response = new Response($content, $statusCode, $headers); - return new TestResponse($baseResponse); + return new TestResponse($response); } } From b93ffde7a1b49283cbfb8e26dde1dc5bd880c806 Mon Sep 17 00:00:00 2001 From: wadakatu Date: Mon, 16 Mar 2026 21:59:03 +0900 Subject: [PATCH 6/6] fix(ci): broaden symfony/http-foundation version constraint Widen from ^8.0 (PHP 8.4+ only) to ^6.4 || ^7.0 || ^8.0 so the package can be installed on PHP 8.2 and 8.3 in the CI matrix. --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 931264e..77ccd1a 100644 --- a/composer.json +++ b/composer.json @@ -13,7 +13,7 @@ "friendsofphp/php-cs-fixer": "^3.94", "illuminate/testing": "^11.0 || ^12.0", "phpstan/phpstan": "^2.0", - "symfony/http-foundation": "^8.0" + "symfony/http-foundation": "^6.4 || ^7.0 || ^8.0" }, "suggest": { "illuminate/testing": "Required for the Laravel adapter (ValidatesOpenApiSchema trait)"