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

declare(strict_types=1);

namespace Studio\OpenApiContractTesting;

/**
* Envelope for a request / response body after JSON decoding, carrying the
* absent-vs-present distinction as a single value.
*
* A decoded body is one of four shapes — a JSON object/array, a JSON scalar,
* the literal JSON `null`, or no body at all. PHP's `json_decode()` collapses
* the last two: a body of the four bytes `null` and an absent body both
* decode to PHP `null`. Passing the decoded value around as a bare `mixed`
* therefore loses the "was a body present?" bit — the gap issue #246 first
* patched with an internal marker enum and issue #248 closes properly here.
*
* `present` records whether the wire carried a body; `value` is the decoded
* value (always `null` when `present` is false). A literal-null body is
* `present === true` with `value === null` — exactly the state a bare `null`
* could not express.
*
* The framework adapters build this envelope; the body validators consume it.
* The public `OpenApiResponseValidator::validate()` /
* `OpenApiRequestValidator::validate()` still accept a `mixed` body for
* backward compatibility and normalize it through {@see self::fromLegacy()}.
*/
final readonly class DecodedBody
{
/**
* @param mixed $value the decoded JSON body value — an `array`, `string`,
* `int`, `float`, `bool`, or `null`. Always `null`
* when `$present` is false. Typed `mixed` rather than
* a union because the public validators accept a bare
* legacy body of any shape via {@see self::fromLegacy()}.
*/
private function __construct(
public bool $present,
public mixed $value,
) {}

/**
* No body was carried on the wire.
*/
public static function absent(): self
{
return new self(false, null);
}

/**
* A body was carried on the wire; `$value` is its decoded value (which may
* itself be `null` for a literal JSON `null` body).
*/
public static function present(mixed $value): self
{
return new self(true, $value);
}

/**
* Normalize a legacy `mixed` body argument into a {@see DecodedBody}.
*
* An existing {@see DecodedBody} passes through unchanged. Otherwise the
* historical convention is preserved: a plain PHP `null` means "no body
* was present", any other value means "this body was present". This keeps
* the `mixed` body parameter of the public validators backward compatible
* — callers that never pass `null` for "present" lose nothing, and the
* marker that previously expressed "present null" was internal-only.
*/
public static function fromLegacy(mixed $body): self
{
if ($body instanceof self) {
return $body;
}

return $body === null ? self::absent() : self::present($body);
}
}
32 changes: 0 additions & 32 deletions src/Internal/PresentJsonNull.php

This file was deleted.

48 changes: 25 additions & 23 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
use RuntimeException;
use Studio\OpenApiContractTesting\Attribute\SkipOpenApi;
use Studio\OpenApiContractTesting\Coverage\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\DecodedBody;
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 @@ -1126,23 +1126,24 @@ 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 (or is absent), 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), report an absent
* body on empty or non-JSON content 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.
* Issues #246 / #248: a non-empty body that decodes to the literal JSON
* `null` is returned as a present {@see DecodedBody} carrying `null`, 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
private function extractRequestBody(Request $request, string $contentType): DecodedBody
{
$content = $request->getContent();
if ($content === '') {
return null;
return DecodedBody::absent();
}

if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) {
return null;
return DecodedBody::absent();
}

// The return lives inside the try so its dependence on a successful
Expand All @@ -1152,7 +1153,7 @@ private function extractRequestBody(Request $request, string $contentType): mixe
/** @var mixed $decoded */
$decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);

return $decoded ?? PresentJsonNull::Body;
return DecodedBody::present($decoded);
} catch (JsonException $e) {
$this->failOpenApi(
'Request body could not be parsed as JSON: ' . $e->getMessage()
Expand All @@ -1164,27 +1165,28 @@ private function extractRequestBody(Request $request, string $contentType): mixe
/**
* 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.
* Content-Type claims it (or is absent), report an absent body on empty
* or non-JSON content so the validator decides whether the spec required
* one.
*
* Issue #246: decoding goes through `json_decode()` rather than
* Issues #246 / #248: 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.
* forced into an array. A present literal `null` is returned as a present
* {@see DecodedBody} carrying `null` 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
private function extractJsonBody(string $content, string $contentType): DecodedBody
{
if ($content === '') {
return null;
return DecodedBody::absent();
}

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

// See extractRequestBody(): the return is inside the try so its
Expand All @@ -1193,7 +1195,7 @@ private function extractJsonBody(string $content, string $contentType): mixed
/** @var mixed $decoded */
$decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);

return $decoded ?? PresentJsonNull::Body;
return DecodedBody::present($decoded);
} catch (JsonException $e) {
$this->failOpenApi(
'Response body could not be parsed as JSON: ' . $e->getMessage()
Expand Down
15 changes: 14 additions & 1 deletion src/OpenApiRequestValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ public function __construct(
* @param array<string, mixed> $queryParams parsed query string (string|array<string> per key)
* @param array<array-key, mixed> $headers request headers (string|array<string> per key, case-insensitive name match; non-string keys are silently dropped)
* @param array<string, mixed> $cookies request cookies (string values per key). Used for apiKey security schemes with `in: cookie`. Caller is expected to pass framework-parsed cookies (e.g. Laravel's `$request->cookies->all()`) — this validator does not parse a `Cookie` header.
* @param mixed $requestBody the decoded request body. Accepts either a
* {@see DecodedBody} envelope (what the framework
* adapters pass) or a bare decoded value for
* backward compatibility. A bare `null` is read
* as an absent body; a caller that needs to
* assert a literal JSON `null` body must pass
* `DecodedBody::present(null)` explicitly.
* @param null|int $responseStatusCode optional response status the request produced; enables the documented-4xx downgrade when set
*/
public function validate(
Expand All @@ -110,6 +117,12 @@ public function validate(
array $cookies = [],
?int $responseStatusCode = null,
): OpenApiValidationResult {
// The `mixed` body parameter is kept for backward compatibility.
// Framework adapters now pass a DecodedBody envelope directly; legacy
// direct callers pass a bare value, which fromLegacy() normalizes
// (a plain `null` becomes an absent body — see {@see DecodedBody}).
$body = DecodedBody::fromLegacy($requestBody);

$spec = OpenApiSpecLoader::load($specName);

$version = OpenApiVersion::fromSpec($spec);
Expand Down Expand Up @@ -164,7 +177,7 @@ public function validate(
...ValidatorErrorBoundary::safely('query', $specName, $method, $matchedPath, fn(): array => $this->queryValidator->validate($method, $matchedPath, $collected->parameters, $queryParams, $version)),
...ValidatorErrorBoundary::safely('header', $specName, $method, $matchedPath, fn(): array => $this->headerValidator->validate($method, $matchedPath, $collected->parameters, $headers, $version)),
...ValidatorErrorBoundary::safely('security', $specName, $method, $matchedPath, fn(): array => $this->securityValidator->validate($method, $matchedPath, $spec, $operation, $headers, $queryParams, $cookies)),
...ValidatorErrorBoundary::safely('request-body', $specName, $method, $matchedPath, fn(): array => $this->bodyValidator->validate($specName, $method, $matchedPath, $operation, $requestBody, $contentType, $version)),
...ValidatorErrorBoundary::safely('request-body', $specName, $method, $matchedPath, fn(): array => $this->bodyValidator->validate($specName, $method, $matchedPath, $operation, $body, $contentType, $version)),
];

if ($errors === []) {
Expand Down
25 changes: 18 additions & 7 deletions src/OpenApiResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

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 @@ -77,6 +76,13 @@ public function __construct(
}

/**
* @param mixed $responseBody the decoded response body. Accepts either a
* {@see DecodedBody} envelope (what the framework
* adapters pass) or a bare decoded value for
* backward compatibility. A bare `null` is read
* as an absent body; a caller that needs to
* assert a literal JSON `null` body must pass
* `DecodedBody::present(null)` explicitly.
* @param null|array<array-key, mixed> $responseHeaders the response's actual headers
* (as returned by HeaderBag::all() — a map of name to list-of-values
* or to a single string). When null, header validation is skipped
Expand All @@ -92,6 +98,12 @@ public function validate(
?string $responseContentType = null,
?array $responseHeaders = null,
): OpenApiValidationResult {
// The `mixed` body parameter is kept for backward compatibility.
// Framework adapters now pass a DecodedBody envelope directly; legacy
// direct callers pass a bare value, which fromLegacy() normalizes
// (a plain `null` becomes an absent body — see {@see DecodedBody}).
$body = DecodedBody::fromLegacy($responseBody);

$spec = OpenApiSpecLoader::load($specName);

$version = OpenApiVersion::fromSpec($spec);
Expand Down Expand Up @@ -183,7 +195,7 @@ public function validate(
$matchedPath,
$statusCode,
$responseSpec,
$responseBody,
$body,
$responseContentType,
$version,
);
Expand Down Expand Up @@ -236,10 +248,9 @@ public function validate(
$matchedPath,
$statusCodeStr,
$bodyResult->matchedContentType,
// 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,
// The strict-required walker observes the decoded body value;
// an absent body carries `null` (issues #246 / #248).
$body->value,
);

return OpenApiValidationResult::success(
Expand Down Expand Up @@ -370,7 +381,7 @@ private function validateBody(
string $matchedPath,
int $statusCode,
array $responseSpec,
mixed $responseBody,
DecodedBody $responseBody,
?string $responseContentType,
OpenApiVersion $version,
): ResponseBodyValidationResult {
Expand Down
22 changes: 11 additions & 11 deletions src/Symfony/OpenApiAssertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\AssertionFailedError;
use Studio\OpenApiContractTesting\Coverage\OpenApiCoverageTracker;
use Studio\OpenApiContractTesting\DecodedBody;
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 @@ -330,26 +330,26 @@ 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 (or is absent), stay `null` on empty or non-JSON bodies so
* the validator decides whether the spec required one.
* claims JSON (or is absent), report an absent body on empty or non-JSON
* content 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
* Issues #246 / #248: when the raw content is non-empty but decodes to the
* literal JSON `null`, a present {@see DecodedBody} carrying `null` is
* returned 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.
* (scalars, arrays) are wrapped in a present envelope unchanged.
*
* @param string $subject either `Request` or `Response`, used only for the
* error message when the body is not valid JSON
*/
private function extractSymfonyJsonBody(string $content, string $contentType, string $subject): mixed
private function extractSymfonyJsonBody(string $content, string $contentType, string $subject): DecodedBody
{
if ($content === '') {
return null;
return DecodedBody::absent();
}

if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) {
return null;
return DecodedBody::absent();
}

// The return is inside the try so its dependence on a successful
Expand All @@ -359,7 +359,7 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st
/** @var mixed $decoded */
$decoded = json_decode($content, true, flags: JSON_THROW_ON_ERROR);

return $decoded ?? PresentJsonNull::Body;
return DecodedBody::present($decoded);
} catch (JsonException $e) {
$this->failOpenApi(sprintf(
'%s body could not be parsed as JSON: %s%s',
Expand Down
Loading
Loading