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
32 changes: 32 additions & 0 deletions src/Internal/PresentJsonNull.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Internal;

/**
* Marker for "the wire carried a body whose decoded JSON value is the literal
* `null`" — distinct from a plain PHP `null`, which the body validators read
* as "no body was present at all".
*
* Issue #246: the framework adapters decode request / response bodies with
* `json_decode()`. A body of the four bytes `null` decodes to PHP `null`,
* indistinguishable from an absent body once it reaches the validators —
* letting a malformed `null` / scalar body be silently classified as "empty".
* An adapter that knows the raw content was non-empty wraps a decoded `null`
* in this marker so the request / response body validators type-check it
* against the schema instead of short-circuiting as "no body".
*
* A single-case enum is used as a value-less singleton: callers detect it
* with `$body instanceof PresentJsonNull` (an explicit `=== PresentJsonNull::Body`
* is equivalent). Every code path that reads a decoded body value MUST unwrap
* this marker — treat it as the value `null` — before passing the value on to
* schema conversion or the strict-required walker; the marker itself must
* never reach `opis/json-schema` or user code.
*
* @internal Not part of the package's public API. Do not use from user code.
*/
enum PresentJsonNull
{
case Body;
}
43 changes: 35 additions & 8 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use Studio\OpenApiContractTesting\Attribute\SkipOpenApi;
use Studio\OpenApiContractTesting\Coverage\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\HttpMethod;
use Studio\OpenApiContractTesting\Internal\PresentJsonNull;
use Studio\OpenApiContractTesting\Internal\StackTraceFilter;
use Studio\OpenApiContractTesting\OpenApiRequestValidator;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
Expand Down Expand Up @@ -520,7 +521,7 @@ protected function assertResponseMatchesOpenApiSchema(
$resolvedMethod,
$resolvedPath,
$response->getStatusCode(),
$this->extractJsonBody($response, $content, $contentType),
$this->extractJsonBody($content, $contentType),
$contentType !== '' ? $contentType : null,
// HeaderNormalizer is idempotent; HeaderBag's already-lower-cased
// keys pass through unchanged.
Expand Down Expand Up @@ -1125,8 +1126,13 @@ private function resolveBoolConfig(string $key): bool
/**
* Extract the request body in the shape OpenApiRequestValidator expects.
* Mirrors {@see self::extractJsonBody()} for the request side: parse JSON
* only when the Content-Type claims it, stay `null` on empty or non-JSON
* bodies so the validator decides whether the spec required one.
* only when the Content-Type claims it (or is absent), stay `null` on
* empty or non-JSON bodies so the validator decides whether the spec
* required one.
*
* Issue #246: a non-empty body that decodes to the literal JSON `null`
* yields a {@see PresentJsonNull} marker so the validator type-checks the
* value against the schema instead of mistaking it for an absent body.
*/
private function extractRequestBody(Request $request, string $contentType): mixed
{
Expand All @@ -1139,21 +1145,37 @@ private function extractRequestBody(Request $request, string $contentType): mixe
return null;
}

// The return lives inside the try so its dependence on a successful
// decode is local and explicit: failOpenApi() is `: never`, so the
// catch cannot fall through to a use of an undefined $decoded.
try {
/** @var mixed $decoded */
$decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);

return $decoded ?? PresentJsonNull::Body;
} catch (JsonException $e) {
$this->failOpenApi(
'Request body could not be parsed as JSON: ' . $e->getMessage()
. ($contentType === '' ? ' (no Content-Type header was present on the request)' : ''),
);
}

return $decoded;
}

/** @return null|array<string, mixed> */
private function extractJsonBody(TestResponse $response, string $content, string $contentType): ?array
/**
* Extract the response body in the shape OpenApiResponseValidator expects.
* Mirrors {@see self::extractRequestBody()}: parse JSON only when the
* Content-Type claims it (or is absent), stay `null` on empty or non-JSON
* bodies so the validator decides whether the spec required one.
*
* Issue #246: decoding goes through `json_decode()` rather than
* `TestResponse::json()` so a body of the literal JSON `null` is not
* tripped up by Laravel's "null decode == invalid JSON" heuristic, and a
* scalar body is returned as-is for schema type-checking instead of being
* forced into an array. A present literal `null` yields a
* {@see PresentJsonNull} marker so it is type-checked rather than read as
* an absent body — keeping this adapter aligned with the Symfony one.
*/
private function extractJsonBody(string $content, string $contentType): mixed
{
if ($content === '') {
return null;
Expand All @@ -1165,8 +1187,13 @@ private function extractJsonBody(TestResponse $response, string $content, string
return null;
}

// See extractRequestBody(): the return is inside the try so its
// dependence on a successful decode is local and explicit.
try {
return $response->json();
/** @var mixed $decoded */
$decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);

return $decoded ?? PresentJsonNull::Body;
} catch (JsonException $e) {
$this->failOpenApi(
'Response body could not be parsed as JSON: ' . $e->getMessage()
Expand Down
6 changes: 5 additions & 1 deletion src/OpenApiResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use InvalidArgumentException;
use RuntimeException;
use Studio\OpenApiContractTesting\Internal\PresentJsonNull;
use Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension;
use Studio\OpenApiContractTesting\Spec\OpenApiPathMatcher;
use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader;
Expand Down Expand Up @@ -235,7 +236,10 @@ public function validate(
$matchedPath,
$statusCodeStr,
$bodyResult->matchedContentType,
$responseBody,
// Issue #246: unwrap the present-literal-null marker so the
// strict-required walker observes the real `null` value
// rather than the marker object.
$responseBody instanceof PresentJsonNull ? null : $responseBody,
);

return OpenApiValidationResult::success(
Expand Down
18 changes: 14 additions & 4 deletions src/Symfony/OpenApiAssertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PHPUnit\Framework\AssertionFailedError;
use Studio\OpenApiContractTesting\Coverage\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\HttpMethod;
use Studio\OpenApiContractTesting\Internal\PresentJsonNull;
use Studio\OpenApiContractTesting\Internal\StackTraceFilter;
use Studio\OpenApiContractTesting\OpenApiRequestValidator;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
Expand Down Expand Up @@ -329,8 +330,14 @@ private function symfonyRequestValidator(): OpenApiRequestValidator
/**
* Decode a JSON request / response body in the shape the validators
* expect. Mirrors the Laravel adapter: parse only when the Content-Type
* claims JSON, stay `null` on empty or non-JSON bodies so the validator
* decides whether the spec required one.
* claims JSON (or is absent), stay `null` on empty or non-JSON bodies so
* the validator decides whether the spec required one.
*
* Issue #246: when the raw content is non-empty but decodes to the literal
* JSON `null`, a {@see PresentJsonNull} marker is returned instead of a
* bare `null` so the validator type-checks the value against the schema
* rather than mistaking it for an absent body. Non-null decoded values
* (scalars, arrays) pass through unchanged.
*
* @param string $subject either `Request` or `Response`, used only for the
* error message when the body is not valid JSON
Expand All @@ -345,9 +352,14 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st
return null;
}

// The return is inside the try so its dependence on a successful
// decode is local and explicit: failOpenApi() is `: never`, so the
// catch cannot fall through to a use of an undefined $decoded.
try {
/** @var mixed $decoded */
$decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);

return $decoded ?? PresentJsonNull::Body;
} catch (JsonException $e) {
$this->failOpenApi(sprintf(
'%s body could not be parsed as JSON: %s%s',
Expand All @@ -358,8 +370,6 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st
: '',
));
}

return $decoded;
}

/** Like Assert::fail() but with vendor frames stripped from the trace. */
Expand Down
13 changes: 12 additions & 1 deletion src/Validation/Request/RequestBodyValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Studio\OpenApiContractTesting\Validation\Request;

use stdClass;
use Studio\OpenApiContractTesting\Internal\PresentJsonNull;
use Studio\OpenApiContractTesting\OpenApiVersion;
use Studio\OpenApiContractTesting\SchemaContext;
use Studio\OpenApiContractTesting\Spec\OpenApiSchemaConverter;
Expand Down Expand Up @@ -141,7 +142,17 @@ public function validate(
return [];
}

if ($requestBody === null) {
// Issue #246: see the matching comment in ResponseBodyValidator. A
// PresentJsonNull marker means the wire carried a literal JSON `null`
// body; unwrap it and flag the body as present so it is type-checked
// against the schema instead of taking the empty-body branch.
$bodyWasPresent = false;
if ($requestBody instanceof PresentJsonNull) {
$requestBody = null;
$bodyWasPresent = true;
}

if ($requestBody === null && !$bodyWasPresent) {
if (!$required) {
return [];
}
Expand Down
14 changes: 13 additions & 1 deletion src/Validation/Response/ResponseBodyValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Studio\OpenApiContractTesting\Validation\Response;

use stdClass;
use Studio\OpenApiContractTesting\Internal\PresentJsonNull;
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Studio\OpenApiContractTesting\OpenApiValidationResult;
use Studio\OpenApiContractTesting\OpenApiVersion;
Expand Down Expand Up @@ -106,7 +107,18 @@ public function validate(
return new ResponseBodyValidationResult([], $jsonContentType);
}

if ($responseBody === null) {
// Issue #246: an adapter that saw non-empty raw content but decoded a
// literal JSON `null` passes the PresentJsonNull marker so the null is
// type-checked against the schema below, rather than short-circuiting
// as an absent body. Unwrap it to a real null and remember the body
// WAS present so the empty-body branch is bypassed.
$bodyWasPresent = false;
if ($responseBody instanceof PresentJsonNull) {
$responseBody = null;
$bodyWasPresent = true;
}

if ($responseBody === null && !$bodyWasPresent) {
return new ResponseBodyValidationResult(
[
"Response body is empty but {$method} {$matchedPath} (status {$statusCode}) defines a JSON-compatible response schema in '{$specName}' spec.",
Expand Down
50 changes: 50 additions & 0 deletions tests/Unit/Symfony/OpenApiAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,56 @@ public function malformed_json_request_body_without_content_type_adds_hint(): vo
$this->assertRequestMatchesOpenApiSchema($request);
}

#[Test]
public function literal_null_response_body_without_content_type_fails_loudly(): void
{
// Issue #246: a response body of the literal JSON `null` with no
// Content-Type is type-checked against the schema, not silently read
// as an absent body. GET /v1/pets declares a `type: object` 200
// schema, so a null body is a contract violation surfaced as a schema
// type error rather than the misleading "Response body is empty".
$request = Request::create('/v1/pets', 'GET');
$response = new Response('null', 200);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('must match the type');

$this->assertResponseMatchesOpenApiSchema($request, $response);
}

#[Test]
public function scalar_response_body_without_content_type_is_type_checked(): void
{
// Issue #246: a scalar JSON body (here the integer `123`) is decoded
// and type-checked against the schema rather than being treated as no
// body. Regression guard — the Symfony adapter's `mixed` body shape
// already handled scalars; this pins that the #246 fix keeps it so.
$request = Request::create('/v1/pets', 'GET');
$response = new Response('123', 200);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('must match the type');

$this->assertResponseMatchesOpenApiSchema($request, $response);
}

#[Test]
public function literal_null_request_body_without_content_type_fails_loudly(): void
{
// Issue #246: a request body of the literal JSON `null` with no
// Content-Type is type-checked against the requestBody schema. POST
// /v1/pets requires a `type: object` body, so a null body fails
// loudly. Request::create() forces a Content-Type on POST bodies;
// drop it so the no-Content-Type path runs.
$request = Request::create('/v1/pets', 'POST', [], [], [], [], 'null');
$request->headers->remove('Content-Type');

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('must match the type');

$this->assertRequestMatchesOpenApiSchema($request);
}

#[Test]
public function non_json_response_body_passes_as_null_body(): void
{
Expand Down
20 changes: 20 additions & 0 deletions tests/Unit/ValidatesOpenApiSchemaAutoValidateRequestTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,26 @@ public function auto_validate_request_true_raises_on_invalid_body(): void
$this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets');
}

#[Test]
public function auto_validate_request_type_checks_literal_null_body(): void
{
// Issue #246: a request body of the literal JSON `null` with no
// Content-Type is type-checked against the requestBody schema instead
// of being read as an absent body. POST /v1/pets requires a
// `type: object` body, so a null body fails loudly with a schema type
// error — before the fix the decoded `null` was misreported as an
// empty body.
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.auto_validate_request'] = true;

$request = Request::create('/v1/pets', 'POST', [], [], [], [], 'null');
$request->headers->remove('Content-Type');

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('must match the type');

$this->maybeAutoValidateOpenApiRequest($request, HttpMethod::POST, '/v1/pets');
}

#[Test]
public function auto_validate_request_true_raises_on_missing_bearer(): void
{
Expand Down
59 changes: 59 additions & 0 deletions tests/Unit/ValidatesOpenApiSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,65 @@ public function successful_validation_records_coverage(): void
$this->assertArrayHasKey('GET /v1/pets', $covered['petstore-3.0']);
}

#[Test]
public function literal_null_response_body_is_type_checked(): void
{
// Issue #246: a response body of the literal JSON `null` is
// type-checked against the schema instead of being read as an absent
// body. GET /v1/pets declares a `type: object` 200 schema, so a null
// body fails with a schema type error. Before the fix the Laravel
// adapter decoded through TestResponse::json(), whose "null decode ==
// invalid JSON" heuristic raised a misleading framework failure.
$response = $this->makeTestResponse('null', 200);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('must match the type');

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

#[Test]
public function literal_null_response_body_with_json_content_type_is_type_checked(): void
{
// Issue #246: the literal-null fix applies on the explicit-Content-Type
// path too, not only when the header is absent. A response with
// `Content-Type: application/json` and a `null` body is type-checked
// against GET /v1/pets' `type: object` 200 schema and fails.
$response = $this->makeTestResponse('null', 200, ['Content-Type' => 'application/json']);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('must match the type');

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

#[Test]
public function scalar_response_body_is_type_checked(): void
{
// Issue #246: a scalar JSON body (the integer `123`) reaches the
// validator and is type-checked. Before the fix the body extractor's
// `?array` return type raised a TypeError on a non-array decoded
// body; the Laravel and Symfony adapters now behave identically.
$response = $this->makeTestResponse('123', 200);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage('must match the type');

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

#[Test]
public function non_json_html_body_passes_as_null_body(): void
{
Expand Down
Loading
Loading