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
45 changes: 32 additions & 13 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader;
use Studio\OpenApiContractTesting\Spec\OpenApiSpecResolver;
use Studio\OpenApiContractTesting\Validation\Request\SecuritySchemeIntrospector;
use Studio\OpenApiContractTesting\Validation\Support\ContentTypeMatcher;
use Studio\OpenApiContractTesting\Validation\Support\HeaderNormalizer;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -44,7 +45,6 @@
use function is_string;
use function json_decode;
use function sprintf;
use function str_contains;
use function strtolower;
use function strtoupper;
use function trigger_error;
Expand Down Expand Up @@ -1125,10 +1125,19 @@ 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), report an absent
* body on empty or non-JSON content so the validator decides whether the
* spec required one.
* Mirrors {@see self::extractJsonBody()} for the request side: the body is
* JSON-decoded only when the Content-Type is a JSON media type (or absent);
* an empty body, or a non-JSON Content-Type, yields an absent envelope.
*
* A non-JSON body is left undecoded rather than guessed at: the Content-Type
* is forwarded to the validator separately, which resolves non-JSON media
* types through content negotiation. When the operation declares a
* `requestBody` content map, a media type missing from it is reported as
* "Content-Type is not defined" and one it declares is accepted without
* body-schema validation (the validator's schema engine is JSON-only). The
* JSON-ness test uses {@see ContentTypeMatcher::isJsonContentType()} so this
* adapter and the validator agree on exactly which media types count as
* JSON (issue #251).
*
* Issues #246 / #248: a non-empty body that decodes to the literal JSON
* `null` is returned as a present {@see DecodedBody} carrying `null`, so
Expand All @@ -1142,7 +1151,12 @@ private function extractRequestBody(Request $request, string $contentType): Deco
return DecodedBody::absent();
}

if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) {
// Non-JSON Content-Type: leave the body undecoded and report it absent.
// The validator receives the Content-Type separately and decides the
// contract verdict from it (issue #251).
if ($contentType !== '' && !ContentTypeMatcher::isJsonContentType(
ContentTypeMatcher::normalizeMediaType($contentType),
)) {
return DecodedBody::absent();
}

Expand All @@ -1164,10 +1178,12 @@ private function extractRequestBody(Request $request, string $contentType): Deco

/**
* 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), report an absent body on empty
* or non-JSON content so the validator decides whether the spec required
* one.
* Mirrors {@see self::extractRequestBody()}: the body is JSON-decoded only
* when the Content-Type is a JSON media type (or absent); an empty body, or
* a non-JSON Content-Type, yields an absent envelope. The JSON-ness test
* uses {@see ContentTypeMatcher::isJsonContentType()} so this adapter and
* the validator agree on exactly which media types count as JSON (issue
* #251).
*
* Issues #246 / #248: decoding goes through `json_decode()` rather than
* `TestResponse::json()` so a body of the literal JSON `null` is not
Expand All @@ -1183,9 +1199,12 @@ private function extractJsonBody(string $content, string $contentType): DecodedB
return DecodedBody::absent();
}

// 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')) {
// Non-JSON Content-Type: leave the body undecoded and report it absent.
// The validator receives the Content-Type separately and decides the
// contract verdict from it (issue #251).
if ($contentType !== '' && !ContentTypeMatcher::isJsonContentType(
ContentTypeMatcher::normalizeMediaType($contentType),
)) {
return DecodedBody::absent();
}

Expand Down
17 changes: 12 additions & 5 deletions src/Symfony/OpenApiAssertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use Studio\OpenApiContractTesting\OpenApiResponseValidator;
use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader;
use Studio\OpenApiContractTesting\Spec\OpenApiSpecResolver;
use Studio\OpenApiContractTesting\Validation\Support\ContentTypeMatcher;
use Symfony\Component\BrowserKit\Exception\BadMethodCallException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand All @@ -25,7 +26,6 @@
use function array_merge;
use function json_decode;
use function sprintf;
use function str_contains;
use function strtolower;
use function strtoupper;
use function var_export;
Expand Down Expand Up @@ -329,9 +329,11 @@ 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), report an absent body on empty or non-JSON
* content so the validator decides whether the spec required one.
* expect. Mirrors the Laravel adapter: the body is JSON-decoded only when
* the Content-Type is a JSON media type (or absent); an empty body, or a
* non-JSON Content-Type, yields an absent envelope. The JSON-ness test uses
* {@see ContentTypeMatcher::isJsonContentType()} so this adapter and the
* validator agree on exactly which media types count as JSON (issue #251).
*
* Issues #246 / #248: when the raw content is non-empty but decodes to the
* literal JSON `null`, a present {@see DecodedBody} carrying `null` is
Expand All @@ -348,7 +350,12 @@ private function extractSymfonyJsonBody(string $content, string $contentType, st
return DecodedBody::absent();
}

if ($contentType !== '' && !str_contains(strtolower($contentType), 'json')) {
// Non-JSON Content-Type: leave the body undecoded and report it absent.
// The validator receives the Content-Type separately and decides the
// contract verdict from it (issue #251).
if ($contentType !== '' && !ContentTypeMatcher::isJsonContentType(
ContentTypeMatcher::normalizeMediaType($contentType),
)) {
return DecodedBody::absent();
}

Expand Down
25 changes: 25 additions & 0 deletions tests/Unit/Symfony/OpenApiAssertionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,31 @@ public function non_json_response_body_passes_as_null_body(): void
$this->assertResponseMatchesOpenApiSchema($request, $response);
}

#[Test]
public function content_type_containing_json_substring_is_not_decoded_as_json(): void
{
// Issue #251: a Content-Type that merely contains the substring "json"
// (e.g. application/jsonsomethingweird) is NOT a JSON media type. The
// adapter defers to ContentTypeMatcher::isJsonContentType() — the same
// strict check the validator uses — so a non-JSON body is left
// undecoded and the validator surfaces its clean "Content-Type is not
// defined" diagnostic instead of a misleading "could not be parsed as
// JSON" parse error.
$request = Request::create('/v1/pets', 'GET');
$response = new Response(
'not json at all',
200,
['Content-Type' => 'application/jsonsomethingweird'],
);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage(
"Response Content-Type 'application/jsonsomethingweird' is not defined",
);

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

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

#[Test]
public function content_type_containing_json_substring_is_not_decoded_as_json(): void
{
// Issue #251: a Content-Type that merely contains the substring "json"
// (e.g. application/jsonsomethingweird) is NOT a JSON media type. The
// adapter defers to ContentTypeMatcher::isJsonContentType() — the same
// strict check the validator uses — so a non-JSON body is left
// undecoded and the validator surfaces its clean "Content-Type is not
// defined" diagnostic instead of a misleading "could not be parsed as
// JSON" parse error.
$GLOBALS['__openapi_testing_config']['openapi-contract-testing.auto_validate_request'] = true;

$request = Request::create(
'/v1/pets',
'POST',
[],
[],
[],
['CONTENT_TYPE' => 'application/jsonsomethingweird'],
'not json at all',
);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage(
"Request Content-Type 'application/jsonsomethingweird' is not defined",
);

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

#[Test]
public function non_json_content_type_body_is_treated_as_null(): void
{
Expand Down
28 changes: 28 additions & 0 deletions tests/Unit/ValidatesOpenApiSchemaTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,34 @@ public function vendor_json_content_type_validates(): void
);
}

#[Test]
public function content_type_containing_json_substring_is_not_decoded_as_json(): void
{
// Issue #251: a Content-Type that merely contains the substring "json"
// (e.g. application/jsonsomethingweird) is NOT a JSON media type. The
// adapter defers to ContentTypeMatcher::isJsonContentType() — the same
// strict check the validator uses — so a non-JSON body is left
// undecoded and the validator surfaces its clean "Content-Type is not
// defined" diagnostic instead of a misleading "could not be parsed as
// JSON" parse error.
$response = $this->makeTestResponse(
'not json at all',
200,
['Content-Type' => 'application/jsonsomethingweird'],
);

$this->expectException(AssertionFailedError::class);
$this->expectExceptionMessage(
"Response Content-Type 'application/jsonsomethingweird' is not defined",
);

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

#[Test]
public function missing_content_type_header_still_parses_json(): void
{
Expand Down
13 changes: 13 additions & 0 deletions tests/Unit/Validation/Support/ContentTypeMatcherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,19 @@ public function is_json_content_type_rejects_non_json(): void
$this->assertFalse(ContentTypeMatcher::isJsonContentType('application/xml'));
}

#[Test]
public function is_json_content_type_rejects_media_types_that_merely_contain_json(): void
{
// Issue #251: only an exact `application/json` or a `+json` structured
// syntax suffix counts as JSON. Media types that merely contain the
// substring "json" are NOT JSON — pinning this is what lets the
// framework adapters safely delegate their JSON-decode decision here
// instead of a loose `str_contains($ct, 'json')` check.
$this->assertFalse(ContentTypeMatcher::isJsonContentType('text/json'));
$this->assertFalse(ContentTypeMatcher::isJsonContentType('application/jsonp'));
$this->assertFalse(ContentTypeMatcher::isJsonContentType('application/jsonsomethingweird'));
}

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