Skip to content

feat(schema): enforce discriminator.mapping via if/then lowering#263

Merged
wadakatu merged 5 commits into
mainfrom
feat/enforce-discriminator-mapping-issue-262
Jun 3, 2026
Merged

feat(schema): enforce discriminator.mapping via if/then lowering#263
wadakatu merged 5 commits into
mainfrom
feat/enforce-discriminator-mapping-issue-262

Conversation

@wadakatu
Copy link
Copy Markdown
Collaborator

@wadakatu wadakatu commented Jun 3, 2026

概要

discriminator.mapping を「strip + 一度きりの E_USER_WARNING」から、Draft-07 の if/then lowering による実際の enforcement へ置き換えます。型を偽った polymorphic body(例: kty: RSA なのに EC 専用フィールドを持つ)が、従来は oneOf/anyOf union のいずれかに合致すれば通っていたのを、正しく FAIL させます。

変更内容

discriminator は OAS 固有キーで opis(Draft 07)が解釈できないため従来は strip していました。本 PR では discriminator + mappingallOf(unknown-value ガード + 値ごとの if/thenthen は解決済み subtype スキーマ)へ lowering し、discriminator 値で単一ブランチへ検証を導きます。

  • converter: OpenApiSchemaConverter から discriminator の strip と警告メソッドを撤去し、lowerDiscriminator() を追加。mapping ポインタ(#/... または bare-name shorthand)を解決し、各 subtype を再帰 convert
  • 再帰ガード: ref は load 時に全インライン解決されるため subtype が base discriminator を再内包する(継承イディオム)。propertyName + mapping キーのシグネチャ方式ガードで base↔subtype サイクルを終端し、組み合わせ爆発を回避
  • root のスレッディング: 解決済み root を request/response orchestrator → body validator → convert() へ伝播する DiscriminatorContext を新設(mapping ポインタの解決に必要)
  • malformed 検出: propertyName 欠落/非文字列・mapping 非配列・値非文字列・ポインタ解決不能・非オブジェクトは MalformedDiscriminatorExceptionRuntimeException 派生)を throw し、既存の body-validator 境界で loud な validation failure として surface
  • config: enforce_discriminator(既定 ON)で gate。Laravel config/trait と PHPUnit OpenApiCoverageExtension に配線。off で従来どおり strip(警告なし)
  • 警告撤去の副次効果: Laravel の HandleExceptionsE_USER_WARNING を致命的な ErrorException に変換していた問題(contract テストが落ちる)も解消
  • テスト: converter unit(lowering 構造 / bare-name / 再帰 convert / 自己参照終端 / oneOf 併存 / opis e2e / malformed throw 6本 / disabled strip)、新 fixture tests/fixtures/specs/jwks.json(暗黙継承 = motivating case)+ 統合テスト(valid RSA/EC・lying type FAIL・unknown kty FAIL)。composer ci グリーン(1871 tests / PHPStan / cs-fixer)
  • docs: supported-features.md / setup.md / UPGRADING.md(v1.7.0 → v1.8.0 の挙動変化節)を更新

挙動変化(要確認)

  • 誤った discriminator 値の body が新たに FAIL する(contract enforcement の強化、既定 ON)
  • discriminator.mappingE_USER_WARNING撤去

既知の制約(docs 記載)

  • 自己参照 discriminator チェーンは第 1 再帰までの enforcement(内側はガードのみに degrade)
  • 3.0 nullable + discriminatornull body が discriminated-object ブランチで FAIL

関連情報

wadakatu added 5 commits June 4, 2026 01:50
Replace the strip-and-E_USER_WARNING handling of `discriminator.mapping`
with real enforcement: the converter now lowers `discriminator` + `mapping`
into a Draft-07 `allOf` of an unknown-value guard plus one `if`/`then` per
mapping value, so the discriminator value steers validation toward a single
branch. A body that lies about its type (e.g. `kty: RSA` carrying EC-only
fields) now fails instead of passing the underlying oneOf/anyOf union.

The mapping pointers are resolved against the resolved root spec, which is
threaded from the request/response orchestrators through the body validators
into the converter via a new DiscriminatorContext. A signature-based
recursion guard handles the base<->subtype cycle created by eager $ref
inlining (the inheritance idiom) and terminates without combinatorial
blow-up. Malformed discriminator blocks throw MalformedDiscriminatorException,
surfaced as a clean validation failure by the existing body-validator
boundary.

Enforcement is gated by `enforce_discriminator` (default on), wired through
the Laravel config/trait and the PHPUnit OpenApiCoverageExtension. The
removed warning also fixes Laravel consumers whose error handler turned it
into a fatal ErrorException.

Closes #262
…nator

Update supported-features.md (discriminator now enforced via if/then
lowering, opt-out flag, malformed/self-reference/nullable notes; remove it
from the stripped list and the warning-channel table), setup.md (new
enforce_discriminator parameter + Laravel config key), and UPGRADING.md
(new v1.7.0 -> v1.8.0 behavioural-change section).
The recursion guard's signature was computed from the discriminator
propertyName plus the mapping KEY set only, excluding the target pointers.
Two genuinely distinct discriminators that share a property name and key set
(but map to different schemas) collided, so a nested distinct discriminator
re-encountered inside a `then` branch was stripped instead of lowered —
a silent under-enforcement. Fold the resolved target into each signature
pair so only an identical mapping (the self-reference case the guard is for)
collides. Adds a regression test plus enforce=false-with-root and
nullable+discriminator coverage.
…orcement

Adds the previously-missing coverage for the enforce_discriminator gate:
the PHPUnit extension parameter (off disables, absent keeps default-on), the
Laravel trait wiring (default-on, explicit-off, string coercion, loud failure
on a non-boolean value), and request-side enforcement against the JWKS
implicit-inheritance fixture (a POST endpoint where the bare union would pass
but the lowered discriminator catches a lying body).
The documented PHPUnit opt-out used value="off", but the boolean parameter
reader only treats false/0/no as off, so "off" left enforcement on. Switch
the docs/comment to value="false" (the repo convention for boolean params).
Also correct the self-reference limitation note: the inner re-appearance is
stripped without re-lowering, not "degraded to the unknown-value guard only".
@wadakatu wadakatu force-pushed the feat/enforce-discriminator-mapping-issue-262 branch from e2c292a to 6ae42c2 Compare June 3, 2026 16:50
@wadakatu wadakatu merged commit 0e41606 into main Jun 3, 2026
16 checks passed
@wadakatu wadakatu deleted the feat/enforce-discriminator-mapping-issue-262 branch June 3, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(schema): enforce discriminator.mapping via if/then lowering instead of strip + E_USER_WARNING (warn-only path breaks Laravel consumers)

1 participant