Skip to content

Commit 67351a1

Browse files
committed
feat(laravel): auto-validate responses via createTestResponse hook
Adds openapi-contract-testing.auto_assert config key (default false). When true, every TestResponse produced by Laravel HTTP helpers is validated against the OpenAPI spec without requiring an explicit assertResponseMatchesOpenApiSchema() call in each test. Implementation overrides Illuminate\Foundation\Testing\TestCase:: createTestResponse() from the ValidatesOpenApiSchema trait. Macros cannot override existing methods like assertStatus(), so the hook fires one level earlier, when Laravel wraps the HTTP response into a TestResponse. A WeakMap guards against double-validation, making auto-assert and manual assertResponseMatchesOpenApiSchema() calls on the same response idempotent. Closes #39
1 parent 389202b commit 67351a1

5 files changed

Lines changed: 416 additions & 0 deletions

File tree

README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ return [
8585
// Maximum number of validation errors to report per response.
8686
// 0 = unlimited (reports all errors).
8787
'max_errors' => 20,
88+
89+
// Automatically validate every TestResponse produced by Laravel HTTP
90+
// helpers (get(), post(), etc.) against the OpenAPI spec. Defaults to
91+
// false for backward compatibility.
92+
'auto_assert' => false,
8893
];
8994
```
9095

@@ -223,6 +228,39 @@ $validator = new OpenApiResponseValidator(maxErrors: 1);
223228

224229
For Laravel, set the `max_errors` key in `config/openapi-contract-testing.php`.
225230

231+
#### Auto-assert every response
232+
233+
Forgetting `$this->assertResponseMatchesOpenApiSchema($response)` in a test means the contract is silently unchecked. Enable `auto_assert` to validate every response produced by Laravel's HTTP helpers automatically — just include the trait:
234+
235+
```php
236+
// config/openapi-contract-testing.php
237+
return [
238+
'default_spec' => 'front',
239+
'auto_assert' => true,
240+
];
241+
```
242+
243+
```php
244+
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema;
245+
246+
class GetPetsTest extends TestCase
247+
{
248+
use ValidatesOpenApiSchema;
249+
250+
public function test_list_pets(): void
251+
{
252+
// Contract is checked automatically — no explicit assert call needed.
253+
$this->get('/api/v1/pets')->assertOk();
254+
}
255+
}
256+
```
257+
258+
Notes:
259+
260+
- Defaults to `false` so existing test suites keep their explicit-assert behavior.
261+
- Auto-assert hooks into `createTestResponse()`, which is only invoked by Laravel's `MakesHttpRequests`. Responses you construct manually (outside `$this->get()`, `$this->post()`, etc.) are not touched.
262+
- Calling `$this->assertResponseMatchesOpenApiSchema($response)` on a response that auto-assert already validated is a no-op (idempotent), so mixing both styles is safe.
263+
226264
## Coverage Report
227265

228266
After running tests, the PHPUnit extension prints a coverage report. The output format is controlled by the `console_output` parameter (or `OPENAPI_CONSOLE_OUTPUT` environment variable).

src/Laravel/ValidatesOpenApiSchema.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
1111
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
1212
use Studio\OpenApiContractTesting\OpenApiSpecResolver;
13+
use Symfony\Component\HttpFoundation\Response;
14+
use WeakMap;
1315

1416
use function is_numeric;
1517
use function is_string;
@@ -22,10 +24,45 @@ trait ValidatesOpenApiSchema
2224
private static ?OpenApiResponseValidator $cachedValidator = null;
2325
private static ?int $cachedMaxErrors = null;
2426

27+
/** @var null|WeakMap<TestResponse, true> */
28+
private static ?WeakMap $validatedResponses = null;
29+
2530
public static function resetValidatorCache(): void
2631
{
2732
self::$cachedValidator = null;
2833
self::$cachedMaxErrors = null;
34+
self::$validatedResponses = null;
35+
}
36+
37+
/**
38+
* Overrides Illuminate\Foundation\Testing\TestCase::createTestResponse so
39+
* every HTTP test call runs schema validation when auto_assert is enabled.
40+
* When the library is used outside Laravel, this method is never called.
41+
*
42+
* @param Response $response
43+
*/
44+
protected function createTestResponse($response, $request = null): TestResponse
45+
{
46+
$testResponse = parent::createTestResponse($response, $request);
47+
$this->maybeAutoAssertOpenApiSchema($testResponse);
48+
49+
return $testResponse;
50+
}
51+
52+
protected function maybeAutoAssertOpenApiSchema(
53+
TestResponse $response,
54+
?HttpMethod $method = null,
55+
?string $path = null,
56+
): void {
57+
if (config('openapi-contract-testing.auto_assert') !== true) {
58+
return;
59+
}
60+
61+
if (self::isAlreadyValidated($response)) {
62+
return;
63+
}
64+
65+
$this->assertResponseMatchesOpenApiSchema($response, $method, $path);
2966
}
3067

3168
protected function openApiSpec(): string
@@ -49,6 +86,11 @@ protected function assertResponseMatchesOpenApiSchema(
4986
?HttpMethod $method = null,
5087
?string $path = null,
5188
): void {
89+
if (self::isAlreadyValidated($response)) {
90+
return;
91+
}
92+
self::markValidated($response);
93+
5294
$specName = $this->resolveOpenApiSpec();
5395
if ($specName === '') {
5496
$this->fail(
@@ -97,6 +139,18 @@ protected function assertResponseMatchesOpenApiSchema(
97139
);
98140
}
99141

142+
private static function isAlreadyValidated(TestResponse $response): bool
143+
{
144+
return self::$validatedResponses !== null &&
145+
isset(self::$validatedResponses[$response]);
146+
}
147+
148+
private static function markValidated(TestResponse $response): void
149+
{
150+
self::$validatedResponses ??= new WeakMap();
151+
self::$validatedResponses[$response] = true;
152+
}
153+
100154
private static function getOrCreateValidator(): OpenApiResponseValidator
101155
{
102156
$maxErrors = config('openapi-contract-testing.max_errors', 20);

src/Laravel/config.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,10 @@
88
// Maximum number of validation errors to report per response.
99
// 0 = unlimited (reports all errors).
1010
'max_errors' => 20,
11+
12+
// When true, every TestResponse produced by Laravel HTTP test helpers
13+
// (get(), post(), etc.) is validated against the OpenAPI spec at creation
14+
// time, without requiring an explicit assertResponseMatchesOpenApiSchema()
15+
// call in each test. Defaults to false for backward compatibility.
16+
'auto_assert' => false,
1117
];
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Studio\OpenApiContractTesting\Tests\Integration\Laravel;
6+
7+
use Illuminate\Support\Facades\Route;
8+
use Orchestra\Testbench\TestCase;
9+
use PHPUnit\Framework\AssertionFailedError;
10+
use PHPUnit\Framework\Attributes\Test;
11+
use Studio\OpenApiContractTesting\Laravel\OpenApiContractTestingServiceProvider;
12+
use Studio\OpenApiContractTesting\Laravel\ValidatesOpenApiSchema;
13+
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
14+
use Studio\OpenApiContractTesting\OpenApiSpecLoader;
15+
16+
use function dirname;
17+
18+
/**
19+
* Full Laravel integration test proving that applying the
20+
* ValidatesOpenApiSchema trait with auto_assert=true is sufficient for
21+
* $this->get() / post() / etc. to trigger OpenAPI validation — no explicit
22+
* assertResponseMatchesOpenApiSchema() call required.
23+
*/
24+
class AutoAssertIntegrationTest extends TestCase
25+
{
26+
use ValidatesOpenApiSchema;
27+
28+
protected function setUp(): void
29+
{
30+
parent::setUp();
31+
OpenApiSpecLoader::reset();
32+
OpenApiSpecLoader::configure(dirname(__DIR__, 2) . '/fixtures/specs');
33+
OpenApiCoverageTracker::reset();
34+
config()->set('openapi-contract-testing.default_spec', 'petstore-3.0');
35+
}
36+
37+
protected function tearDown(): void
38+
{
39+
self::resetValidatorCache();
40+
OpenApiSpecLoader::reset();
41+
OpenApiCoverageTracker::reset();
42+
parent::tearDown();
43+
}
44+
45+
#[Test]
46+
public function auto_assert_true_validates_http_response_without_explicit_call(): void
47+
{
48+
config()->set('openapi-contract-testing.auto_assert', true);
49+
50+
// No explicit assertResponseMatchesOpenApiSchema call — trait alone
51+
// must trigger validation when auto_assert=true.
52+
$response = $this->get('/v1/pets');
53+
54+
$response->assertOk();
55+
56+
$covered = OpenApiCoverageTracker::getCovered();
57+
$this->assertArrayHasKey('petstore-3.0', $covered);
58+
$this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0']);
59+
}
60+
61+
#[Test]
62+
public function auto_assert_true_raises_assertion_error_on_schema_mismatch(): void
63+
{
64+
config()->set('openapi-contract-testing.auto_assert', true);
65+
66+
$this->expectException(AssertionFailedError::class);
67+
$this->expectExceptionMessage('OpenAPI schema validation failed');
68+
69+
// Auto-assert must surface the mismatch without an explicit assert.
70+
$this->get('/v1/pets?bad=1');
71+
}
72+
73+
#[Test]
74+
public function auto_assert_false_does_not_validate_automatically(): void
75+
{
76+
config()->set('openapi-contract-testing.auto_assert', false);
77+
78+
// Invalid body but auto_assert=false — no exception, no coverage.
79+
$response = $this->get('/v1/pets?bad=1');
80+
81+
$response->assertOk();
82+
83+
$covered = OpenApiCoverageTracker::getCovered();
84+
$this->assertArrayNotHasKey('petstore-3.0', $covered);
85+
}
86+
87+
#[Test]
88+
public function auto_assert_not_set_defaults_to_skip(): void
89+
{
90+
// auto_assert not explicitly set — config merge default (false) applies.
91+
$response = $this->get('/v1/pets?bad=1');
92+
93+
$response->assertOk();
94+
95+
$covered = OpenApiCoverageTracker::getCovered();
96+
$this->assertArrayNotHasKey('petstore-3.0', $covered);
97+
}
98+
99+
#[Test]
100+
public function explicit_and_auto_assert_are_idempotent(): void
101+
{
102+
config()->set('openapi-contract-testing.auto_assert', true);
103+
104+
// Auto-assert runs during $this->get(). A subsequent explicit call on
105+
// the same response must not re-run validation nor re-record coverage.
106+
$response = $this->get('/v1/pets');
107+
$this->assertResponseMatchesOpenApiSchema($response);
108+
109+
$covered = OpenApiCoverageTracker::getCovered();
110+
$this->assertCount(1, $covered['petstore-3.0'] ?? []);
111+
}
112+
113+
/** @return array<int, class-string> */
114+
protected function getPackageProviders($app): array
115+
{
116+
return [OpenApiContractTestingServiceProvider::class];
117+
}
118+
119+
protected function defineRoutes($router): void
120+
{
121+
Route::get('/v1/pets', static function () {
122+
$bad = request()->query('bad') === '1';
123+
124+
return response()->json(
125+
$bad
126+
? ['wrong_key' => 'value']
127+
: ['data' => [['id' => 1, 'name' => 'Fido', 'tag' => null]]],
128+
);
129+
});
130+
}
131+
}

0 commit comments

Comments
 (0)