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
2 changes: 1 addition & 1 deletion docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Main validator class. Validates a response body against the spec.

The constructor accepts a `maxErrors` parameter (default: `20`) that limits how many validation errors the underlying JSON Schema validator collects. Use `0` for unlimited, `1` to stop at the first error.

The optional `responseContentType` parameter enables content negotiation: when provided, non-JSON content types (e.g., `text/html`) are checked for spec presence only, while JSON-compatible types proceed to full schema validation.
The optional `responseContentType` parameter enables content negotiation: when provided, non-JSON content types (e.g., `text/html`) are checked for spec presence only, while JSON-compatible types proceed to full schema validation. When a non-JSON content type matches a spec media-type key that declares a `schema`, the body cannot be evaluated by the JSON Schema engine — the result is reported as `Skipped` (with a `skipReason`) rather than a clean success, so the unvalidated body is not miscounted.

```php
$validator = new OpenApiResponseValidator(maxErrors: 20);
Expand Down
2 changes: 1 addition & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ Notes:

- Patterns are regex strings **without** `/` delimiters or `^$` anchors; they are anchored automatically, so `5\d\d` matches exactly `500`–`599` (not `5000`).
- The skip check sits **between** the "path / method not in spec" checks and the "status code not defined" / schema-validation checks. A skipped code therefore suppresses both status-code failure modes (undocumented code AND body mismatch for a documented code), but typos in the request path or method still fail loudly.
- Skipped endpoints count as covered — the endpoint was exercised, just not schema-validated. Coverage semantics here match how non-JSON content types and schema-less `204` responses are handled, but `OpenApiValidationResult::isSkipped()` returns `true` **only** for status-code skips; the other no-body-validation branches still return a plain `success()`.
- Skipped endpoints count as covered — the endpoint was exercised, just not schema-validated. Coverage semantics here match how non-JSON content types and schema-less `204` responses are handled. `OpenApiValidationResult::isSkipped()` returns `true` for status-code skips **and** for responses/requests whose body could not be schema-validated because the spec declared it under a non-JSON content type (with a `schema` the JSON Schema engine cannot evaluate). A schema-less `204` and a non-JSON content type with no `schema` have nothing to validate and still return a plain `success()`.
- `OpenApiValidationResult::isSkipped()` is exposed for callers who want to distinguish a skip from a genuine success. `skipReason()` identifies the matched pattern. `outcome()` returns an `OpenApiValidationOutcome` enum (`Success` / `Failure` / `Skipped`) for callers who want exhaustive `match` handling instead of two bool predicates.
- **Observability trade-off**: a real regression that causes an unrelated `500` will not fail this assertion. Keep your HTTP-level assertions (`$response->assertOk()`, status-code expectations in the test) alongside the contract check so a stray 5xx still surfaces — the contract assertion alone is not a substitute for status-code assertions on happy paths.
- **Coverage signal**: skipped responses surface as their own row inside each endpoint's response table — `⚠` (`:warning:` in Markdown) on the per-`(status, content-type)` line, with the matched skip pattern shown inline. The endpoint marker becomes `◐` (partial) when other responses are still validated, or stays `✓` only when every declared response is covered. The response-level rate (`responseCovered / responseTotal`) excludes skipped definitions, so a happy-path regression that silently returns `500` in every test no longer hides behind a 100% endpoint count. `skipReason()` is available on each `OpenApiValidationResult` for callers who want to log the matched pattern from a custom renderer.
Expand Down
2 changes: 1 addition & 1 deletion docs/supported-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Detection looks at each property schema's own top-level `readOnly` / `writeOnly`
## Body validation
- **Validated**: `application/json` and any `+json` structured-syntax suffix (RFC 6838), and content keys using ranges (`application/*`, `*/*`) — the matcher tries exact match first, then `<type>/*`, then `*/*`.
- **Multi-JSON-per-status specs** (e.g. `application/json` + `application/problem+json` for the same status): when the actual response Content-Type is supplied, schema validation prefers the spec key that exactly matches the response Content-Type before falling back to the first JSON key. A problem-details body served as `application/problem+json` is judged against its own schema, not the success-shape `application/json` schema. Vendor `+json` suffixes the spec doesn't enumerate (e.g. `application/vnd.example.v1+json`) still fall through to the first JSON key, preserving the legacy interchangeable-JSON behaviour for that case.
- **Presence-only** (no schema validation): every other media type, including `application/xml`, `multipart/form-data`, `application/x-www-form-urlencoded`, `text/plain`, and `application/octet-stream`. The validator confirms the spec declares the content type but does not check the body. The orchestrator marks these responses as `Skipped` for coverage reporting.
- **Presence-only** (no schema validation): every other media type, including `application/xml`, `multipart/form-data`, `application/x-www-form-urlencoded`, `text/plain`, and `application/octet-stream`. The validator confirms the spec declares the content type but does not check the body. When the matched media-type entry declares a `schema` (OpenAPI permits a schema on any media type, but this JSON Schema engine cannot evaluate a non-JSON one), the orchestrator marks the response/request as `Skipped` with a `skipReason` so the unvalidated body is surfaced in coverage rather than counted as a clean pass. A non-JSON entry with no `schema` has nothing to validate and stays a plain success.
- **Multipart `encoding` object**: per-part `contentType` / `headers` / `style` / `explode` are not consulted.
- **Cascading `additionalProperties: false` errors** are stripped automatically. opis's `PropertiesKeyword` skips its `addCheckedProperties()` call whenever any sub-property fails its schema, leaving `$checked` empty in the validation context. The follow-on `additionalProperties: false` keyword then reports every property the data carries — including ones explicitly declared in the schema's `properties` — as "additional". The validator walks opis's `ValidationError` tree, reads the raw list of "additional" property names from `args()['properties']`, and filters out names that ARE declared in the schema's `properties` keyword at that path. A single failure shows as one error, not a paired pseudo-error naming declared properties as not-allowed. Genuine additional properties still surface; mixed cases keep only the real extras in the message. The property-name comparison is fully structural (raw arrays + raw path segments — no string parsing of the rendered message for the names), so property names containing commas, whitespace, empty strings, or JSON-Pointer-escape-worthy characters survive correctly. The walker also descends through `items` for array-element segments, so cascades through `{ data: [Item] }`-shaped envelopes (single-schema items and Draft 07 tuple-form items, including the shape `OpenApiSchemaConverter` lowers OAS 3.1 `prefixItems` to) collapse the same way. The walker recognises only `properties.<name>` and `items` transitions and treats every other shape as unresolvable — composition keywords (`oneOf` / `allOf` / `anyOf`), `additionalProperties: <schema>`, `patternProperties`, `additionalItems`, and boolean schemas at item level all fall through to keeping the original message untouched, so a real additional-property violation is never silently swallowed.

Expand Down
66 changes: 65 additions & 1 deletion src/OpenApiRequestValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

namespace Studio\OpenApiContractTesting;

use RuntimeException;
use Studio\OpenApiContractTesting\Spec\OpenApiPathMatcher;
use Studio\OpenApiContractTesting\Spec\OpenApiSpecLoader;
use Studio\OpenApiContractTesting\Validation\Request\HeaderParameterValidator;
use Studio\OpenApiContractTesting\Validation\Request\ParameterCollector;
use Studio\OpenApiContractTesting\Validation\Request\PathParameterValidator;
use Studio\OpenApiContractTesting\Validation\Request\QueryParameterValidator;
use Studio\OpenApiContractTesting\Validation\Request\RequestBodyValidationResult;
use Studio\OpenApiContractTesting\Validation\Request\RequestBodyValidator;
use Studio\OpenApiContractTesting\Validation\Request\SecurityValidator;
use Studio\OpenApiContractTesting\Validation\Support\PathDiagnosticsFormatter;
Expand Down Expand Up @@ -171,16 +173,33 @@ public function validate(
// The boundary is per-sub-validator and permissive: a capture at one stage
// does NOT short-circuit later stages — every sub-validator still runs so
// a single test run surfaces as much contract drift as possible.
// The body validator returns a richer DTO (errors + an optional
// skipReason) rather than a bare string[], so it cannot flow through
// ValidatorErrorBoundary::safely() like the other sub-validators.
// validateBody() runs it behind the same narrow RuntimeException
// boundary inline — mirrors OpenApiResponseValidator::validateBody().
$bodyResult = $this->validateBody($specName, $method, $matchedPath, $operation, $body, $contentType, $version);

$errors = [
...$collected->specErrors,
...ValidatorErrorBoundary::safely('path', $specName, $method, $matchedPath, fn(): array => $this->pathValidator->validate($method, $matchedPath, $collected->parameters, $pathVariables, $version)),
...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, $body, $contentType, $version)),
...$bodyResult->errors,
];

if ($errors === []) {
// Issue #254: a non-JSON request Content-Type matched a spec
// media-type key declaring a `schema` this JSON-Schema engine
// cannot evaluate. No sibling validator failed, so the request
// is non-failing — but the body went unchecked, so surface a
// Skipped result (rather than a clean Success) and forward the
// reason to coverage tracking.
if ($bodyResult->skipReason !== null) {
return OpenApiValidationResult::skipped($matchedPath, $bodyResult->skipReason);
}

return OpenApiValidationResult::success($matchedPath);
}

Expand Down Expand Up @@ -229,6 +248,51 @@ public function validate(
return OpenApiValidationResult::failure($errors, $matchedPath);
}

/**
* Run the request-body validator behind the same narrow
* `RuntimeException` boundary {@see ValidatorErrorBoundary::safely()}
* applies to the other sub-validators: a `RuntimeException` (typically
* an opis/json-schema `SchemaException` raised from schema conversion
* or validation) is converted to an error string instead of aborting
* the orchestrator. The body validator returns a
* {@see RequestBodyValidationResult} DTO carrying an optional
* `skipReason`, so it cannot reuse the string[]-returning helper as-is
* — same reasoning as {@see OpenApiResponseValidator::validateBody()}.
* `\LogicException` and `\Error` still bubble so programmer bugs are
* not silently downgraded to contract errors.
*
* @param array<string, mixed> $operation
*/
private function validateBody(
string $specName,
string $method,
string $matchedPath,
array $operation,
DecodedBody $body,
?string $contentType,
OpenApiVersion $version,
): RequestBodyValidationResult {
try {
return $this->bodyValidator->validate($specName, $method, $matchedPath, $operation, $body, $contentType, $version);
} catch (RuntimeException $e) {
$previous = $e->getPrevious();
$previousSuffix = $previous !== null
? sprintf(' (caused by %s: %s)', $previous::class, $previous->getMessage())
: '';

return new RequestBodyValidationResult([sprintf(
"[%s] %s %s in '%s' spec: %s threw: %s%s",
'request-body',
$method,
$matchedPath,
$specName,
$e::class,
$e->getMessage(),
$previousSuffix,
)]);
}
}

/**
* @param string[] $specPaths
*/
Expand Down
33 changes: 27 additions & 6 deletions src/OpenApiResponseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -209,15 +209,36 @@ public function validate(
$version,
);

// The body validator returns ([], null) for two distinct cases:
// The body validator matched a non-JSON media-type key that declares
// a `schema` this JSON-Schema engine cannot evaluate (issue #254).
// The body was not checked, so surface a Skipped result rather than
// a clean Success — but only when headers also passed; a real header
// failure must still fail loudly (it falls through to the error
// merge below). matchedContentType is forwarded so coverage records
// the skip against that exact media-type row.
if ($bodyResult->skipReason !== null && $headerErrors === []) {
return OpenApiValidationResult::skipped(
$matchedPath,
$bodyResult->skipReason,
$statusCodeStr,
$bodyResult->matchedContentType,
);
}

// The body validator returns `errors: []` + `matchedContentType: null`
// (and `skipReason: null`, so the branch above did not fire) for two
// distinct cases:
// (a) 204-style — spec has no `content` block; nothing to validate,
// legitimately Success.
// (b) Spec declares only non-JSON content types (e.g. `text/plain`)
// and we have no schema engine for them; the result is "we
// didn't actually check anything". Without this branch the
// orchestrator would mark the response as a clean Success and
// coverage would credit the spec's declared content-type as
// validated even though no validation occurred.
// with no `schema` and no actual response Content-Type was
// supplied to look one up; the result is "we didn't actually
// check anything". Without this branch the orchestrator would
// mark the response as a clean Success and coverage would credit
// the spec's declared content-type as validated even though no
// validation occurred. (A non-JSON type that DID match a key
// declaring a `schema` is handled by the skipReason branch above
// and never reaches here.)
// Distinguishing them requires looking at the spec — `content`
// present + non-empty + bodyResult.matchedContentType null + body
// had no errors → case (b).
Expand Down
15 changes: 13 additions & 2 deletions src/OpenApiValidationResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@
* `matchedContentType` is the spec media-type key (with the spec author's
* original casing) the body was checked against, or null when no body
* lookup occurred (204, non-JSON-only specs, content-type-not-in-spec
* failures, skipped responses).
* failures, and most skipped responses). A Skipped result carries it
* only for the issue #254 case — a non-JSON media type whose declared
* `schema` this JSON-Schema engine cannot evaluate.
*
* @param string[] $errors
*/
Expand Down Expand Up @@ -110,19 +112,28 @@ public static function failure(
* the spec response map is consulted. Coverage tracking reconciles the
* literal status against any spec range keys (`5XX`/`5xx`/`default`) at
* compute time, marking the spec-declared response as `skipped`.
*
* `matchedContentType` is null for most skip cases (status-code skip,
* non-JSON-only specs with no Content-Type header — no spec media-type
* key was resolved). It carries the spec media-type key only when the
* skip happened *after* a content-type lookup matched a declared key —
* the "non-JSON media type with an unvalidatable `schema`" case (issue
* #254). Passing it through lets coverage record the skip against that
* exact media-type row instead of the wildcard bucket.
*/
public static function skipped(
?string $matchedPath = null,
?string $reason = null,
?string $matchedStatusCode = null,
?string $matchedContentType = null,
): self {
return new self(
OpenApiValidationOutcome::Skipped,
[],
$matchedPath,
$reason,
$matchedStatusCode,
null,
$matchedContentType,
);
}

Expand Down
57 changes: 57 additions & 0 deletions src/Validation/Request/RequestBodyValidationResult.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Validation\Request;

use InvalidArgumentException;
use Studio\OpenApiContractTesting\OpenApiRequestValidator;
use Studio\OpenApiContractTesting\OpenApiValidationResult;
use Studio\OpenApiContractTesting\Validation\Response\ResponseBodyValidationResult;

/**
* Outcome of {@see RequestBodyValidator::validate()}.
*
* `errors` carries the same string payload the validator previously returned
* directly (empty list = body acceptable). `skipReason`, when non-null, marks
* that the validator deliberately did NOT check the body even though a
* media-type key matched and that key declared a `schema`: the request
* Content-Type is a non-JSON media type this JSON-Schema engine cannot
* evaluate (issue #254). `errors` stays empty in that case — it is a skip,
* not a failure — and {@see OpenApiRequestValidator} turns it into an
* `OpenApiValidationResult::skipped()` (when no sibling validator failed) so
* the unvalidated body is not miscounted as a clean pass and the skip reason
* reaches coverage tracking.
*
* If a sibling validator (path / query / header / security) failed, the
* orchestrator builds a `failure()` instead and the `skipReason` is dropped
* — a genuine failure takes precedence over a skip.
*
* This mirrors {@see ResponseBodyValidationResult} on the response side;
* request-side coverage has no per-content-type dimension, so no
* `matchedContentType` is carried.
*
* @internal Not part of the package's public API. Do not use from user code.
*/
final readonly class RequestBodyValidationResult
{
/**
* @param string[] $errors
*
* @throws InvalidArgumentException when `skipReason` is set alongside a
* non-empty `errors` list — a skip means the body was deliberately
* not checked, which is mutually exclusive with reporting errors.
* Mirrors the `failure([])` guard on {@see OpenApiValidationResult}.
*/
public function __construct(
public array $errors,
public ?string $skipReason = null,
) {
if ($skipReason !== null && $errors !== []) {
throw new InvalidArgumentException(
'A skipped RequestBodyValidationResult cannot also carry errors: '
. 'a skip means the body was not checked.',
);
}
}
}
Loading
Loading