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
69 changes: 65 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,24 +106,85 @@ 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\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

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;
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "^6.4 || ^7.0 || ^8.0"
},
"suggest": {
"illuminate/testing": "Required for the Laravel adapter (ValidatesOpenApiSchema trait)"
Expand Down
15 changes: 1 addition & 14 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +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
-
message: '#expects TResponse of Symfony\\Component\\HttpFoundation\\Response#'
paths:
- tests/Unit/ValidatesOpenApiSchemaTest.php
- tests/Unit/ValidatesOpenApiSchemaDefaultSpecTest.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
path: src/Laravel/ValidatesOpenApiSchema.php
13 changes: 11 additions & 2 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Studio\OpenApiContractTesting\HttpMethod;
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Studio\OpenApiContractTesting\OpenApiSpecResolver;

use function is_numeric;
use function is_string;
Expand All @@ -17,6 +18,8 @@

trait ValidatesOpenApiSchema
{
use OpenApiSpecResolver;

protected function openApiSpec(): string
{
$spec = config('openapi-contract-testing.default_spec');
Expand All @@ -28,16 +31,22 @@ protected function openApiSpec(): string
return $spec;
}

protected function openApiSpecFallback(): string
{
return $this->openApiSpec();
}

protected function assertResponseMatchesOpenApiSchema(
TestResponse $response,
?HttpMethod $method = null,
?string $path = null,
): void {
$specName = $this->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.',
);
}
Expand Down
15 changes: 15 additions & 0 deletions src/OpenApiSpec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting;

use Attribute;

#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
final class OpenApiSpec
{
public function __construct(
public readonly string $name,
) {}
}
37 changes: 37 additions & 0 deletions src/OpenApiSpecResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting;

use ReflectionClass;
use ReflectionMethod;

trait OpenApiSpecResolver
{
protected function openApiSpecFallback(): string
{
return '';
}

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. Subclass hook (e.g. openApiSpec() in Laravel trait)
return $this->openApiSpecFallback();
}
}
48 changes: 5 additions & 43 deletions tests/Helpers/CreatesTestResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> $headers
*
* @return TestResponse<Response>
*/
private function makeTestResponse(string $content, int $statusCode, array $headers = []): TestResponse
{
$headerBag = new class ($headers) {
/** @var array<string, string> */
private readonly array $headers;

/** @param array<string, string> $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);
}
}
25 changes: 25 additions & 0 deletions tests/Unit/OpenApiSpecResolverFallbackTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Studio\OpenApiContractTesting\OpenApiSpecResolver;

class OpenApiSpecResolverFallbackTest extends TestCase
{
use OpenApiSpecResolver;

#[Test]
public function fallback_is_used_when_no_attribute_present(): void
{
$this->assertSame('from-fallback', $this->resolveOpenApiSpec());
}

protected function openApiSpecFallback(): string
{
return 'from-fallback';
}
}
37 changes: 37 additions & 0 deletions tests/Unit/OpenApiSpecResolverTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Tests\Unit;

use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Studio\OpenApiContractTesting\OpenApiSpec;
use Studio\OpenApiContractTesting\OpenApiSpecResolver;

#[OpenApiSpec('petstore-3.0')]
class OpenApiSpecResolverTest extends TestCase
{
use OpenApiSpecResolver;

#[Test]
public function class_level_attribute_is_resolved(): void
{
$this->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());
}
}
Loading