From 8a07d1fd98005ceb66fa16054df04706c93c4c7c Mon Sep 17 00:00:00 2001 From: DerManoMann Date: Tue, 13 Oct 2026 19:40:49 +1300 Subject: [PATCH] Skip a non-JSON body when the content type is not JSON either `OpenApiVerifier::verifyResponseBody()` returns `false` for a body that does not decode when the response's `Content-Type` is set and not JSON. The README's media type and "What is verified" bullets say so. --- README.md | 5 +++-- src/OpenApiVerifier.php | 21 +++++++++++++++++++-- tests/OpenApiVerifierTest.php | 4 ++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 3d5497a..ec56863 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ How the response is looked up: first, and the optional third argument of `verify()` skips the matching and uses the given template. * **Status code**: the exact code, then its range (`2XX`), then `default`. * **Media type**: the response's `Content-Type`, including `+json` types such as `application/problem+json`. - Without a match it falls back to `application/json`. Only JSON bodies are validated. + Without a match it falls back to `application/json`, so a JSON body labelled `text/html` is still validated. Bodies are validated with [opis/json-schema](https://github.com/opis/json-schema), which supports JSON Schema 2020-12, the dialect of OpenAPI 3.1. OpenAPI 3.0 documents are translated first: `nullable` and the boolean form of @@ -75,7 +75,8 @@ The body is validated against that schema as written: * `required` properties and types are checked. * Formats that opis knows, such as `date-time` and `email`, are checked. * Properties the schema does not declare are allowed, unless it sets `additionalProperties: false`. -* A body that is not valid JSON is validated as `null`. +* A body that is not valid JSON is validated as `null`, so it fails. With a `Content-Type` that is not JSON it is + skipped instead. ### Guzzle and PSR-18 clients Verifying on the client side needs no framework. It works against a running server, a mock diff --git a/src/OpenApiVerifier.php b/src/OpenApiVerifier.php index 5d92e32..ce81def 100644 --- a/src/OpenApiVerifier.php +++ b/src/OpenApiVerifier.php @@ -67,7 +67,9 @@ public function verify(RequestInterface $request, ResponseInterface $response, ? /** * Verify a response body for the given request method, path and status code. * - * @return bool `true` if the body has been validated, `false` if not (no matching schema) + * A body that is not JSON is not validated if its content type is not JSON either. + * + * @return bool `true` if the body has been validated, `false` if not (no matching schema, or not JSON) * * @throws OpenApiSchemaMismatchException */ @@ -77,11 +79,26 @@ public function verifyResponseBody(string $method, string $path, int $statusCode return false; } - if ([] !== ($errors = $this->schemaValidator->validate(json_decode($body), $schema, $this->specificationLoader->getOpenApiVersion()))) { + $data = json_decode($body); + if (null === $data && JSON_ERROR_NONE !== json_last_error() && !$this->claimsJson($contentType)) { + return false; + } + + if ([] !== ($errors = $this->schemaValidator->validate($data, $schema, $this->specificationLoader->getOpenApiVersion()))) { throw (new OpenApiSchemaMismatchException(sprintf('Schema mismatch: %s[%s]:%s', $method, $statusCode, $path))) ->setErrors($errors); } return true; } + + /** + * Whether the content type is JSON, or missing, in which case the body is taken to be JSON. + */ + private function claimsJson(?string $contentType): bool + { + $type = strtolower(trim(explode(';', (string) $contentType)[0])); + + return '' === $type || 'application/json' === $type || str_ends_with($type, '+json'); + } } diff --git a/tests/OpenApiVerifierTest.php b/tests/OpenApiVerifierTest.php index f8c71ff..c0b2621 100644 --- a/tests/OpenApiVerifierTest.php +++ b/tests/OpenApiVerifierTest.php @@ -80,6 +80,10 @@ public static function responses(): iterable yield 'json suffix media type mismatch' => ['/problems', 200, '{}', 'application/problem+json', null]; yield 'non-json media type skipped' => ['/problems', 200, 'hello', 'text/plain', false]; yield 'unmatched content type falls back to json' => ['/users/42', 200, '{"id":42}', 'text/html; charset=UTF-8', true]; + yield 'unmatched content type falls back to json, mismatch' => ['/users/42', 200, '{"name":"joe"}', 'text/html', null]; + yield 'unmatched content type, body not json, skipped' => ['/users/42', 200, '

hi

', 'application/xml', false]; + yield 'json content type, body not json, mismatch' => ['/users/42', 200, '

hi

', 'application/json', null]; + yield 'no content type, body not json, mismatch' => ['/users/42', 200, '

hi

', null, null]; } #[Test]