Skip to content

feat(schema): resolve #[BoundToOpenApiEnum] from optional enum_spec_base_path#171

Merged
wadakatu merged 2 commits into
mainfrom
feat/170-enum-spec-base-path
May 8, 2026
Merged

feat(schema): resolve #[BoundToOpenApiEnum] from optional enum_spec_base_path#171
wadakatu merged 2 commits into
mainfrom
feat/170-enum-spec-base-path

Conversation

@wadakatu
Copy link
Copy Markdown
Collaborator

@wadakatu wadakatu commented May 7, 2026

Summary

Adds an opt-in enum_spec_base_path extension parameter (and matching OpenApiSpecLoader::configure(enumBasePath: ...) argument) used only when resolving #[BoundToOpenApiEnum] paths. When the parameter is omitted, behaviour is bit-for-bit identical to v1.1.x — single-root projects need to change nothing.

<extensions>
    <bootstrap class="Studio\OpenApiContractTesting\PHPUnit\OpenApiCoverageExtension">
        <parameter name="spec_base_path" value="openapi/bundled"/>
        <parameter name="enum_spec_base_path" value="openapi"/>     <!-- new -->
        <parameter name="specs" value="front,store,admin"/>
    </bootstrap>
</extensions>
// before — leaks the bundled root choice into the attribute
#[BoundToOpenApiEnum('../_shared/components/schemas/enums/NotificationCodeEnum.json')]

// after
#[BoundToOpenApiEnum('_shared/components/schemas/enums/NotificationCodeEnum.json')]

Why

Per #170: #[BoundToOpenApiEnum] resolution is currently coupled to spec_base_path. Projects that point spec_base_path at openapi/bundled/ (where orval-readable aggregates live) have to write '../_shared/...' in every attribute to reach per-enum source JSONs that deliberately live outside the bundle root. The parent-traversal is awkward, fragile under bundle-layout refactors, and visually leaks the bundle directory choice into every attributed enum.

Workarounds rejected for the reasons spelled out in #170 — duplicating per-enum JSONs under bundled/ re-creates the exact drift this library exists to detect, and re-pointing spec_base_path at openapi/ would break the existing {base}/{spec}.json lookup.

Fixes #170.

Verification

  • composer test passes — 1252 tests, 3018 assertions (+10 new across the review fixup commit, on top of the original +10 from the feature commit)
  • composer stan passes (PHPStan level 6 clean)
  • composer cs-check passes (PHP-CS-Fixer clean)

End-to-end exercise: tests/Integration/EnumDriftIntegrationTest::bundled_external_layout_resolves_via_enum_spec_base_path and three new bootstrap-level cases in OpenApiCoverageExtensionEnumDriftBootstrapTest (auto-discovery clean, auto-discovery drift, orphaned-parameter FATAL) drive the headline dogfood layout end-to-end.

Notes for reviewers

Public API surface added (intentionally additive — SemVer minor):

  • OpenApiSpecLoader::configure(..., ?string $enumBasePath = null) — optional 6th argument, defaults preserve all existing call sites. Empty / whitespace-only value rejected with InvalidArgumentException to avoid the unhelpful "is not a directory: " (empty) diagnostic later.
  • OpenApiSpecLoader::getEnumBasePath(): ?string — non-throwing on purpose. Absence is the documented default and the asserter relies on the null return to fall back to spec_base_path. (Contrast with getBasePath() which throws because spec_base_path is functionally required for spec lookup.)
  • OpenApiCoverageExtension parameter enum_spec_base_path — relative paths resolve against getcwd(), identical to spec_base_path / output_file / sidecar_dir. trim()'d on read so XML editing artefacts (leading newlines, etc.) don't silently coerce to getcwd().
  • EnumBindingException::forConfig() — sibling to forScan for global configuration failures (no enumFqcn / specPath because the failure isn't tied to any specific binding).
  • New EnumBindingReason cases: EnumBasePathNotFound (parameter set but path doesn't exist) and EnumSpecBasePathOrphaned (parameter set without spec_base_path, or empty/whitespace value).

Design notes:

  • EnumDriftAsserter::resolveEnumBasePath() is the new private helper — it consults getEnumBasePath() first and only falls back to getBasePath() when the new parameter is unset.
  • Setting enum_spec_base_path to the same value as spec_base_path is functionally equivalent to omitting it (the opt-in branch additionally validates with is_dir() before resolving any binding; the fallback branch defers that check to per-file file_exists() lookups). Pinned by a field-by-field detectAll() comparison test.
  • EnumBasePathNotFound / EnumSpecBasePathOrphaned are both routed through forConfig() — the misconfiguration is global, so enumFqcn / specPath are intentionally null instead of being set to an arbitrary "first failing binding".
  • Fail-loud-on-misconfiguration is preserved: empty / whitespace / orphaned enum_spec_base_path all FATAL at PHPUnit bootstrap with diagnostics also written to GITHUB_STEP_SUMMARY.
  • oneOf enum union resolution is explicitly out of scope (called out in feat(schema): allow #[BoundToOpenApiEnum] paths to resolve from a secondary base path (or bundled-external root) #170 as a known limitation tracked from feat(schema): enum drift detection between OpenAPI spec and bound PHP enums #166).

Review fixups

Commit 8376c8c addresses the multi-agent code review on the initial commit:

  • Critical: orphaned and empty enum_spec_base_path no longer silently dropped (review C1, C2)
  • Important: forConfig() factory for global config failures (I4); docblock / README accuracy (I1, I2, I3); auto-discovery + bootstrap integration tests (I5); strengthened no-op + trailing-slash unit tests (I6, S3)
  • Suggestion: empty enumBasePath rejected at the loader's configure() API surface (S1)

Follow-up

…ase_path

Adds an opt-in `enum_spec_base_path` extension parameter (and matching
`OpenApiSpecLoader::configure(enumBasePath: ...)` argument) used only when
resolving `#[BoundToOpenApiEnum]` paths. When omitted, behaviour is bit-for-
bit identical to pre-1.2.0 — single-root projects need no changes.

Motivated by the bundled-external dogfood layout in issue #170: projects
that point `spec_base_path` at `openapi/bundled/` (where orval-readable
aggregates live) want per-enum sources under `openapi/_shared/...` to bind
without writing `'../_shared/...'` in every attribute.

A misconfigured `enum_spec_base_path` (parameter set but the directory
does not exist) surfaces via `EnumBindingException` with the new
`EnumBindingReason::EnumBasePathNotFound`, so a typo cannot silently
fall through to a misleading `SpecFileNotFound` on every binding.

Closes #170.
… doc accuracy)

Aggregates the multi-agent review findings from PR #171:

C1+C2+S2 — extension parameter hardening:
  - Move enum_spec_base_path read out of the spec_base_path gate so an
    orphaned parameter (e.g. typo'd spec_base_path) is detected as a
    misconfiguration instead of silently dropped.
  - trim() the value; treat empty / whitespace-only as absent and FATAL.
  - FATAL diagnostic also written to GITHUB_STEP_SUMMARY for visibility.

S1 — loader configure() rejects empty enumBasePath:
  Throw InvalidArgumentException at the API surface, matching the
  allowRemoteRefs pairing checks. Prevents the unhelpful
  "Configured enum_spec_base_path is not a directory: " (empty after
  the colon) message later.

I4 — type design:
  Add EnumBindingException::forConfig() (sibling to forScan) for global
  configuration failures. Switch the EnumBasePathNotFound throw to use
  it so enumFqcn / specPath are no longer set to an arbitrary "first
  failing binding" — the failure is global, not per-binding. Add new
  reason EnumSpecBasePathOrphaned for the parameter-validation FATALs.

I1+I2+I3 — comment / doc accuracy:
  - Soften resolveEnumBasePath() docblock claim about "spec_base_path
    not configured at all" (configure() requires basePath, so the case
    is mostly theoretical).
  - Replace "pre-1.2.0 behavior" with "pre-issue-#170 behavior" — issue
    numbers are stable, version strings rot.
  - Soften README "no-op" claim to "functionally equivalent (the
    opt-in branch additionally validates with is_dir() before resolving
    any binding)".

I5 — bootstrap-level integration tests:
  Three new cases in OpenApiCoverageExtensionEnumDriftBootstrapTest
  exercising auto-discovery + enum_spec_base_path round-trip: clean
  pass, drift detection, and orphaned-parameter FATAL. Refactor
  runPhpunit() helper to accept an optional spec_base_path override.

I6+S3 — strengthen unit coverage:
  - Replace the "did it throw?"-shaped no-op equivalence test with a
    field-by-field comparison of detectAll() reports between the
    fallback branch and enumBasePath = basePath branch.
  - Add a trailing-slash end-to-end test through the asserter.

S5 — deferred to issue #172 (CoverageMergeCommand propagation).
@wadakatu wadakatu merged commit d0bc6fd into main May 8, 2026
13 checks passed
@wadakatu wadakatu deleted the feat/170-enum-spec-base-path branch May 8, 2026 00:52
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): allow #[BoundToOpenApiEnum] paths to resolve from a secondary base path (or bundled-external root)

1 participant