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
41 changes: 38 additions & 3 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,44 @@ read each intermediate section in order — behavioural changes compose.
## Within v1.x

The v1.x line is covered end-to-end by SemVer (see "v0.x → v1.0.0"
below for the surface contract). Minor releases are additive by default;
the only behavioural change so far is in v1.3.0 and is gated on an
already-opt-in flag.
below for the surface contract). Minor releases are additive by default.
Two behavioural changes exist so far: v1.3.0 (gated on an already-opt-in
flag) and v1.8.0 (`discriminator.mapping` enforcement, default-on with an
opt-out flag — see directly below).

### From v1.7.0 → v1.8.0

- **`discriminator.mapping` is now enforced** (#262). Previously the
converter stripped `discriminator` and emitted a one-shot
`E_USER_WARNING`; the underlying `oneOf` / `anyOf` was validated only as
a plain union, so a polymorphic body that lied about its type passed as
long as it matched any branch. The converter now lowers `discriminator` +
`mapping` into Draft-07 `if`/`then` conditionals so the discriminator
value steers validation toward a single branch.
- **Behaviour change**: a body whose discriminator value routes to a
branch it does not satisfy (e.g. `kty: RSA` carrying EC-only fields, or
an unknown discriminator value) now **fails** where it previously
passed. This is the contract bug the warning only narrated.
- **The `discriminator.mapping` `E_USER_WARNING` is removed.** This also
fixes Laravel consumers, whose `HandleExceptions` turned that advisory
warning into a fatal `ErrorException` on the first polymorphic contract
test. No per-consumer `set_error_handler` boilerplate is needed any
more.
- **Opt out**: set `enforce_discriminator: false` (Laravel
`config/openapi-contract-testing.php`) or
`<parameter name="enforce_discriminator" value="false"/>` (the PHPUnit
`OpenApiCoverageExtension`; `0` / `no` also work) to keep the old
strip-without-enforce behaviour (now also warning-free).
- **Malformed `discriminator`** blocks (missing/non-string
`propertyName`, non-array `mapping`, non-string mapping value,
unresolvable mapping pointer, non-object target) now surface as a loud
validation failure under enforcement, instead of being silently
dropped.
- **Known limitation**: self-referential discriminator chains (a subtype
that re-contains the same base discriminator via `allOf` + `$ref`) are
enforced at the first recursion level; the inner re-appearance is
stripped without re-lowering (the outer branch already enforces it).
See `docs/supported-features.md` → "Schema features" → `discriminator`.

### From v1.3.0 → v1.4.0

Expand Down
6 changes: 6 additions & 0 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Add the coverage extension to your `phpunit.xml`:
| `console_output` | No | `default` | Console output mode: `default`, `all`, `uncovered_only`, or `active_only` (overridden by `OPENAPI_CONSOLE_OUTPUT` env var) |
| `sidecar_dir` | No | `sys_get_temp_dir()/openapi-coverage-sidecars` | Directory paratest workers drop per-worker JSON sidecars into. Used only under parallel test runners — see [Parallel test runners](parallel.md) |
| `default_testsuite_as_full` | No | `false` | Opt-in. When `true` and PHPUnit's `includeTestSuites` resolves exactly to the configured `defaultTestSuite`, treat the run as full instead of partial (so `strict_required` and coverage outputs aren't suppressed). See [default_testsuite_as_full opt-in](ci.md#default_testsuite_as_full-opt-in) for trade-offs |
| `enforce_discriminator` | No | `true` | When `true` (default), `discriminator` + `mapping` is enforced via `if`/`then` lowering so a body that lies about its type fails. Set to `false` (or `0` / `no`) to strip `discriminator` without enforcing (no warning either). See [Schema features → discriminator](supported-features.md#schema-features) |

*Not required if you call `OpenApiSpecLoader::configure()` manually.

Expand All @@ -81,6 +82,11 @@ return [
// 0 = unlimited (reports all errors).
'max_errors' => 20,

// Enforce `discriminator` + `mapping` by lowering it into if/then
// conditionals so a body that lies about its type fails (default true).
// Set false to strip discriminator without enforcing (no warning either).
'enforce_discriminator' => true,

// Automatically validate every TestResponse produced by Laravel HTTP
// helpers (get(), post(), etc.) against the OpenAPI spec. Defaults to
// false for backward compatibility.
Expand Down
12 changes: 8 additions & 4 deletions docs/supported-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,16 @@ Detection looks at each property schema's own top-level `readOnly` / `writeOnly`
- **Validated** (delegated to opis Draft 07): `type`, `enum`, `multipleOf`, `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum`, `minLength`/`maxLength`/`pattern`, `minItems`/`maxItems`/`uniqueItems`, `minProperties`/`maxProperties`/`required`, `additionalProperties` (`true` / `false` / schema), `allOf` / `oneOf` / `anyOf` / `not`.
- **`format`** (validated by opis Draft 06+): the canonical 19-entry set (`email`, `uuid`, `date`, `date-time`, `uri`, `ipv4`, `ipv6`, `hostname`, `regex`, `json-pointer`, …). The full list is the authoritative `KNOWN_OPIS_FORMATS` constant in `src/Spec/OpenApiSchemaConverter.php` — keeping it in one place avoids drift when opis adds formats. Unknown values (e.g. `format: emial` typo for `email`) emit a one-shot `E_USER_WARNING` per format value, since opis silently accepts any value for unrecognised formats. Non-string `format` values fire a separate malformed-spec warning.
- **Advisory `format`** (deliberately not enforced, no warning): `int32`, `int64`, `float`, `double`, `byte`, `binary`, `password`. Treated as documentation hints per OAS conventions; see `ADVISORY_FORMATS` constant.
- **Lowered**: `const` → `enum: [value]` (3.1).
- **Stripped**: `discriminator` (including `mapping`), `xml`, `externalDocs`, `example` / `examples`, `deprecated`, OAS-only `nullable`/`readOnly`/`writeOnly` after enforcement (3.0), and Draft 2020-12 keys `$dynamicRef` / `$dynamicAnchor` / `contentSchema` (3.1).
- **Lowered**: `const` → `enum: [value]` (3.1); `discriminator` + `mapping` → an `allOf` of `if`/`then` conditionals (default; see `discriminator` below).
- **Stripped**: `xml`, `externalDocs`, `example` / `examples`, `deprecated`, OAS-only `nullable`/`readOnly`/`writeOnly` after enforcement (3.0), and Draft 2020-12 keys `$dynamicRef` / `$dynamicAnchor` / `contentSchema` (3.1). `discriminator` is also stripped when enforcement is turned off (`enforce_discriminator: false`).
- **Validated via opis (Draft 06+)**: `patternProperties`, `contentMediaType`, `contentEncoding`. These are JSON Schema keywords that opis implements natively, so your constraints are enforced.
- **Not supported (loud E_USER_WARNING when first encountered)**: `unevaluatedProperties`, `unevaluatedItems`. These are 2019-09 keywords with no Draft 07 equivalent — opis silently ignores them, so the warning surfaces specs that depend on them. Rewrite using `additionalProperties: false` plus explicit `properties` to enforce object closure.
- **Advisory-only (loud E_USER_WARNING when first encountered)**: `dependentSchemas`, `dependentRequired`. These 2019-09 property-dependency keywords are not registered by opis Draft 07, so the constraint is dropped wholesale — a payload carrying the trigger property without its dependents passes silently. Rewrite as a Draft 07 conditional with `if` / `then` / `else` (the `if` clause tests for the trigger property, the `then` clause carries the dependent requirement).
- **`discriminator`**: the keyword is dropped; the underlying `oneOf` / `anyOf` is still validated as a union, but `discriminator.mapping` does not steer validation toward a single branch. When `mapping` is non-empty the converter emits a one-shot `E_USER_WARNING` so polymorphic specs with serialiser bugs surface as a loud signal rather than silently passing through any valid branch.
- **`discriminator`** (enforced by default, #262): when a schema declares `discriminator` with a non-empty `mapping`, the converter lowers it into an `allOf` of an unknown-value guard (the discriminator property must be present and one of the mapping keys) plus one `if`/`then` per mapping value, where `then` is the resolved subtype schema. The discriminator value therefore steers validation toward a single branch — a body that lies about its type (e.g. `kty: RSA` carrying EC-only fields) fails instead of passing the underlying `oneOf` / `anyOf` union. This is stricter than the OAS spec strictly requires (the discriminator is officially a tooling hint), which is exactly what a contract-testing tool wants. No `E_USER_WARNING` is emitted.
- **Opt out**: set `enforce_discriminator: false` (Laravel config) or `<parameter name="enforce_discriminator" value="false"/>` (the PHPUnit extension; `0` / `no` also work) to restore the historical behaviour — `discriminator` is stripped and the mapping is not enforced (and no warning is emitted).
- **Malformed blocks**: with enforcement on, a structurally invalid `discriminator` (missing/non-string `propertyName`, non-array `mapping`, non-string mapping value, an unresolvable mapping pointer, or a pointer to a non-object) surfaces as a loud validation failure rather than silently passing.
- **Known limitation**: a self-referential discriminator chain (a subtype that, via `allOf` + `$ref`, re-contains the *same* base discriminator — the inheritance idiom) is enforced at the first recursion level; the inner re-appearance of that same discriminator is stripped without re-lowering (the outer branch already routes to and enforces that exact subtype). This terminates the lowering and avoids combinatorial blow-up while still enforcing the outer branch selection. Subtype-specific constraints (e.g. `required`) are unaffected — they live in the outer `then`.
- **`nullable` + `discriminator`** (3.0): a `null` body fails the discriminated-object branch (the lowered guard requires the discriminator property). Model a null-tolerant polymorphic field with an explicit `oneOf` including `{type: 'null'}` if needed.
- **`readOnly` / `writeOnly`**: enforced at the property's own top level only (see [readOnly / writeOnly enforcement](#readonly--writeonly-enforcement)).

## HTTP methods
Expand All @@ -82,7 +86,7 @@ The library uses PHP's native `trigger_error(..., E_USER_WARNING)` as the loud-s
| Category prefix | Source | Dedup key |
|---|---|---|
| `[security]` | `SecurityValidator` (`oauth2`, `openIdConnect`, `mutualTLS`, `http-basic`, `http-digest`) | scheme name |
| `[OpenAPI Schema]` | `OpenApiSchemaConverter` (`unevaluatedProperties` / `unevaluatedItems`, `dependentSchemas` / `dependentRequired`, `discriminator.mapping`, unknown / malformed `format`) | per-keyword / per-format-value |
| `[OpenAPI Schema]` | `OpenApiSchemaConverter` (`unevaluatedProperties` / `unevaluatedItems`, `dependentSchemas` / `dependentRequired`, unknown / malformed `format`) | per-keyword / per-format-value |

**How to consume:**

Expand Down
34 changes: 34 additions & 0 deletions src/Exception/MalformedDiscriminatorException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Studio\OpenApiContractTesting\Exception;

use RuntimeException;
use Studio\OpenApiContractTesting\Spec\OpenApiSchemaConverter;
use Studio\OpenApiContractTesting\Validation\Support\MalformedSpecNode;
use Throwable;

/**
* Thrown by {@see OpenApiSchemaConverter}
* when a `discriminator` block is structurally malformed while enforcement is
* active (Issue #262) — a missing / non-string `propertyName`, a non-array
* `mapping`, a non-string mapping value, or a mapping pointer that does not
* resolve to a schema object in the root spec.
*
* Extends {@see RuntimeException} so the body validators' existing
* `validateBody()` boundary catches it and surfaces it as one loud, clean
* validation failure (`"... threw: Malformed 'discriminator' ..."`), exactly
* like the {@see MalformedSpecNode}
* structural guards — rather than letting a malformed spec silently bypass the
* enforcement it opted into.
*
* @internal Not part of the package's public API. Do not use from user code.
*/
final class MalformedDiscriminatorException extends RuntimeException
{
public function __construct(string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
}
25 changes: 23 additions & 2 deletions src/Laravel/ValidatesOpenApiSchema.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use Studio\OpenApiContractTesting\Spec\OpenApiSpecResolver;
use Studio\OpenApiContractTesting\Validation\Request\SecuritySchemeIntrospector;
use Studio\OpenApiContractTesting\Validation\Support\ContentTypeMatcher;
use Studio\OpenApiContractTesting\Validation\Support\DiscriminatorEnforcement;
use Studio\OpenApiContractTesting\Validation\Support\HeaderNormalizer;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
Expand Down Expand Up @@ -716,6 +717,7 @@ private function runRequestAssertion(

private function getOrCreateRequestValidator(): OpenApiRequestValidator
{
$this->applyDiscriminatorEnforcementConfig();
$resolvedMaxErrors = $this->resolveMaxErrors();
$resolvedSkipCodes = $this->resolveSkipRequestValidationResponseCodes();

Expand Down Expand Up @@ -894,6 +896,7 @@ private function findOperationForRequest(array $paths, string $method, string $p

private function getOrCreateValidator(): OpenApiResponseValidator
{
$this->applyDiscriminatorEnforcementConfig();
$resolvedMaxErrors = $this->resolveMaxErrors();
$resolvedSkipCodes = $this->resolveSkipResponseCodes();

Expand All @@ -918,6 +921,8 @@ private function getOrCreateValidator(): OpenApiResponseValidator
*/
private function buildOneOffValidator(array $extraSkipResponseCodes): OpenApiResponseValidator
{
$this->applyDiscriminatorEnforcementConfig();

return new OpenApiResponseValidator(
maxErrors: $this->resolveMaxErrors(),
skipResponseCodes: array_merge(
Expand All @@ -927,6 +932,18 @@ private function buildOneOffValidator(array $extraSkipResponseCodes): OpenApiRes
);
}

/**
* Push the `enforce_discriminator` config flag (Issue #262, default on)
* into the process-global {@see DiscriminatorEnforcement} gate the body
* validators read at conversion time. Called from every validator-build
* path so the current test's config is reflected even when the cached
* validator instance is reused.
*/
private function applyDiscriminatorEnforcementConfig(): void
{
DiscriminatorEnforcement::configure($this->resolveBoolConfig('enforce_discriminator', true));
}

private function resolveMaxErrors(): int
{
$maxErrors = config('openapi-contract-testing.max_errors', 20);
Expand Down Expand Up @@ -1097,10 +1114,14 @@ private function isAutoInjectDummyCredentialsEnabled(): bool
* `'auto_X' => env('X')` (strings like "true" / "1") works without an
* explicit cast. Anything else raises a loud PHPUnit failure so a typo
* is not silently read as "off".
*
* `$default` is returned when the key is entirely absent (most flags
* default off, but `enforce_discriminator` defaults on — Issue #262); an
* explicit `null` value still coerces to false.
*/
private function resolveBoolConfig(string $key): bool
private function resolveBoolConfig(string $key, bool $default = false): bool
{
$raw = config('openapi-contract-testing.' . $key, false);
$raw = config('openapi-contract-testing.' . $key, $default);

if ($raw === true) {
return true;
Expand Down
10 changes: 10 additions & 0 deletions src/Laravel/config.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
// 0 = unlimited (reports all errors).
'max_errors' => 20,

// When true (the default), a schema's `discriminator` + `mapping` is
// lowered into Draft-07 `if`/`then` conditionals so the discriminator
// value actually steers validation toward a single branch — a body that
// lies about its type (e.g. `kty: RSA` carrying EC-only fields) fails
// instead of passing the underlying oneOf/anyOf union. Set to false to
// restore the historical behaviour (discriminator stripped, mapping not
// enforced) for specs that rely on the loose union semantics. See
// docs/supported-features.md "Discriminator" for the full note.
'enforce_discriminator' => true,

// When true, every TestResponse produced by Laravel HTTP test helpers
// (get(), post(), etc.) is validated against the OpenAPI spec at creation
// time, without requiring an explicit assertResponseMatchesOpenApiSchema()
Expand Down
10 changes: 8 additions & 2 deletions src/OpenApiRequestValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
use Studio\OpenApiContractTesting\Validation\Request\RequestBodyValidationResult;
use Studio\OpenApiContractTesting\Validation\Request\RequestBodyValidator;
use Studio\OpenApiContractTesting\Validation\Request\SecurityValidator;
use Studio\OpenApiContractTesting\Validation\Support\DiscriminatorContext;
use Studio\OpenApiContractTesting\Validation\Support\DiscriminatorEnforcement;
use Studio\OpenApiContractTesting\Validation\Support\MalformedSpecNode;
use Studio\OpenApiContractTesting\Validation\Support\PathDiagnosticsFormatter;
use Studio\OpenApiContractTesting\Validation\Support\SchemaValidatorRunner;
Expand Down Expand Up @@ -245,7 +247,10 @@ public function validate(
// 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);
// Carry the resolved root + enforce gate so the body validator can
// lower `discriminator.mapping` into enforceable conditionals (#262).
$discriminatorContext = new DiscriminatorContext($spec, DiscriminatorEnforcement::isEnabled());
$bodyResult = $this->validateBody($specName, $method, $matchedPath, $operation, $body, $contentType, $version, $discriminatorContext);

$errors = [
...$collected->specErrors,
Expand Down Expand Up @@ -338,9 +343,10 @@ private function validateBody(
DecodedBody $body,
?string $contentType,
OpenApiVersion $version,
DiscriminatorContext $discriminatorContext,
): RequestBodyValidationResult {
try {
return $this->bodyValidator->validate($specName, $method, $matchedPath, $operation, $body, $contentType, $version);
return $this->bodyValidator->validate($specName, $method, $matchedPath, $operation, $body, $contentType, $version, $discriminatorContext);
} catch (RuntimeException $e) {
$previous = $e->getPrevious();
$previousSuffix = $previous !== null
Expand Down
Loading
Loading