diff --git a/CLAUDE.md b/CLAUDE.md index bf8d936..118a045 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,8 @@ This is a PHP library (`studio-design/openapi-contract-testing`) that validates ### Laravel Integration -- **`ValidatesOpenApiSchema`** trait — Used in Laravel test cases. Provides `assertResponseMatchesOpenApiSchema()` which auto-resolves method/path from the current request and records coverage. Requires implementing `openApiSpec(): string` to specify which spec to validate against. +- **`ValidatesOpenApiSchema`** trait — Used in Laravel test cases. Provides `assertResponseMatchesOpenApiSchema()` which auto-resolves method/path from the current request and records coverage. The default spec name is read from `config('openapi-contract-testing.default_spec')`; override `openApiSpec(): string` per-test-class if needed. +- **`OpenApiContractTestingServiceProvider`** — Auto-discovered service provider that registers and publishes the `openapi-contract-testing` config file (`default_spec` key). ### Key Enums diff --git a/composer.json b/composer.json index c95d050..3a8395a 100644 --- a/composer.json +++ b/composer.json @@ -33,6 +33,13 @@ "dealerdirect/phpcodesniffer-composer-installer": true } }, + "extra": { + "laravel": { + "providers": [ + "Studio\\OpenApiContractTesting\\Laravel\\OpenApiContractTestingServiceProvider" + ] + } + }, "minimum-stability": "stable", "prefer-stable": true } diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2d8f8fc..33debca 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -6,16 +6,22 @@ parameters: tmpDir: .phpstan.cache ignoreErrors: - - message: '#Function app not found#' + message: '#Function (app|config) not found#' path: src/Laravel/ValidatesOpenApiSchema.php - message: '#Call to an undefined method Illuminate\\Testing\\TestResponse::#' path: src/Laravel/ValidatesOpenApiSchema.php + - + message: '#Function config_path not found#' + 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 - message: '#expects TResponse of Symfony\\Component\\HttpFoundation\\Response#' - path: tests/Unit/ValidatesOpenApiSchemaTest.php + paths: + - tests/Unit/ValidatesOpenApiSchemaTest.php + - tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.php diff --git a/src/Laravel/OpenApiContractTestingServiceProvider.php b/src/Laravel/OpenApiContractTestingServiceProvider.php new file mode 100644 index 0000000..c946e05 --- /dev/null +++ b/src/Laravel/OpenApiContractTestingServiceProvider.php @@ -0,0 +1,22 @@ +mergeConfigFrom(__DIR__ . '/config.php', 'openapi-contract-testing'); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/config.php' => config_path('openapi-contract-testing.php'), + ], 'openapi-contract-testing'); + } +} diff --git a/src/Laravel/ValidatesOpenApiSchema.php b/src/Laravel/ValidatesOpenApiSchema.php index 52e405f..fdc5e01 100644 --- a/src/Laravel/ValidatesOpenApiSchema.php +++ b/src/Laravel/ValidatesOpenApiSchema.php @@ -9,9 +9,20 @@ use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiResponseValidator; +use function is_string; + trait ValidatesOpenApiSchema { - abstract protected function openApiSpec(): string; + protected function openApiSpec(): string + { + $spec = config('openapi-contract-testing.default_spec'); + + if (!is_string($spec) || $spec === '') { + return ''; + } + + return $spec; + } protected function assertResponseMatchesOpenApiSchema( TestResponse $response, @@ -20,7 +31,11 @@ protected function assertResponseMatchesOpenApiSchema( ): void { $specName = $this->openApiSpec(); if ($specName === '') { - $this->fail('openApiSpec() must return a non-empty spec name, but an empty string was returned.'); + $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 ' + . 'in config/openapi-contract-testing.php.', + ); } $resolvedMethod = $method !== null ? $method->value : app('request')->getMethod(); diff --git a/src/Laravel/config.php b/src/Laravel/config.php new file mode 100644 index 0000000..d9cf9f4 --- /dev/null +++ b/src/Laravel/config.php @@ -0,0 +1,7 @@ + '', +]; diff --git a/tests/Helpers/CreatesTestResponse.php b/tests/Helpers/CreatesTestResponse.php new file mode 100644 index 0000000..06afbb7 --- /dev/null +++ b/tests/Helpers/CreatesTestResponse.php @@ -0,0 +1,32 @@ +content; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + }; + + return new TestResponse($baseResponse); + } +} diff --git a/tests/Helpers/LaravelConfigMock.php b/tests/Helpers/LaravelConfigMock.php new file mode 100644 index 0000000..eefe1ac --- /dev/null +++ b/tests/Helpers/LaravelConfigMock.php @@ -0,0 +1,29 @@ +assertSame('', $this->openApiSpec()); + } + + #[Test] + public function default_open_api_spec_returns_config_value(): void + { + $GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = 'petstore-3.0'; + + $this->assertSame('petstore-3.0', $this->openApiSpec()); + } + + #[Test] + public function null_config_value_returns_empty_string(): void + { + $GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = null; + + $this->assertSame('', $this->openApiSpec()); + } + + #[Test] + public function empty_config_default_spec_fails_with_clear_message(): void + { + $response = $this->makeTestResponse('{}', 200); + + $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 ' + . 'in config/openapi-contract-testing.php.', + ); + + $this->assertResponseMatchesOpenApiSchema( + $response, + HttpMethod::GET, + '/v1/pets', + ); + } + + #[Test] + public function configured_default_spec_validates_successfully(): void + { + $GLOBALS['__openapi_testing_config']['openapi-contract-testing.default_spec'] = 'petstore-3.0'; + + $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', + ); + } +} diff --git a/tests/Unit/ValidatesOpenApiSchemaTest.php b/tests/Unit/ValidatesOpenApiSchemaTest.php index 3d3466e..6038d34 100644 --- a/tests/Unit/ValidatesOpenApiSchemaTest.php +++ b/tests/Unit/ValidatesOpenApiSchemaTest.php @@ -6,7 +6,6 @@ use const JSON_THROW_ON_ERROR; -use Illuminate\Testing\TestResponse; use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -14,11 +13,13 @@ use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema; use Studio\OpenApiContractTesting\OpenApiCoverageTracker; use Studio\OpenApiContractTesting\OpenApiSpecLoader; +use Studio\OpenApiContractTesting\Tests\Helpers\CreatesTestResponse; use function json_encode; class ValidatesOpenApiSchemaTest extends TestCase { + use CreatesTestResponse; use ValidatesOpenApiSchema; protected function setUp(): void @@ -146,26 +147,4 @@ protected function openApiSpec(): string { return 'petstore-3.0'; } - - private function makeTestResponse(string $content, int $statusCode): TestResponse - { - $baseResponse = new class ($content, $statusCode) { - public function __construct( - private readonly string $content, - private readonly int $statusCode, - ) {} - - public function getContent(): string - { - return $this->content; - } - - public function getStatusCode(): int - { - return $this->statusCode; - } - }; - - return new TestResponse($baseResponse); - } }