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
8 changes: 8 additions & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ parameters:
-
message: '#Call to an undefined method Illuminate\\Testing\\TestResponse::#'
path: src/Laravel/ValidatesOpenApiSchema.php
-
message: '#Access to an undefined property Illuminate\\Testing\\TestResponse::\$headers#'
path: src/Laravel/ValidatesOpenApiSchema.php
-
message: '#Function config_path not found#'
path: src/Laravel/OpenApiContractTestingServiceProvider.php
Expand All @@ -25,3 +28,8 @@ parameters:
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
38 changes: 37 additions & 1 deletion src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
use Studio\OpenApiContractTesting\HttpMethod;
use Studio\OpenApiContractTesting\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Throwable;

use function is_string;
use function str_contains;
use function strtolower;

trait ValidatesOpenApiSchema
{
Expand Down Expand Up @@ -46,13 +49,16 @@ protected function assertResponseMatchesOpenApiSchema(
$this->fail('OpenAPI contract testing requires buffered responses, but getContent() returned false (streamed response?).');
}

$contentType = $response->headers->get('Content-Type', '');
$hasNonJsonContentType = $content !== '' && $contentType !== '' && !str_contains(strtolower($contentType), 'json');

$validator = new OpenApiResponseValidator();
$result = $validator->validate(
$specName,
$resolvedMethod,
$resolvedPath,
$response->getStatusCode(),
$content !== '' ? $response->json() : null,
$this->extractJsonBody($response, $content, $contentType),
);

if ($result->matchedPath() !== null) {
Expand All @@ -63,10 +69,40 @@ protected function assertResponseMatchesOpenApiSchema(
);
}

if (!$result->isValid() && $hasNonJsonContentType) {
$this->fail(
"OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n"
. "Response has Content-Type '{$contentType}' but the spec expects a JSON response.",
);
}

$this->assertTrue(
$result->isValid(),
"OpenAPI schema validation failed for {$resolvedMethod} {$resolvedPath} (spec: {$specName}):\n"
. $result->errorMessage(),
);
}

/** @return null|array<string, mixed> */
private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array
{
if ($content === '') {
return null;
}

// Non-JSON Content-Type: return null so the validator can decide
// whether the spec requires a JSON body for this endpoint.
if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) {
return null;
}

try {
return $response->json();
} catch (Throwable $e) {
$this->fail(
'Response body could not be parsed as JSON: ' . $e->getMessage()
. ($contentType === '' ? ' (no Content-Type header was present on the response)' : ''),
);
}
}
}
35 changes: 32 additions & 3 deletions tests/Helpers/CreatesTestResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,46 @@

namespace Studio\OpenApiContractTesting\Tests\Helpers;

use const CASE_LOWER;

use Illuminate\Testing\TestResponse;

use function array_change_key_case;
use function strtolower;

trait CreatesTestResponse
{
private function makeTestResponse(string $content, int $statusCode): TestResponse
/**
* @param array<string, string> $headers
*/
private function makeTestResponse(string $content, int $statusCode, array $headers = []): TestResponse
{
$baseResponse = new class ($content, $statusCode) {
$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
{
Expand Down
99 changes: 99 additions & 0 deletions tests/Unit/ValidatesOpenApiSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,105 @@ public function successful_validation_records_coverage(): void
$this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0']);
}

#[Test]
public function non_json_html_body_passes_as_null_body(): void
{
$response = $this->makeTestResponse(
'<html><body>Done</body></html>',
204,
['Content-Type' => 'text/html'],
);

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::DELETE,
'/v1/pets/123',
);
}

#[Test]
public function non_json_body_fails_with_content_type_mismatch(): void
{
$response = $this->makeTestResponse(
'<html><body>OK</body></html>',
200,
['Content-Type' => 'text/html'],
);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage("Response has Content-Type 'text/html' but the spec expects a JSON response");

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
}

#[Test]
public function json_content_type_response_still_validates(): void
{
$body = (string) json_encode(
['data' => [['id' => 1, 'name' => 'Buddy', 'tag' => 'dog']]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200, ['Content-Type' => 'application/json']);

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
}

#[Test]
public function json_content_type_with_charset_validates(): void
{
$body = (string) json_encode(
['data' => [['id' => 1, 'name' => 'Buddy', 'tag' => 'dog']]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200, ['Content-Type' => 'application/json; charset=utf-8']);

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
}

#[Test]
public function vendor_json_content_type_validates(): void
{
$body = (string) json_encode(
['data' => [['id' => 1, 'name' => 'Buddy', 'tag' => 'dog']]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200, ['Content-Type' => 'application/vnd.api+json']);

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
}

#[Test]
public function missing_content_type_header_still_parses_json(): void
{
$body = (string) json_encode(
['data' => [['id' => 1, 'name' => 'Rex', 'tag' => 'dog']]],
JSON_THROW_ON_ERROR,
);
$response = $this->makeTestResponse($body, 200);

$this->assertResponseMatchesOpenApiSchema(
$response,
HttpMethod::GET,
'/v1/pets',
);
}

protected function openApiSpec(): string
{
return 'petstore-3.0';
Expand Down