# Gesso > Every page listed in llms.txt, concatenated in the same order. Each page begins with its Source URL. Source: https://studio-design.github.io/gesso/quickstarts/core.md # Core / PHPUnit quickstart ```bash composer require --dev "studio-design/gesso:^2.0" ``` Register the extension in `phpunit.xml`. It configures the spec loader and prints the coverage report; without it the first validation call throws `OpenApiSpecLoader base path not configured`: ```xml ``` Copy the minimal [`examples/core`](https://github.com/studio-design/gesso/tree/main/examples/core) project. Its test validates a JSON response without a framework adapter. The validator records the coverage observation itself, so this is the whole test — the endpoint coverage table appears without any tracker call: ```php $result = (new OpenApiResponseValidator(new StrictRequiredTracker()))->validate( 'petstore', 'GET', '/pets', 200, [['id' => 1, 'name' => 'Fido']], 'application/json', ); self::assertTrue($result->isValid(), $result->errorMessage()); ``` Run it: ```bash composer test ``` The PHPUnit extension prints the endpoint coverage report after the passing test. For applications using PSR-7 messages, continue with the [PSR-7 guide](../psr7.md) — `OpenApiPsr7Validator` records coverage itself. --- Source: https://studio-design.github.io/gesso/quickstarts/laravel.md # Laravel quickstart ```bash composer require --dev "studio-design/gesso:^2.0" php artisan vendor:publish --tag=gesso ``` Register the extension in `phpunit.xml`. This is what configures the spec loader for the runtime validator; `gesso.spec_base_path` in the published `config/gesso.php` does not — it only feeds the `gesso:routes` and `gesso:stubs` Artisan commands: ```xml ``` Set `default_spec` to `petstore`, add `ValidatesOpenApiSchema` to your base test case, then assert a normal Laravel response: ```php $response = $this->getJson('/pets'); $response->assertOk(); $this->assertResponseMatchesOpenApiSchema($response); ``` The complete [`examples/laravel`](https://github.com/studio-design/gesso/tree/main/examples/laravel) fixture installs and runs in CI: ```bash composer test ``` Its second test enables `auto_assert` and `auto_validate_request`, demonstrating validation without an explicit assertion. The PHPUnit extension prints coverage for both tests. Already generating your spec with Scramble? Follow the [Scramble integration recipe](../recipes/scramble.md) to validate generated contracts, cover 401/404/422 responses, and gate untested contract changes in CI. --- Source: https://studio-design.github.io/gesso/quickstarts/symfony.md # Symfony quickstart ```bash composer require --dev "studio-design/gesso:^2.0" symfony/http-foundation symfony/http-kernel ``` Register the extension in `phpunit.xml`. Gesso finds spec files through the PHPUnit extension, not a Symfony bundle: ```xml ``` Mix `Studio\Gesso\Symfony\OpenApiAssertions` into a PHPUnit or `WebTestCase` class and name the spec with `#[OpenApiSpec]` (or override `openApiSpec()`): ```php use PHPUnit\Framework\TestCase; use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Symfony\OpenApiAssertions; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; #[OpenApiSpec('petstore')] class PetsApiTest extends TestCase { use OpenApiAssertions; public function test_pets_index(): void { $request = Request::create('/pets', 'GET'); $response = new Response('[{"id":1,"name":"Fido"}]', 200, [ 'Content-Type' => 'application/json', ]); $this->assertResponseMatchesOpenApiSchema($request, $response); } } ``` The trait adds three assertions: `assertResponseMatchesOpenApiSchema($request, $response)`, `assertRequestMatchesOpenApiSchema($request)`, and `assertClientMatchesOpenApiSchema($client)`, which runs both against the last client call. Run the CI-tested [`examples/symfony`](https://github.com/studio-design/gesso/tree/main/examples/symfony) fixture: ```bash composer test ``` For a full application, call `assertClientMatchesOpenApiSchema($client)` after `KernelBrowser::request()`. Both forms record coverage. --- Source: https://studio-design.github.io/gesso/quickstarts/pest.md # Pest quickstart ```bash composer require --dev "studio-design/gesso:^2.0" pestphp/pest ``` Pest runs on PHPUnit, so the spec loader comes from the same extension. Register it in `phpunit.xml` — without it the first expectation throws `OpenApiSpecLoader base path not configured`: ```xml ``` Use the automatically registered expectation with a Laravel response: ```php expect($this->getJson('/pets'))->toMatchOpenApiResponseSchema(); ``` The runnable [`examples/pest`](https://github.com/studio-design/gesso/tree/main/examples/pest) project includes response and request expectations and is executed in CI. See the [Pest guide](../pest-plugin.md) for setup and supported expectation arguments. --- Source: https://studio-design.github.io/gesso/doctor.md # Doctor command `gesso doctor` answers one focused question before the test suite starts: can this package load and enforce the selected OpenAPI contract as configured? It is not a replacement for a semantic or style linter such as Spectral or Redocly. ## Scope `gesso doctor` is a preflight for a local OpenAPI file. It confirms Gesso can load and enforce the document — version and dialect, references, warned-about keywords, structurally valid operations — and exits non-zero when it can't. It does not fetch a deployed spec URL, and it does not compare the checked-in spec against what a running service returns. `--allow-remote-refs` only covers `$ref` targets from the local entry document. ## Basic usage ```bash vendor/bin/gesso doctor \ --spec=openapi/front.yaml \ --strip-prefix=/api ``` The command checks file readability and parser availability, the declared OpenAPI version and JSON Schema dialect, internal and external references, schema keywords that the validator warns about, and structurally valid operations, request bodies, and response definitions. It also reports recognized features that are intentionally not enforced. Request-body checks share the runtime validator's structural rules for `requestBody`, `content`, media-type entries, `schema`, `itemSchema`, and request `encoding` maps and entries. Doctor reports all malformed nodes as `structure` errors, including media types a particular request would not select and operations without responses in OpenAPI 3.1/3.2. These checks do not turn doctor into a complete OpenAPI linter: for compatibility, missing or null request-body and request-content nodes retain the runtime behavior, and response-side `encoding` is not inspected. JSON and YAML are supported. YAML requires the optional `symfony/yaml` package, just like runtime validation. ## Multiple specs Repeat `--spec`, or provide comma-separated paths: ```bash vendor/bin/gesso doctor \ --spec=openapi/front.yaml \ --spec=openapi/admin.json \ --strip-prefix=/api ``` `--phpunit-snippet` prints the equivalent extension configuration when all entry documents share one directory. The snippet uses each entry document's filename without its extension as the configured spec name. Local external references are confined to each entry document's directory by default. If an entry and its shared schemas use sibling directories, pass their narrowest trusted common parent: ```bash vendor/bin/gesso doctor \ --spec=openapi/bundled/front.yaml \ --local-ref-root=openapi \ --phpunit-snippet ``` The generated PHPUnit snippet then uses `spec_base_path="openapi"` and the spec name `bundled/front`. Targets that escape the canonical root through `../`, an absolute path, or a symlink are rejected; those forms remain valid when their canonical targets stay inside the root. ## Acknowledged unvalidatable security schemes When the test suite acknowledges an unvalidatable security scheme by name (see [Acknowledging an unvalidatable security scheme](setup.md#acknowledging-an-unvalidatable-security-scheme)), pass the same names so the doctor report reflects the acknowledgement: ```bash vendor/bin/gesso doctor \ --spec=openapi/front.yaml \ --acknowledge-unvalidatable-scheme=ClientBasicAuth ``` The scheme is still listed as a skipped feature, marked as acknowledged instead of prompting for a separate authentication test. A name that is not defined in `components.securitySchemes`, or that names a scheme the validator can enforce, is reported as a `configuration` warning so the acknowledged list cannot rot. ## HTTP references Remote references remain opt-in because diagnostics must not access the network unexpectedly: ```bash vendor/bin/gesso doctor \ --spec=openapi/root.yaml \ --allow-remote-refs \ --remote-ref-host=specs.example.com \ --remote-ref-max-bytes=10485760 ``` The command automatically uses an installed Guzzle (`guzzlehttp/guzzle` plus `guzzlehttp/psr7`) or Symfony HttpClient PSR-18 implementation. Every permitted host must be listed with `--remote-ref-host`; repeat the option when a trusted contract intentionally spans hosts. A nested `$ref` that switches to an unlisted host is rejected before any request is sent. Each remote document is limited to 10 MiB by default; use `--remote-ref-max-bytes=` only when a trusted contract requires a larger bound. Without `--allow-remote-refs`, an HTTP(S) `$ref` is reported as a reference error with an actionable hint. Network failures, oversized or malformed remote documents, content-type mismatches, and reference cycles also exit non-zero. ## Installed version `gesso --version` is a global flag on the binary, matched before any subcommand: ```bash $ vendor/bin/gesso --version gesso 2.4.0 ``` It writes to STDOUT, writes nothing to STDERR, and exits `0`. When Composer's `InstalledVersions` metadata cannot be read it prints `gesso unknown` — the same sentinel the JSON documents emit — and still exits `0`, so a CI script or an agent probing for the version always gets an answer rather than a usage error. `gesso --help` lists it under `Global options:`. ## Machine-readable output Use `--format=json` for CI and tooling. The top-level `schemaVersion` is currently `1` and will change if the machine-readable contract requires an incompatible revision. ```json { "schemaVersion": 1, "tool": { "name": "studio-design/gesso", "version": "2.4.0" }, "status": "ok", "summary": { "specs": 1, "operations": 4, "responses": 7, "errors": 0, "warnings": 0, "skipped": 0 }, "specs": [], "issues": [], "phpunit": null } ``` `tool` is `{ "name": "studio-design/gesso", "version": "" }`, the same block the [coverage JSON](coverage-json-schema.md) and [validation JSON](validation-json-schema.md) documents carry. `"unknown"` is emitted when Composer's `InstalledVersions` metadata is unavailable; the field is always a string. Additive changes — a new field such as `tool`, or a new `category` slug — keep `schemaVersion` at `1`. Removals, renames, and type changes bump it. Every issue has a stable `severity`, `category`, `spec`, `message`, and nullable `suggestion` field. Severities are: - `error`: the contract cannot be loaded or enforced as configured; - `warning`: validation can proceed, but the package detected a compatibility limitation; - `skipped`: a recognized contract feature is not currently enforced and needs a separate test. Categories currently emitted by schema version 1 are `io`, `parser`, `version`, `dialect`, `references`, `structure`, `keyword`, `feature`, `dependency`, `configuration`, `spec`, `compatibility`, and `internal`. Consumers should branch on `severity` and `category`, not the human-readable message. ## Exit codes | Code | Meaning | |---:|---| | `0` | All selected specs are compatible. Warnings and skipped features may still be present. | | `1` | At least one diagnostic error prevents reliable contract enforcement. | | `2` | The command-line invocation is invalid, such as a missing `--spec` or unknown format. | Treat both `1` and `2` as CI failures. Always inspect warnings and skipped features even when the exit code is `0`. --- Source: https://studio-design.github.io/gesso/baseline.md # Violation baseline Adopting contract testing on an existing API usually fails at the first run: hundreds of known spec/implementation mismatches, all failing at once. The violation baseline converts that into a one-PR adoption, the same way PHPStan and Psalm baselines work: record today's violations in a committed file, keep CI green, and fail only on **new** violations. The debt stays enumerated in one reviewable file and can be ratcheted down entry by entry. Its counterpart for *untested* responses is the [coverage baseline](coverage-baseline.md), which shares the same generation command. ## Quick start 1. Point the PHPUnit extension at a baseline file: ```xml ``` 2. Generate the baseline (full suite, no `--filter`): ```bash GESSO_BASELINE_GENERATE=1 vendor/bin/phpunit ``` Every contract violation is recorded instead of failing, and the sorted, versioned baseline is written at run end: ```text [Gesso] Baseline written: 42 violation(s) → /path/to/gesso-baseline.json ``` 3. Commit `gesso-baseline.json`. From now on every normal run loads it and suppresses baselined failures; new violations fail as usual. The file is deterministic — fully sorted, no timestamps — so regenerating an unchanged contract produces a byte-identical file and diffs stay reviewable. Its format is a versioned compatibility surface; see [versioning.md](versioning.md#whats-covered-by-semver). ## What counts as "the same violation" Each violation is identified by a fingerprint: `(spec, method, path template, status, content type, category, parameter, instance path, keyword)`. Two properties make it survive unrelated change: - **Message wording is excluded.** Validator prose may improve between releases without invalidating the baseline. - **Numeric JSON Pointer segments are canonicalized to `*`.** The same schema defect reported at `/data/0/id` and `/data/3/id` is one entry, regardless of test-data size or ordering. Fixed HTTP methods normalize to their canonical uppercase form (`get` and `GET` are one entry). OpenAPI 3.2 custom `additionalOperations` methods are case-sensitive, so `COPY` and `copy` are distinct entries — a baselined `COPY` violation never absorbs a new `copy` violation. Non-body violations carry the parameter / response-header / security-scheme name plus the failing keyword, so a known `limit` violation does not absorb a future `page` violation, and a known "`limit` missing" does not absorb a future "`limit` has the wrong type". Violations with neither a name nor a keyword (structural spec errors, error-boundary captures) collapse per operation and category — a documented trade-off. Body-decode failures (unparseable JSON, an unreadable or non-seekable PSR-7 stream) carry the synthetic `parse` keyword, so a baselined decode failure never absorbs a genuinely empty body on the same operation — and vice versa. ## Generating under parallel runners Under paratest or Pest `--parallel`, each worker sees only its slice of the suite, so no single worker may write the baseline file. Generation instead follows the same two-step workflow as [parallel coverage](parallel.md): each worker stages its collected fingerprints in its sidecar (the `baseline` key), and the merge step unions them into the committed file: ```bash # 1. Run the parallel suite in generation mode — failures are demoted, # fingerprints ride the worker sidecars. GESSO_BASELINE_GENERATE=1 vendor/bin/paratest --processes=4 # 2. Union the worker halves and write the baseline. vendor/bin/gesso coverage:merge \ --spec-base-path=openapi/bundled \ --baseline-file=gesso-baseline.json ``` The union is deterministic — the same contract debt produces a byte-identical file regardless of how tests were distributed across workers. The merge fails loudly (exit 1, sidecars preserved for a retry) instead of writing a wrong or missing baseline when: - sidecars carry baseline data but `--baseline-file` was not given — discarding it would silently hide the violations the workers demoted; - `--baseline-file` was given but some sidecar carries no baseline data (a worker ran without generation mode, or on a pre-2.2 library version) — the union would be an incomplete baseline; - `--baseline-file` was given but no sidecars exist at all. Because generation demotes failures per worker, the merge step is what makes the run trustworthy — wire both steps into the same CI job so a forgotten merge cannot leave a green run with no baseline written. As with sequential generation, run the full suite: per-worker slices are expected, but a `--filter`ed parallel run cannot be detected and would bake an incomplete baseline. ## Enforcement semantics - A failing assertion is suppressed only when **every** one of its violations is baselined. One new violation fails the assertion with the full, unmodified error output (including the baselined violations — fix or regenerate deliberately, never silently). - The PSR-7 exchange assertion evaluates each failing side independently and passes only when all failing sides are fully baselined. - A missing or malformed `baseline_file` is FATAL at bootstrap. A typo'd path must not silently disable suppression. - A body that fails to decode as JSON is baselined as a body-category entry with the synthetic `parse` keyword. In the Laravel and Symfony adapters it carries no matched status/content-type context (the failure happens before path matching); the PSR-7 adapter folds the failure into the validation result as a `parse`-keyword issue with its matched context. Either way, validation still continues against a placeholder body so violations elsewhere in the request/response are not masked — and the placeholder's own body verdicts are treated as artifacts of the decode failure: they are neither recorded at generation time nor required to be baselined at enforcement time. - The `response.content_type` note — emitted when an undeclared `+json` Content-Type fell through to the first JSON key, see [supported-features.md](supported-features.md#body-validation) — explains the body errors beside it rather than being a violation of its own. It is neither recorded nor required to be baselined, so a baseline written before the note existed keeps suppressing the same failure. - While a baseline is active the `max_errors` cap is lifted: a truncated error list could hide a new violation behind baselined ones. Failure output may therefore list more errors than the configured cap. ## The end-of-run summary Every full enforcement run prints one line to stderr: ```text [Gesso] baseline: 42 entries, 40 hit, 2 stale (removable). ``` `hit` counts baseline entries that occurred during the run; `stale` counts entries that never occurred — debt that has been fixed and can be removed from the file. ## Ratcheting down Stale entries are the ratchet-down signal. The `baseline_stale` parameter controls how they are reported: | Value | Behavior | |---|---| | `note` (default) | Stale entries are listed on stderr and in the GitHub Actions step summary | | `fail` | Same listing, and the run exits non-zero — CI enforces that the baseline only shrinks | | `off` | Stale entries are not evaluated | To shrink the baseline, delete the listed entries from the file (or re-run the generation command, which rewrites the file to exactly the current debt). Hand-edited entries are re-normalized on load, so removing an entry is safe without regenerating. Stale evaluation needs a complete, clean run: an entry is only provably gone when every assertion that could hit it actually ran. It is therefore skipped — entries/hits are still reported, and the `fail` gate never trips — in two cases: - **Partial runs** (`--filter` / `--testsuite` / path arguments): the subset cannot prove an entry no longer occurs. - **Runs that did not complete cleanly**: any failed, errored, skipped, or incomplete test, a skipped test suite (class-level requirements), or a truncated run (any `--stop-on-*` flag, hook failures — detected by comparing the planned test count against the tests that actually finished): later assertions may never have executed, so an unhit entry proves nothing. ## Limitations - **Parallel runners:** enforcement (suppression) works per worker, but the stale summary is not aggregated across workers — ratchet-down evaluation needs a sequential full run. Generation is supported via the sidecar merge (see [Generating under parallel runners](#generating-under-parallel-runners)). - Suppression is per assertion, not per test: an assertion mixing baselined and new violations fails as a whole (by design — see enforcement semantics). --- Source: https://studio-design.github.io/gesso/coverage-baseline.md # Coverage baseline The [coverage threshold gate](coverage.md#coverage-threshold-gate) answers "is coverage above N%". That is a poor fit for "coverage must not get worse", because a percentage has to be maintained by hand: - setting the threshold below the current rate leaves headroom, so several responses can go uncovered without failing; - setting it exactly at the current rate couples the gate to the **denominator** — documenting one new response in the spec fails an otherwise unrelated PR, and the message only reports a percentage, not which row caused it; - improving coverage without also raising the threshold silently restores the headroom, and CI stays green while the ratchet loosens. The coverage baseline gates the **set** of uncovered responses instead. It is the [violation baseline](baseline.md) idea applied to coverage: commit today's gaps, fail on new ones by name, and let the ratchet show up as a diff. ## Quick start 1. Point the PHPUnit extension at a coverage baseline file: ```xml ``` 2. Generate it from a full run (no `--filter`): ```bash GESSO_BASELINE_GENERATE=1 vendor/bin/phpunit ``` ```text [Gesso] Coverage baseline written: 105 uncovered response(s) → /path/to/gesso-coverage-baseline.json ``` 3. Commit the file. Every later run compares its uncovered set against it. `GESSO_BASELINE_GENERATE` drives both baselines: if `baseline_file` is configured too, one command regenerates both. Configuring only `coverage_baseline_file` is fine — the violation baseline stays uninvolved. The file is deterministic — fully sorted, no timestamps — so regenerating an unchanged suite produces a byte-identical file. Its format is a versioned compatibility surface; see [versioning.md](versioning.md#whats-covered-by-semver). ## What an entry is One entry per response the run did not validate, at the same granularity coverage is measured at: ```json { "spec": "front", "method": "GET", "path": "/v1/pets", "status": "500", "content_type": "application/json" } ``` `status` is the spec's response key (`200`, `5XX`, `default`), not an observed HTTP status. `content_type` is `*` for responses declared without a `content` block (204-style). Fixed HTTP methods normalize to uppercase; OpenAPI 3.2 custom `additionalOperations` methods stay case-sensitive. **Skipped responses are baselined too.** A response matched by `skip_response_codes`, or one whose content type has no schema engine, counts as not covered — exactly as it does in the `min_response_coverage` numerator. Including it means switching from the percentage gate to the baseline never weakens the gate. ## Enforcement semantics Every full run prints one summary line to stderr: ```text [Gesso] coverage baseline: 105 entries, 105 uncovered response(s) in this run, 0 covered now. ``` An uncovered response that is **not** in the file fails the run and is named: ```text [Gesso] FATAL: 2 response(s) are not covered and are not listed in the coverage baseline: - [front] DELETE /v1/pets/{id} status=204 content-type=* - [front] GET /v1/pets status=500 content-type=application/json Action: cover them with a test, or accept them by regenerating the baseline with `GESSO_BASELINE_GENERATE=1 vendor/bin/phpunit`. ``` Because the gate compares sets, documenting a new response in the spec does not fail an unrelated PR — as long as that response is covered. If it is not, the failure names it and says so, which is the intended behavior arriving for the intended reason. The gate is skipped, with a one-line NOTE, when it cannot be evaluated honestly: - **Partial runs** (`--filter` / `--testsuite` / path arguments): a subset leaves responses uncovered that the full suite covers. - **Runs that did not complete cleanly** (any failed, errored, skipped, or incomplete test, or a truncated `--stop-on-*` run): a test that never reached its contract assertion leaves its responses uncovered, and reporting those as regressions would bury the real failure. A run that recorded no contract coverage at all is FATAL rather than a vacuous pass — an empty uncovered set there means "no data", not "no gaps". ## Ratcheting down Baseline entries that are covered now — the ratchet-down signal — are reported per `coverage_baseline_stale`: | Value | Behavior | |---|---| | `note` (default) | Entries are listed on stderr as removable | | `fail` | Same listing, and the run exits non-zero — CI enforces that the baseline only shrinks | | `off` | Entries that are covered now are not reported | ```text [Gesso] NOTE: 1 coverage baseline entry(ies) are covered now and can be removed from the baseline file: - [front] GET /v1/pets status=500 content-type=application/json ``` Delete the listed entries, or re-run the generation command — it rewrites the file to exactly the current gaps. Either way the improvement appears in the diff, so it cannot be forgotten the way a threshold bump can. Entries for responses the spec no longer declares are reported the same way, since they can never be covered again. ## Parallel runners A paratest worker sees only its slice of the suite, so no worker can decide whether a response is covered. Workers therefore neither generate nor enforce; the merge step owns both halves, reading the same merged coverage state it already renders reports from: ```bash # Generate GESSO_BASELINE_GENERATE=1 vendor/bin/paratest --processes=4 GESSO_BASELINE_GENERATE=1 vendor/bin/gesso coverage:merge \ --spec-base-path=openapi/bundled \ --coverage-baseline-file=gesso-coverage-baseline.json # Enforce vendor/bin/paratest --processes=4 vendor/bin/gesso coverage:merge \ --spec-base-path=openapi/bundled \ --coverage-baseline-file=gesso-coverage-baseline.json \ --coverage-baseline-stale=fail ``` The merge exits 1 when a response is newly uncovered, when no sidecars were found, when no coverage was recorded, or when the baseline file cannot be read; it exits 2 for usage errors (an unknown `--coverage-baseline-stale` value, or that flag without `--coverage-baseline-file`). On any of those failures the sidecars are **kept** instead of cleaned up, as the violation baseline does. Every recovery re-runs the merge, not the suite: fix a typo'd path, free disk space for the write, or accept the reported regressions by re-running with `GESSO_BASELINE_GENERATE=1`. A generation worker that cannot write its sidecar — and cannot drop a failure marker either — fails the parallel run rather than letting the merge build a baseline from the surviving workers, which would record the lost worker's covered responses as uncovered and loosen the ratchet for good. Unlike the sequential path, the merge has no view of whether the suite passed, so a red parallel run can report responses of failed tests as newly uncovered. Check the test run's own exit code first — the same caveat the threshold gate has here. ## Using it alongside the threshold gate They are independent and can both be configured, but the baseline is the stronger of the two for regression protection. `min_endpoint_coverage` and `min_sdk_exercise_coverage` still measure things the coverage baseline does not, so keeping those while dropping `min_response_coverage` is a reasonable setup. --- Source: https://studio-design.github.io/gesso/coverage-gate.md # Spec patch coverage gate `gesso coverage:gate` fails a pull request when it changes an operation that no test exercises. It is the OpenAPI counterpart of `diff-cover` / Codecov's patch status: instead of "the whole spec must be 80% covered", it enforces "whatever *this* change touched must be covered". It needs three inputs: | Input | Where it comes from | |---|---| | the base spec | `git show` for a single file, `git worktree` for a split spec (below) | | the current spec | your working tree | | a coverage document | [`json_output`](coverage-json-schema.md) from the test run | ```bash git show origin/main:openapi.json > /tmp/base.json vendor/bin/gesso coverage:gate \ --base-spec=/tmp/base.json \ --spec=openapi.json \ --coverage=build/coverage.json ``` Both documents are loaded exactly like the runtime loader loads them, so local `$ref`s resolve **relative to their own entry document's directory**. The single-file `git show` redirect above only works for a spec that has no local `$ref`. For a split spec, materialise the whole base tree instead: ```bash git worktree add /tmp/base origin/main vendor/bin/gesso coverage:gate \ --base-spec=/tmp/base/openapi/root.yaml \ --spec=openapi/root.yaml \ --coverage=build/coverage.json ``` ```text [Gesso] 2 operations changed against the base spec: DELETE /pets/{id} 204 (no content) UNCOVERED PUT /pets/{id} 200 application/json covered 1 changed response is not covered by any test. ``` ## What counts as a change Both documents are resolved through the same loader the validators use, then each operation is fingerprinted: - **added** — the `(method, path)` is not in the base spec. Every declared response is in scope. - **changed** — the operation exists in both, but its resolved tree differs. When only some responses changed, only those are in scope. When the operation's request contract changed, every response of that operation is in scope, because a changed request is worth re-exercising end to end. - **removed** — listed for information only. An operation or a response that no longer exists cannot be tested, so a removal never fails the gate. A change that only deletes a response still reports the operation, rendering the deleted row as `removed (not testable)`. The request contract means the **effective** one, not just the operation object: - the parameters after the Path Item / operation merge — an operation-level entry replaces the Path Item entry with the same `in` + `name`, so changing an *already-overridden* Path Item parameter is not a change; - the `servers` that actually apply. `servers` overrides rather than merges, so exactly one level is ever in force: operation, else Path Item, else root. A root `servers` change reaches every operation that inherits it, and never reaches one that declares its own; - the security requirement that actually applies — operation-level if declared, otherwise the root default — together with the `components.securitySchemes` definitions it names; - any remaining Path Item fields the operation inherits (`summary`, `description`, extensions). Tightening a path-level parameter to `required`, or swapping an API key from a header to a query parameter, therefore puts every affected operation in scope even though no operation object changed. The comparison is **structural, not semantic**. The gate does not classify a change as breaking or non-breaking — that is the job of a dedicated diff tool such as [oasdiff](https://github.com/oasdiff/oasdiff) or [pb33f/openapi-changes](https://github.com/pb33f/openapi-changes). Because the comparison happens after reference resolution, moving a schema into `components` and pointing a `$ref` at it is *not* a change. ## Which operations are gated The gate covers the methods a coverage document can actually contain: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `QUERY`, and OpenAPI 3.2 `additionalOperations`. `OPTIONS`, `HEAD`, and `TRACE` are skipped, because the [coverage tracker](coverage.md) never records them — gating them would demand coverage no report can ever show. ## What counts as covered The gate joins each in-scope `(method, path, status, content-type)` tuple against the coverage document at the same granularity coverage is measured at. A tuple passes only when its `response_state` is `validated`. A `skipped` response is rendered as `SKIPPED` and fails the gate — a skip is a deliberate hole, and a change is exactly the moment to revisit it. A tuple missing from the coverage document entirely counts as `UNCOVERED`. ## Options | Flag | Description | |---|---| | `--base-spec=` | Spec as it looks on the base branch. Required. | | `--spec=` | Spec as it looks on this branch. Required. | | `--coverage=` | Coverage JSON, `schema_version` 3. Required. | | `--spec-name=` | Key under `specs` in the coverage document. Defaults to the `--spec` filename without its extension. | | `--format=text\|markdown` | `markdown` renders a table for `$GITHUB_STEP_SUMMARY`. Default `text`. | ## Exit codes | Code | Meaning | |---|---| | `0` | Every changed operation is covered, including "nothing changed". | | `1` | A changed operation has a response no test validated. | | `2` | Command-line usage is invalid, or a spec / coverage file cannot be read. | ## In CI See [CI integration](ci.md#spec-patch-coverage-gate) for a complete `on: pull_request` workflow. ## Related - [Coverage](coverage.md) — how coverage is measured and reported. - [Coverage baseline](coverage-baseline.md) — ratchet the *existing* uncovered set instead of gating on the diff. The two compose: the baseline stops regressions from growing, the gate stops new work from arriving uncovered. - [Coverage JSON schema](coverage-json-schema.md) — the document this command reads. --- Source: https://studio-design.github.io/gesso/stubs.md # Test stub scaffolding `gesso stubs` writes test skeletons for the responses your suite does not exercise yet. Point it at a spec and a [coverage document](coverage-json-schema.md) and it generates one test class per operation, one test method per uncovered `(status, content-type)` tuple — the same granularity the [coverage report](coverage.md) measures. ```bash vendor/bin/gesso stubs \ --spec=openapi.json \ --coverage=build/coverage.json \ --adapter=laravel ``` ```text [Gesso] Wrote 3 files covering 7 uncovered responses to tests/Feature/Contract: + tests/Feature/Contract/DeletePetsPetIdTest.php + tests/Feature/Contract/PostPetsTest.php + tests/Feature/Contract/PutPetsPetIdTest.php 1 file already exists and was left untouched: = tests/Feature/Contract/GetPetsTest.php ``` Without `--coverage` the whole spec is scaffolded. With it, a response the document reports as `validated` is skipped; `uncovered` and `skipped` responses both get a stub, because a skipped response is untested too. Laravel users can run the same thing through Artisan, which takes the spec from `gesso.default_spec` / `gesso.spec_base_path`: ```bash php artisan gesso:stubs --coverage=build/coverage.json ``` ## Options | Option | Default | Meaning | |---|---|---| | `--spec=` | required | OpenAPI document (`.json` / `.yaml` / `.yml`). | | `--coverage=` | — | Coverage JSON (`schema_version` 3). Omit to scaffold the whole spec. | | `--spec-name=` | `--spec` filename | Key under `specs` in the coverage document, and the spec name written into the generated tests. | | `--adapter=` | `phpunit` | `phpunit`, `laravel`, `symfony`, or `pest`. | | `--output=` | per adapter | Where to write. `tests/Contract`, or `tests/Feature/Contract` for `laravel` and `pest`. | | `--namespace=` | per adapter | Namespace for the generated classes. Ignored by `pest`. | | `--base-class=` | per adapter | Test class to extend — `PHPUnit\Framework\TestCase`, or `Tests\TestCase` for `laravel`. Ignored by `pest`. | | `--dry-run` | off | Report what would be written without writing it. | | `--request-prefix=` | none | Prepend the application's mount path to generated Laravel, Symfony, or Pest requests, e.g. `/api`. Does not configure runtime path matching. | Exit code `0` means stubs were written or there was nothing left to stub; `2` means a usage error or an unreadable input. The Artisan command takes the same options. Its `--spec` accepts a spec *name* resolved under `gesso.spec_base_path` as well as a path. ## Applications mounted under a prefix If your spec declares `/users` but Laravel serves `/api/users`, generate the request with an explicit mount path: ```bash vendor/bin/gesso stubs --spec=openapi/api.json \ --coverage=build/coverage.json --adapter=laravel --request-prefix=/api # Equivalent, using Laravel's configured spec: php artisan gesso:stubs --coverage=build/coverage.json --request-prefix=/api ``` The generated test calls `/api/users`; its class name, spec attribute, and coverage identity still refer to the original operation. Path parameter values and query strings are retained. For example, `/users/7?q=alice` becomes `/api/users/7?q=alice`. Configure `strip_prefixes=/api` on the PHPUnit extension separately so the runtime validator matches that request to `/users`; see [server base paths](setup.md#server-base-paths-are-not-stripped-automatically). The prefix is an absolute URI path, not a URL: `/api` and `/api/v1` are accepted; hosts, query strings, fragments, and unencoded whitespace are rejected. Encode spaces and non-ASCII characters with percent encoding. Trailing slashes are removed before prepending, so `/api/` behaves like `/api`; an empty prefix or `/` leaves requests unchanged. The spec's root path `/` becomes `/api/`. Omitting the option preserves existing output. The generator does not infer a mount from `servers` or `strip_prefixes`, and does not remove an existing prefix from a spec path. Only supply the portion missing from the spec's paths. The response-only `phpunit` adapter rejects a non-empty normalized prefix because it does not send HTTP requests. Existing files are still never overwritten, including when you change the prefix. Edit an existing test's URL yourself or use a fresh output directory to preview a new stub. New tests still start incomplete: review their payload, authentication, and fixtures before removing the marker. The [Scramble example](recipes/scramble.md#generate-the-missing-422-test) verifies this workflow through to a passing coverage gate. ## What ends up in a stub Each generated test is marked incomplete — `markTestIncomplete()`, or `->todo()` for Pest — so a freshly scaffolded suite reports outstanding work instead of failures. Removing that line is how you say the stub is finished. Everything the spec pins down is filled in: - **Path and query.** Path template variables are substituted, and required query parameters are appended. Values come from the parameter's `example`, then `schema.default` / `schema.enum`, then a type- and format-shaped placeholder (`1` for an integer, a zero UUID for `format: uuid`, …). `TODO` is the last resort. - **Required headers.** `in: header` parameters marked `required` are passed on the request. - **Bodies.** A request body or response `example` (or the first entry of `examples`) becomes the literal in the stub, preserving `{}` versus `[]` even when nested. Without one you get a type-shaped placeholder (`{}` for objects, `[]` for arrays, a scalar for a declared scalar type) under a `// TODO` comment. This is not schema-driven generation: required properties and other constraints still need filling in. The call shape follows the media type: array-compatible JSON uses JSON helpers; empty objects and scalars use raw JSON. `application/x-www-form-urlencoded` and `multipart/form-data` are sent as request fields so the form decoders see a field map, and anything else goes out as a raw body. Whichever shape it takes, the declared media type is set explicitly — `Request::create()` would otherwise default a form request to `application/x-www-form-urlencoded` (and leave `PATCH` with no `Content-Type` at all), so a multipart operation would never match its own `requestBody`. A multipart stub points at `UploadedFile` for the file parts rather than hand-building a boundary. "JSON" here means what the validator means by it — `application/json` or a `+json` suffix. `application/vnd.acme+json` uses the same JSON dispatch rules; `application/notjson` does not. A spec key that is a *range* (`application/*`, `*/*`) is not something a client can put on the wire, so the stub sends a concrete type the range covers. A form body on an `additionalOperations` method goes out as raw urlencoded bytes rather than request fields: `Request::create()` only moves its parameters into the request bag for POST/PUT/PATCH/DELETE/QUERY, and routes anything else into the query bag. `multipart/form-data` has no raw-byte form the decoder can parse back. A multipart body on a custom method is therefore only a dead end when it is `required` *and* no other media type is declared: an optional body is simply omitted, and an operation that also offers urlencoded is stubbed through that. A body no Content-Type resolves back to at all — a `text/*` key, which no JSON type selects and no form type covers — is the other dead end. Either way the operation is reported as not stubbable for the Laravel, Pest, and Symfony adapters rather than emitted as a request that would silently validate as skipped. The `phpunit` adapter is unaffected by both — it only validates responses and never builds a request, so no `requestBody` can cost it an operation. A request media type is resolved the way `RequestBodyValidator` does, which is not how responses resolve. Its JSON route has no exact-match preference, so only the first JSON key (or `application/*`) is ever selected by a JSON Content-Type. And forms are the one non-JSON family it still checks against a schema, which makes `multipart/form-data` the way to reach a `multipart/*` range — unreachable through every other route. Where several media types are declared, the one whose schema is actually enforced wins: an operation offering both `application/xml` and `multipart/form-data` is stubbed through the form, because the XML would validate as Skipped whatever the body is. - **Status codes.** A range key such as `4XX`, or `default`, is exercised as a concrete code with a comment saying which one was picked. The code is chosen the way the runtime resolver reads the spec — exact keys win over ranges, and ranges over `default` — so an operation declaring both `400` and `4XX` gets a `4XX` stub sending 401, not one that would silently validate the `400` schema. A key no status can reach (a `4XX` alongside all 100 exact 4xx codes) is reported rather than stubbed, because no test could ever cover it. A key that is not a status, a range, or `default` is reported as malformed and does not affect its operation's valid keys — run [`gesso doctor`](doctor.md) for those. - **Specification extensions.** An `x-` key on a Responses Object is not a response and gets no stub. - **Content negotiation.** Each response media type gets its own `Accept` header, so two media types declared under one status do not both resolve to the same response. A range key (`application/*`) is exercised as a concrete type it covers — sending the range itself would make the validator read the body as non-JSON and skip the schema, so a violating body would pass. The declared key still names the test and the coverage tuple it closes. Which substitute works depends on the key: the JSON resolver takes an exact match first and otherwise the first JSON entry, so a range declared *alongside* a literal JSON key is unreachable through a JSON Content-Type. A non-JSON one reaches it — the general matcher matches `/*` in its second pass — but only pays off when the range declares no `schema`, since a non-JSON type that lands on one is skipped as a contract this engine cannot evaluate. So a schema-less range is stubbed with a concrete non-JSON type, and a range carrying a schema next to a JSON sibling is reported rather than stubbed. The substitute is searched for rather than guessed, on both halves of the media type: a spec is free to declare the invented type the stub would have reached for, and `*/*` is only matched after every `/*` sibling, so a document ranging over `application/*` and `text/*` gets something like `image/…` for its full wildcard. - **Bodies only a skip can reach.** OpenAPI 3.2 `itemSchema` streaming cannot be checked from a buffered body, and a non-JSON media type carrying a `schema` is a contract this JSON Schema engine does not evaluate. Both still get a stub — it exercises the endpoint and moves the tuple off `uncovered` — but with a `TODO` saying the assertion can only ever pass as Skipped, because the generated `isValid()` is satisfied by a skip and the coverage document will keep reporting the tuple as skipped rather than validated. This applies to the request body as well as the response, except for the `phpunit` adapter, which never sends a body. - **Responses without `content`.** These become a single "no content" test, the same tuple the coverage tracker records for a 204. ```php #[OpenApiSpec('petstore')] final class PostPetsTest extends TestCase { use ValidatesOpenApiSchema; public function test_post_pets_201_application_json(): void { $this->markTestIncomplete('Exercise POST /pets returns 201 application/json.'); // TODO: adjust the payload your application expects. $payload = [ 'name' => 'Fido', ]; $response = $this->postJson( '/pets', $payload, [ 'Accept' => 'application/json', ], ); $response->assertStatus(201); $this->assertResponseMatchesOpenApiSchema($response); } } ``` Each adapter follows its own [quickstart](quickstarts/laravel.md) idiom, so generated code reads like the documented usage rather than a dialect of its own. A request body that Laravel's JSON helpers cannot carry — an empty object, a scalar, or a non-JSON media type — is sent through `call()` with an explicit `Content-Type` instead of `postJson()`. Pest stubs default into `tests/Feature/Contract` because they generate Laravel HTTP calls: they need the `uses(TestCase::class, ValidatesOpenApiSchema::class)->in('Feature')` binding from [the Pest guide](pest-plugin.md) to have a harness once `->todo()` comes off. Point `--output` elsewhere only if your `uses(...)` reaches there. ## Re-running it The command never overwrites a file. Once you have edited `PostPetsTest.php`, a later run reports it as untouched and writes only the operations that have appeared since. Output is deterministic — operations are ordered by `METHOD path`, responses by status then content type — so two runs on the same inputs produce byte-identical files. That makes the loop straightforward: run your suite, regenerate, fill in the next batch. ```bash vendor/bin/phpunit # writes build/coverage.json vendor/bin/gesso stubs --spec=openapi.json --coverage=build/coverage.json ``` Class names come from the method and path — `GET /pets/{petId}` becomes `GetPetsPetIdTest` — not from `operationId`, so a document that reuses an `operationId` cannot collide two operations into one file. Where two paths do normalise to the same name (`/foo-bar` and `/foo/bar`), each gets a suffix derived from its own endpoint, so neither is dropped and neither name shifts when an unrelated operation joins the spec. Collisions are resolved against every operation the spec declares, not just the uncovered ones, so a name stays put once the other side of a collision goes green. `--spec` is loaded through the runtime loader, which resolves a *name* and searches `.json` before `.yaml` before `.yml`. Passing `--spec=openapi.yaml` next to an `openapi.json` fails rather than silently stubbing the JSON — the same shadowing check [`gesso doctor`](doctor.md) makes. ## Scope Only the methods the coverage tracker records are stubbed: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `QUERY`, and OpenAPI 3.2 `additionalOperations`. `OPTIONS`, `HEAD`, and `TRACE` never appear in a coverage document, so a stub for one could never turn a tuple green. Generated tests are yours to edit; they are ordinary test files with no runtime dependency on the generator. --- Source: https://studio-design.github.io/gesso/psr7.md # PSR-7 request and response validation Use the PSR-7 adapter when your application or HTTP client already exposes `RequestInterface`, `ServerRequestInterface`, and `ResponseInterface` messages. It validates both sides of an exchange through the same core validators and records the same response-level coverage as the Laravel and Symfony adapters. No concrete PSR-7 implementation is required by this package. The examples use Guzzle PSR-7, but Nyholm PSR-7, Laminas Diactoros, Slim messages, and other implementations of `psr/http-message` use the same API. ## Result API Configure the spec loader once, then construct an adapter for a spec: ```php use Studio\Gesso\Psr7\OpenApiPsr7Validator; use Studio\Gesso\Spec\OpenApiSpecLoader; OpenApiSpecLoader::configure(__DIR__ . '/openapi', ['/api']); $validator = new OpenApiPsr7Validator('front'); $result = $validator->validateExchange($request, $response); if (!$result->isValid()) { throw new RuntimeException($result->errorMessage()); } ``` `validateExchange()` validates the request first and forwards the response status to the request validator, preserving the documented-4xx downgrade used by the framework adapters. The returned `OpenApiPsr7ValidationResult` exposes `requestResult()` and `responseResult()` when the two outcomes need to be handled separately. The individual entry points are: ```php $requestResult = $validator->validateRequest($request, $response->getStatusCode()); // Resolve method and path from a RequestInterface. $responseResult = $validator->validateResponse($request, $response); // Or provide the operation address when only a response is available. $responseResult = $validator->validateResponseForOperation( method: 'POST', requestPath: '/v1/pets', response: $response, ); ``` A `ServerRequestInterface` contributes its parsed query and cookie parameters. For a client-side `RequestInterface`, the adapter parses the URI query and `Cookie` header. Method spelling is preserved so OpenAPI 3.2 custom `additionalOperations` remain case-sensitive. ## PHPUnit assertions Mix the PSR-7 assertion trait into a PHPUnit test and select the spec with an attribute or the `openApiSpec()` hook: ```php use PHPUnit\Framework\TestCase; use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Psr7\OpenApiAssertions; #[OpenApiSpec('front')] final class CreatePetTest extends TestCase { use OpenApiAssertions; public function test_create_pet(): void { [$request, $response] = $this->callApplication(); $this->assertPsr7ExchangeMatchesOpenApiSchema($request, $response); } } ``` Without an attribute or override, `openApiSpec()` returns `spec.default` from [`gesso.php`](configuration.md) (or the `default_spec` extension parameter). Likewise `openApiMaxErrors()`, `openApiSkipResponseCodes()`, and `openApiSkipRequestValidationResponseCodes()` default to the shared `validation.*` settings; override a hook to pin a per-suite value. Request-only, response-only, and explicit-operation assertions are also available: ```php $this->assertPsr7RequestMatchesOpenApiSchema($request, $response->getStatusCode()); $this->assertPsr7ResponseMatchesOpenApiSchema($request, $response); $this->assertPsr7ResponseForOperationMatchesOpenApiSchema('POST', '/v1/pets', $response); ``` Assertion failures end with a redacted `Reproduce: curl …` line (for the exchange assertion, one line for the whole exchange, with each error prefixed by its `[request]` / `[response]` side). In the JSON failure output mode the exchange failure instead emits one labelled JSON document per failing side. See [reproduction commands](setup.md#reproduction-commands-in-failure-output) and [validation-json-schema.md](validation-json-schema.md). ## PSR-15 test integration Keep contract validation in tests rather than placing validation middleware on the production request path. A PSR-15 handler test can validate the exchange immediately after invoking the handler: ```php final class ContractTest extends TestCase { public function test_handler_contract(): void { $request = $this->serverRequestFactory->createServerRequest('GET', '/v1/pets'); $response = $this->handler->handle($request); $result = $this->openApi->validateExchange($request, $response); $this->assertTrue($result->isValid(), $result->errorMessage()); } } ``` This works at the outer handler boundary or in a test-only middleware harness; the adapter does not require `psr/http-server-middleware` and adds no runtime middleware to the application. ## Body streams The adapter preserves a seekable stream's current cursor: it remembers the position, reads from the beginning, and restores the original position before returning. Empty, scalar, object/array, and literal JSON `null` bodies remain distinct. A non-seekable JSON stream cannot be read and then restored through the [PSR-7 `StreamInterface`](https://github.com/php-fig/http-message/blob/2.0/src/StreamInterface.php). The adapter therefore returns a failure without reading it. Buffer or decorate the body with a seekable/caching stream before validation when the application uses a one-pass stream. This also applies when a form request needs raw bytes because no parsed fields are available. A read or JSON parse failure produces only a parse issue for that body, not a second empty-body or schema error. Other parameter/header/security violations remain visible, including when the request produced a documented 4xx response. Opaque (non-JSON, non-form) request presence is inspected only when the resolved contract requires it. Parsed fields/files or a known size need no read; an unknown-size seekable stream needs only `read(1)`, with its cursor restored. Optional, undeclared, and unmatched opaque bodies are not inspected. The core still checks the declared content type and reports unsupported schema enforcement as skipped. See the [runnable Guzzle example](https://github.com/studio-design/gesso/tree/main/examples/psr7) for a complete project. --- Source: https://studio-design.github.io/gesso/laravel-route-parity.md # Laravel route parity `gesso:routes` compares Laravel's registered routes with the operations in one or more OpenAPI specs. It catches drift before runtime coverage can: a documented operation may have no application route, or an application route may never have been documented. Route parity complements contract coverage. Parity checks that a route and an operation exist; runtime coverage still proves that tests exercised the operation and validated its responses. ## Configuration Publish the Laravel config if you have not already: ```bash php artisan vendor:publish --tag=gesso ``` Configure the spec directory, default spec, and the same prefixes used by the PHPUnit extension: ```php return [ 'default_spec' => 'front', 'spec_base_path' => base_path('openapi/bundled'), 'strip_prefixes' => ['/api'], 'route_parity' => [ 'external_operation_ids' => ['forms.*'], 'external_openapi_paths' => ['/v2/proxy/*'], ], ]; ``` `spec_base_path` is the directory searched for `front.json`, `front.yaml`, or `front.yml`. Keep `strip_prefixes` aligned with the PHPUnit extension — or move both to the root [`gesso.php`](configuration.md) so nothing needs aligning. The command applies the runtime matcher's first-match prefix and trailing-slash rules, so the static result matches request/response validation. ## Usage Use the configured `default_spec`: ```bash php artisan gesso:routes ``` Select multiple specs and narrow the Laravel route collection: ```bash php artisan gesso:routes \ --spec=front \ --spec=admin \ --prefix=api/v2 \ --middleware=api \ --domain=api.example.com \ --exclude-route='internal.*' \ --exclude-operation='forms.*' \ --exclude-openapi-path='/v2/proxy/*' ``` Filters are combined with AND semantics: - `--prefix` matches a complete Laravel URI prefix segment. - Repeat `--middleware` to require every listed middleware name. - Repeat `--domain` to allow any listed exact route domain. - Repeat `--exclude-route` to exclude named routes; `*` wildcards are supported. Unnamed routes are not excluded by this option. Documented-side exclusions classify operations implemented by another service in a gateway topology. Repeat `--exclude-operation` to match `operationId`, or `--exclude-openapi-path` to match the OpenAPI path template. Both support `*` wildcards and are merged with `route_parity.external_operation_ids` and `route_parity.external_openapi_paths` from the Laravel config. An exclusion is applied only when the operation has no matching Laravel route: implemented operations remain in `matched`. External operations are reported separately instead of being silently removed. Laravel parameter names do not need to equal OpenAPI parameter names: `/pets/{pet}` matches `/pets/{petId}`. A trailing optional Laravel parameter is compared in both forms, so `/users/{user?}` can implement both `/users` and `/users/{userId}`. Laravel's implicit `HEAD` on a `GET` route is ignored when the spec omits `head`, but it implements and matches an explicitly documented OpenAPI `head` operation on the same path. Fallback routes are reported as ambiguous because they cannot prove that one specific OpenAPI path is implemented. A custom HTTP method is supported when the selected OpenAPI 3.2 spec declares the same, case-sensitive key under `additionalOperations`; otherwise it is reported as unsupported. ## CI exit codes By default, discovered differences are reported but the command exits `0`. Enable either gate independently: ```bash php artisan gesso:routes --fail-on-undocumented php artisan gesso:routes --fail-on-unimplemented ``` - `--fail-on-undocumented` exits `1` for registered-but-undocumented routes or unsupported route methods. - `--fail-on-unimplemented` exits `1` for documented-but-not-registered operations, excluding those classified under `external_operations`. - Invalid command options exit `2`; load/configuration failures exit `1`. ## JSON output Use stable machine-readable output in CI: ```bash php artisan gesso:routes --format=json ``` For large applications, capture the formatted JSON as a CI artifact instead of relying only on the job log: ```yaml - name: Compare Laravel routes with OpenAPI run: php artisan gesso:routes --format=json > route-parity.json - name: Upload route parity report uses: actions/upload-artifact@v4 with: name: openapi-route-parity path: route-parity.json ``` The top-level `schema_version` is currently `2`. Version 2 adds the `external_operations` result and summary count. The payload contains the selected `specs`, a `summary`, and these result arrays: - `matched` - `documented_but_not_registered` - `external_operations` - `registered_but_undocumented` - `ambiguous` - `unsupported` Paths and route names are emitted, but absolute filesystem paths and spec contents are not. --- Source: https://studio-design.github.io/gesso/fuzzing.md # Schema-driven fuzzing The `ExploresOpenApiEndpoint` trait ships for Laravel only. It generates deterministic request inputs for one operation or a filtered whole spec. Symfony, Pest, and plain PHPUnit users call the framework-agnostic [`OpenApiSpecExplorer::explore()`](#framework-agnostic-exploration) instead. The workflow is inspired by [Schemathesis][schemathesis], but the supported strategy matrix below is the contract; this package does not claim feature parity. ```php use Tests\TestCase; use Studio\Gesso\Fuzz\ExploredCase; use Studio\Gesso\Laravel\ExploresOpenApiEndpoint; use Studio\Gesso\Laravel\ValidatesOpenApiSchema; use Studio\Gesso\Attribute\OpenApiSpec; #[OpenApiSpec('front')] class CreatePetTest extends TestCase { use ExploresOpenApiEndpoint; use ValidatesOpenApiSchema; public function test_create_pet_contract(): void { $this->exploreEndpoint('POST', '/v1/pets', cases: 50, seed: 1) ->each(fn (ExploredCase $case) => $this->call( 'POST', $case->uri('/api'), cookies: $case->cookies, server: $this->transformHeadersToServerVars([ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ...$case->headers, ]), content: $case->bodyAsJson(), )->assertSuccessful()); } } ``` What you get per case (`Studio\Gesso\Fuzz\ExploredCase`): | Property | Description | |--------|-------------| | `body` | Generated JSON body (or `null` when the operation has no `application/json` requestBody) | | `query` | name → value for every `in: query` parameter | | `headers` | name → value for every `in: header` parameter (excludes the OpenAPI-reserved `Accept`/`Content-Type`/`Authorization`) | | `pathParams` | name → value for every `{placeholder}` segment | | `cookies` | name → value; empty unless a check populated it (cookie generation is not implemented). A dispatcher must forward these to its client's cookie bag — a `Cookie:` header alone never reaches `$request->cookie(...)` in Laravel or Symfony test clients | | `method`, `matchedPath` | The resolved spec template (`/v1/pets/{petId}`) and its method | | `kind`, `targetKeyword`, `targetPointer` | Valid/invalid classification and the single constraint targeted by a negative case | | `expectedStatusClasses` | Explicit response classes supplied for a negative case (for example `[4]`) | | `seed`, `caseIndex` | Stable replay identity | The collection is `Countable` and `IteratorAggregate`, so `foreach ($cases as $case)` works too if you prefer it over the fluent `each()` helper. Use `$case->bodyAsJson()` to encode the original value and send raw JSON, as above. `bodyAsArray()` is a lossy compatibility helper for array-only consumers: it recursively turns `{}` into `[]`, including nested objects. Feeding it to Laravel's `postJson()` or Symfony's `jsonRequest()` can turn a generated valid object case into an invalid array. Do not use `JSON_FORCE_OBJECT`, which would also change genuine JSON arrays. `bodyAsJson()` encodes null as literal JSON `null`; when an operation declares no body or a check omits a required body, omit the raw content instead. Encoding failures throw `JsonException`. `uri($prefix)` substitutes and URL-encodes path parameters, prepends an optional application prefix, and appends the generated query string. ## Explore response payloads against a generated SDK `OpenApiResponseExplorer` exercises the other side of the contract: whether a generated SDK can decode every supported branch of one response schema and encode the values without losing data. Unlike request exploration's fixed case count and rotation, the response explorer derives enough cases to cover every branch of every reachable choice point, then appends any requested `extraCases`. ```php use Studio\Gesso\Fuzz\GeneratedResponseCase; use Studio\Gesso\Fuzz\OpenApiResponseExplorer; OpenApiResponseExplorer::explore( 'front', 'POST', '/oauth/introspect', 200, seed: 1, )->each(function (GeneratedResponseCase $case): void { $model = ObjectSerializer::deserialize($case->bodyAsObject(), IntrospectResponse::class); $case->assertRoundTrip(ObjectSerializer::sanitizeForSerialization($model)); }); ``` Laravel tests using `ExploresOpenApiEndpoint` can call `$this->exploreResponseSchema(...)` with the same arguments except the spec name. If `seed` is omitted, response exploration uses the replayable default seed `0`. See the [SDK round-trip guide](sdk-roundtrip.md) for the complete case contract, fidelity rules, deterministic replay, and unsupported response outcomes. To replace one-test-per-response maintenance with a loud spec-wide gate, register explicit SDK mappings on `OpenApiResponseExplorer::exploreSpec()`: ```php OpenApiResponseExplorer::exploreSpec('front', seed: 1) ->includeTags(['public']) ->mapResponse( 'introspect', 200, static fn (GeneratedResponseCase $case): mixed => sdk_decode($case->bodyAsObject()), static fn (mixed $model): mixed => sdk_encode($model), ) ->failOnUnmapped() ->assertRoundTrips(); ``` The plan uses the whole-spec tag/method/path/operation/deprecated filters and stable per-operation crc32 seeds. Exact, range, and `default` status mappings are supported; one mapping exercises every JSON media type for that response. The summary distinguishes explicit skips from decoder and round-trip failures, while `failOnUnmapped()` makes newly added unmapped schemas fail CI. Laravel tests call `$this->exploreResponseSpec()` for the same builder. See the [SDK round-trip guide](sdk-roundtrip.md#exercise-every-mapped-response-in-a-spec) for callback and reporting details. ## Explore a whole spec `exploreSpec()` enumerates the Path Item operations defined by OpenAPI, including 3.2 `additionalOperations`, then generates and dispatches cases for every selected method supported by the explorer. It uses the same Laravel spec resolution as response validation (`#[OpenApiSpec]`, `openApiSpec()`, then `default_spec`). The two traits are designed to be composed: ```php use Studio\Gesso\Fuzz\ExploredCase; use Studio\Gesso\Fuzz\ExploredOperation; use Studio\Gesso\HttpMethod; use Studio\Gesso\Laravel\ExploresOpenApiEndpoint; use Studio\Gesso\Laravel\ValidatesOpenApiSchema; class ApiContractTest extends TestCase { use ExploresOpenApiEndpoint; use ValidatesOpenApiSchema; public function test_public_contract(): void { $summary = $this->exploreSpec(casesPerOperation: 20, seed: 20260711) ->includeTags(['public']) ->excludeOperations(['admin.users.destroy']) ->authenticateUsing(fn (ExploredOperation $operation) => $this->actingAs($this->userFor($operation))) ->dispatchUsing(fn (ExploredCase $case) => match ($case->method) { HttpMethod::GET => $this->get($case->uri('/api'), $case->headers), HttpMethod::POST => $this->call( 'POST', $case->uri('/api'), cookies: $case->cookies, server: $this->transformHeadersToServerVars([ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ...$case->headers, ]), content: $case->bodyAsJson(), ), default => throw new LogicException('Add the method to the test dispatcher.'), }) ->assertResponses(); // ValidatesOpenApiSchema auto_assert validates each response self::assertFalse($summary->hasSkips(), $summary->skips[0]->reason ?? ''); } } ``` ### Framework-agnostic exploration `ExploresOpenApiEndpoint` is Laravel-only; there is no Symfony equivalent. Symfony, Pest, and plain PHPUnit users call `OpenApiSpecExplorer::explore()` directly. It returns the same filter/hook builder, so everything below applies unchanged: ```php use Studio\Gesso\Fuzz\ExploredCase; use Studio\Gesso\Fuzz\OpenApiSpecExplorer; $summary = OpenApiSpecExplorer::explore('petstore', casesPerOperation: 20, seed: 7) ->dispatchUsing(fn (ExploredCase $case) => $this->sendRequest($case)) ->assertResponseUsing(fn (mixed $exchange) => $this->assertExchangeIsValid($exchange)); ``` `dispatchUsing()` returns whatever your client produces and `assertResponseUsing()` validates it. The runnable [`examples/psr7`](https://github.com/studio-design/gesso/tree/main/examples/psr7) suite demonstrates this with `OpenApiPsr7Validator` assertions. ### Filters and hooks - `includeTags()` / `excludeTags()` match when any operation tag overlaps. - `includeMethods()` / `excludeMethods()`, `includePaths()` / `excludePaths()`, and `includeOperations()` / `excludeOperations()` use exact values. Fixed HTTP methods are canonicalized; OpenAPI 3.2 custom method spelling stays case-sensitive. - Deprecated operations are excluded by default. Call `includeDeprecated()` to opt in. - `setUpUsing()` and `tearDownUsing()` run once per operation; `authenticateUsing()` runs after setup and before its cases. - `mutateCasesUsing()` runs per generated case and must return an `ExploredCase`. Its `withBody()`, `withQuery()`, `withHeaders()`, `withCookies()`, and `withPathParams()` helpers support credentials, stateful IDs, and other request-specific changes without mutating shared state. Operations that are declared but cannot be generated are returned in `SpecExplorationSummary::$skips` with their reason. This includes schema-less required inputs and methods outside `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `QUERY`. Malformed `paths`, Path Item, `additionalOperations`, and Operation Object nodes are spec errors, not unsupported operations: a structural preflight fails before any request is dispatched. Filters intentionally remove operations and therefore do not create skip entries. A filter set matching nothing fails loudly instead of producing a test that asserted nothing. ### Replay and parallel runs The global seed is deterministic. Each operation receives a stable derived seed based on `(spec, method, path, global seed)`, so adding or reordering a different operation does not change existing cases. A dispatch, mutation, or assertion failure prints the spec, operation ID, method/path, both seeds, case index, and a minimal `OpenApiEndpointExplorer::explore(...)` replay expression. Each case also exposes `replayToken()`, `replaySnippet($specName)`, and `curlSnippet($baseUrl)`. The token identifies generation inputs; the PHP and curl output include the concrete generated request. `curlSnippet()` redacts sensitive header values (`Authorization`, `Cookie`, API-key/token/secret-style names) as `` so the command is safe to paste into CI logs; pass `redactSensitiveHeaders: false` to keep the raw values. Summaries are local immutable values; the explorer adds no process-global aggregation. Run one plan per parallel worker partition and use the existing coverage sidecar/merge workflow to aggregate validated response rows. ### Safety for mutating operations Whole-spec exploration can execute `POST`, `PUT`, `PATCH`, and `DELETE`. Run it only against an isolated test database or disposable environment, wrap each case/operation in your framework's transaction reset, and start with `includeMethods(['GET', 'QUERY'])` when evaluating an unfamiliar spec. Use `includeOperations()` for a deliberate allowlist when an endpoint triggers external effects that a database rollback cannot undo. ## Generation behaviour Every purportedly valid value is checked against the converted JSON Schema before dispatch. A generator bug therefore fails locally with an `Internal fuzz generator defect` diagnostic instead of sending invalid data to the API. | Strategy | Valid generation | Targeted invalid mutation | |---|---|---| | Scalars | `type`, `const`, `enum`, nullable branches | wrong type, const/enum miss | | Strings | min/max length, common regex patterns (including anchored character classes with fixed or `+` quantifiers, FQDN labels with a fixed domain suffix, and phone-number alternations), Unicode code points, Faker-backed email/UUID/date/time/URI/host/IP formats | below/above length, pattern miss, invalid recognized format | | Numbers | inclusive/exclusive bounds and `multipleOf`, including OAS 3.0 boolean-exclusive lowering | outside/equal-exclusive bound, non-multiple | | Arrays | `items`, `prefixItems`, min/max items, `uniqueItems` | too few/many or duplicate items | | Objects | properties, required, min/max properties, schema-valued/default additional properties | missing required, extra forbidden, too few/many properties, nested property constraint | | Composition | Request cases rotate `oneOf`/`anyOf` and conditional branches. Response payload exploration guarantees at least one case for every branch of every reachable composition, nullable, and optional-property choice point. Both merge `allOf`/range assertions and preserve lowered discriminator branches. | deterministic composition miss where one can be isolated | Arbitrary regex synthesis, recursive schemas, `contains`, `patternProperties`, `dependentSchemas`, and `unevaluated*` generation are not currently strategies. Those keywords remain validator features; an operation whose valid value cannot be synthesized fails locally with a supported-subset diagnostic or is an explicit whole-spec skip carrying that reason. Branch-complete response enumeration is bounded to 32 nested property/item levels, 256 collected choice points, and 10,000 visited schema nodes across all branch contexts. Exceeding any limit fails locally with a supported-subset diagnostic; Gesso never returns partial branch coverage. - Request cases alternate optional object properties between included and omitted. Response payload exploration pins both presence branches for every reachable optional property. - Required keys are always emitted. - Path resolution accepts both the spec template form (`/v1/pets/{petId}`) and concrete URIs that match it (`/api/v1/pets/123` with `strip_prefixes=/api`). Captured URI values are intentionally discarded — `pathParams` is always regenerated from the operation spec for consistency. ## `seed` and determinism When [`fakerphp/faker`][faker] is installed, generation uses Faker's locale-aware primitives and is deterministic for a given `seed:`. Without it, ordinary strings and scalar boundaries use deterministic counter-based values. Supported character-class patterns sample multiple class members after the first boundary case, so increasing `cases:` also increases their input variety. Recognized formats such as email and UUID cannot be synthesized reliably by that fallback: the existing one-shot warning is followed by the valid-case self-check, so the operation fails locally rather than dispatching a value that does not satisfy its format. ```bash # Required when explored schemas use Faker-backed formats composer require --dev fakerphp/faker ``` ## Negative cases and reduction Negative exploration requires the expected response class. There is no implicit "anything except 5xx" fallback: ```php $this->exploreInvalidEndpoint( 'POST', '/v1/pets', expectedStatusClasses: [4], cases: 20, seed: 7, )->each(function (ExploredCase $case): void { $response = $this->call( 'POST', $case->uri('/api'), cookies: $case->cookies, server: $this->transformHeadersToServerVars([ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ...$case->headers, ]), content: $case->bodyAsJson(), ); self::assertContains(intdiv($response->getStatusCode(), 100), $case->expectedStatusClasses); }); ``` For a whole spec, add `->negativeCases([4])` before `dispatchUsing()` and inspect the same metadata in `assertResponseUsing()`. Each generated invalid case is self-checked to ensure it actually fails the complete schema. `FailureReducer::reduce($case, $classify)` deterministically removes body members only while the callback returns the original non-empty classification. Use a stable value such as `status:500` or an exception class. Reduction is deliberately classification-preserving; it never equates every failure. ## Named contract checks Schema validation cannot see protocol-level contract holes — an ignored `security` requirement, a `required: true` header nobody checks, an undocumented method that answers 200. The opt-in `ignored_auth`, `missing_required_header`, and `unsupported_method` checks probe those directly and live in `Studio\Gesso\Contract\`. See [named contract checks](contract-checks.md). ## Remaining gaps - Arbitrary ECMA-262 regex synthesis and recursive/reference-cycle generation. - Cookie and `parameters[].content` fuzz generation. - Structural shrinking inside nested arrays/objects; current reduction removes top-level body members. - Measurement-based feature parity with Schemathesis. [schemathesis]: https://github.com/schemathesis/schemathesis [faker]: https://github.com/FakerPHP/Faker --- Source: https://studio-design.github.io/gesso/contract-checks.md # Named contract checks Schema validation cannot see protocol-level contract holes. Named checks probe them directly, mirroring [Schemathesis check names][schemathesis-checks] where behavior matches. The pipeline is opt-in per check, deterministic under `seed`, and collects every result before you assert — one run reports all holes, not just the first. The checks live in `Studio\Gesso\Contract\`; they consume generated cases from `Studio\Gesso\Fuzz\` (`ExploredCase`, `ExploredOperation`), never the reverse. The `Studio\Gesso\Fuzz\` spellings still resolve through a deprecated alias until v3.0 — see [Deprecations in `UPGRADING.md`](https://github.com/studio-design/gesso/blob/main/UPGRADING.md#deprecations). ```php use Studio\Gesso\Contract\ContractCheck; use Studio\Gesso\Contract\OpenApiContractChecks; use Studio\Gesso\Fuzz\ExploredCase; // still Fuzz\: a generated case $summary = OpenApiContractChecks::run('petstore', seed: 7) ->checks([ ContractCheck::IgnoredAuth, ContractCheck::MissingRequiredHeader, ContractCheck::UnsupportedMethod, ]) ->dispatchUsing(fn (ExploredCase $case): int => $this->send($case)->getStatusCode()) ->report(); self::assertSame([], $summary->failures, $summary->describeFailures()); ``` | Check | Probe | Default pass statuses | |---|---|---| | `ignored_auth` | per operation with an effective `security` requirement: the valid request with no credentials, then again with credentials the API cannot have issued | `401`, `403` | | `missing_required_header` | per `required: true` header parameter: the valid request with that one header omitted, gated behind a 2xx control request; state-changing methods need `dispatchIsolatedUsing()` | any `4xx` | | `unsupported_method` | one deterministically chosen undocumented method per documented path | `405` | `dispatchUsing()` may return an `int` status, a PSR-7 response, or any object exposing `getStatusCode(): int` (Symfony `Response`, Laravel `TestResponse`). The same `includeTags()`/`excludePaths()`/… filters and `setUpUsing()`/`authenticateUsing()`/`tearDownUsing()` hooks as whole-spec [exploration](fuzzing.md) apply; a path is probed when at least one of its documented operations passes the filters. `ignored_auth` is the one exception to `authenticateUsing()`: its probes deliberately bypass the hook, because handing them credentials is exactly what the check exists to prevent. Override an expectation per check with `expectedStatuses(ContractCheck::UnsupportedMethod, [405, 404])` for frameworks that answer unknown methods with 404, or `expectedStatusClasses(ContractCheck::IgnoredAuth, [4])` to accept any client error. Either call replaces the check's whole default expectation, so `expectedStatuses(ContractCheck::MissingRequiredHeader, [400])` means exactly 400 — the default `4xx` class no longer widens it. ### `ignored_auth` An operation is probed when its effective `security` — operation-level if declared, root-level otherwise — actually demands credentials. `security: []` and a requirement list containing an empty `{}` entry both document unauthenticated access, so they are skipped rather than reported. Anything else malformed is a hard error instead — reading it as "no authentication required", or probing a declaration the runtime validator rejects, would turn a broken spec into a green run. That covers the outer node (`security: "not-a-list"`, a scalar requirement entry) and the inside of each entry: non-list scopes, non-string scope items, undefined scheme names, and malformed scheme definitions. The detection rules are the runtime validator's own (`SecurityValidator::inspectRequirementPair()`), shared rather than re-derived, so the two cannot disagree about which specs are broken. The invalid-credential probe writes a placeholder into every credential location the operation declares (all of them, so an AND-style requirement is genuinely exercised): `Authorization: Bearer …` for `http` + `bearer`, and the declared header / query / cookie name for `apiKey`. Those are the schemes [request validation can locate](supported-features.md#security-schemes); operations secured solely by `oauth2`, `openIdConnect`, `mutualTLS`, or non-bearer `http` schemes get the no-credential probe plus a skip for the other one, rather than a fabricated credential in a guessed location. The no-credential probe also strips any `apiKey` header, query, or cookie value the generated valid case happened to carry, so it is genuinely credential-free. It cannot, however, see credentials your own `dispatchUsing()` closure adds — send the case as-is there. **Cookie credentials require dispatcher cooperation.** `apiKey` + `in: cookie` values land in `$case->cookies`, not in a `Cookie:` request header, because Laravel's and Symfony's test clients build their cookie bag from a separate `SymfonyRequest::create()` argument — a header alone leaves `$request->cookie(...)` empty. A dispatcher that drops `$case->cookies` makes the invalid-credential probe identical to the no-credential one, and an API that accepts any cookie value would pass. Forward them: ```php ->dispatchUsing(fn (ExploredCase $case): int => $this->call( $case->method->value, $case->uri(), [], $case->cookies, // <- not optional for cookie-secured operations [], $this->transformHeadersToServerVars($case->headers), $case->body !== null ? $case->bodyAsJson() : null, )->getStatusCode()) ``` ### `missing_required_header` One probe per `required: true` `in: header` parameter, each omitting exactly that header so the failure names the one that was not enforced. `Accept`/`Content-Type`/`Authorization` parameter declarations are excluded: per OAS 3.x §4.7.12.1 they are ignored, and content negotiation and security schemes own them. Operations that declare no required header are skipped with a reason. The default expectation is the `4xx` class rather than a status list, because frameworks answer a missing required header with 400, 406, 422, or a scheme-specific 401/403 — pinning one of them would report framework choice as contract drift. Accepting a family that wide is only sound with a control: each operation first dispatches the **unmutated** valid case, and the omission probes run only when that control answers a 2xx. Without it, an operation that never enforces the header would score green because the request was rejected for an unrelated reason — no credentials configured, a missing fixture, a 404. A 3xx does not qualify either: Laravel answers an unauthenticated non-JSON request with a 302 to the login page, and comparing the omission probes against that redirect proves nothing. When the control does not return 2xx, the operation is skipped with its status in the reason, so use `setUpUsing()` / `authenticateUsing()` to make the valid request succeed. The control counts toward `$summary->dispatchedProbes`. **State-changing methods are skipped by default.** On `POST` / `PUT` / `PATCH` / `DELETE`, the control request's own effect can answer the probes that follow it — a duplicate create is a 409, an already-deleted resource is a 404, and both sit inside the accepted 4xx class — so the status cannot distinguish them from real header enforcement. Nothing observable from inside the plan tells an isolated dispatcher from a leaky one. Repeating the control does not: an idempotent create answers 201 twice and still leaves the row behind, so the next probe collides with it. General hooks do not either — `setUpUsing()` / `tearDownUsing()` may exist for authentication, logging, or fixtures, and a no-op one proves nothing. So the decision is the caller's, stated on the dispatcher that has to honour it: ```php ->dispatchIsolatedUsing(fn (ExploredCase $case): int => $this->send($case)->getStatusCode()) ``` `dispatchIsolatedUsing()` registers the dispatcher **and** promises that every dispatch starts from state indistinguishable from the one before it — a transaction rolled back around each call, a database refreshed per call, a fixture rebuilt per call. Only make that promise where the isolation is really implemented; the plan cannot check it. `dispatchUsing()` is the same dispatcher without the promise, and leaves state-changing methods skipped with the reason naming this method (calling it after `dispatchIsolatedUsing()` withdraws the promise). `GET` and `QUERY` are safe methods (RFC 9110 §9.2.1) and are probed either way. ### `unsupported_method` Probe construction: candidates are the explorer-supported methods (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `QUERY`) minus every method the path documents — fixed fields and `additionalOperations` keys, matched case-sensitively, so a (spec-invalid but runtime-honored) `"PUT"` entry under `additionalOperations` removes `PUT` from the candidates while a custom `"copy"` entry removes nothing. OpenAPI 3.2 `additionalOperations` are never probed themselves (case-sensitive custom methods). Concrete path parameters are generated from a documented operation of the same path; the probe sends no body, query, or headers, so only path-parameter generatability gates it — a documented operation whose required request body is not JSON (for example a form-encoded OAuth token endpoint) does not prevent the probe. When the concrete probe URI is also an instance of a different documented template (`/members/me` alongside `/members/{member_id}`), methods documented by that colliding template are excluded too — the application would route the probe to the documented operation, so a failure there would be a false positive. Paths where every explorable method is documented, where every remaining candidate collides with another template, or where no documented operation's path parameters can be generated, appear in `$summary->skips` with a reason. Two caveats: `HEAD`/`OPTIONS`/`TRACE` are outside the explorer's method set and are not probed, and a probe response cannot pass normal response validation (its method is undocumented by definition) — disable Laravel's `auto_assert` in contract-check tests or dispatch outside the trait's helpers. ### Reading the results `ContractCheckFailure::describe()` names the check, the operation, the mutation the probe dispatched, the expected and actual status, and a replayable curl command: ```text ignored_auth: GET /orders/{orderId} (showOrder) [no credentials] — expected 401 or 403, got 200 Curl: curl -X GET '/orders/42' ``` Every reason a probe was not dispatched lands in `$summary->skips` as a `ContractCheckSkip` carrying the check, path, method (null for path-level skips), and an explanation. Nothing is dropped silently — a check that found no work says so. ### Compared with Schemathesis Check names match [Schemathesis][schemathesis-checks] so cross-tool documentation and CI dashboards line up, and the probes match its behavior: `ignored_auth` sends two extra requests per operation (no credentials, then invalid ones), `unsupported_method` expects 405, and `missing_required_header` expects a client error. Two deliberate differences: Gesso accepts 403 alongside 401 for `ignored_auth` (many PHP frameworks answer an unauthenticated request with 403), and it does not assert the RFC 9110 `Allow` header on a 405 — only the status. The rest of the Schemathesis check catalog (`use_after_free`, `ensure_resource_availability`, `max_response_time`, and the response-conformance family) is out of scope here; response conformance is what `OpenApiResponseValidator` and the coverage tracker already cover. [schemathesis-checks]: https://schemathesis.readthedocs.io/en/stable/reference/checks/ --- Source: https://studio-design.github.io/gesso/sdk-roundtrip.md # SDK response round-trip testing Server response validation proves that the provider emits spec-valid data. It does not prove that a generated client SDK can deserialize every shape allowed by the same response schema. `OpenApiResponseExplorer` closes that boundary by generating valid response payloads, handing them to your SDK, and verifying the SDK's re-encoded output. ## Plain PHPUnit Configure specs in the normal test bootstrap, then select one response by its method, path, wire status, and optional content type: ```php use Studio\Gesso\Fuzz\GeneratedResponseCase; use Studio\Gesso\Fuzz\OpenApiResponseExplorer; use Studio\Gesso\Spec\OpenApiSpecLoader; OpenApiSpecLoader::configure(__DIR__ . '/specs'); OpenApiResponseExplorer::explore( 'front', 'POST', '/oauth/introspect', 200, contentType: 'application/json', seed: 1, )->each(function (GeneratedResponseCase $case): void { // The generated SDK must accept every spec-valid payload. $model = ObjectSerializer::deserialize( $case->bodyAsObject(), IntrospectResponse::class, ); // The SDK must also preserve every generated value when it re-encodes. $case->assertRoundTrip( ObjectSerializer::sanitizeForSerialization($model), ); }); ``` Omit `contentType` to select the first JSON-compatible media type exactly as response validation does. Status resolution also shares validator behavior: an exact response key wins, followed by an `X00` range key and `default`. ## Named component models Some generated SDK models map directly to `components.schemas` entries and are not reachable through one useful operation/status pair. Select those models by their exact, case-sensitive component name: ```php OpenApiResponseExplorer::exploreComponent( specName: 'front', schemaName: 'IntrospectResponse', seed: 1, )->each(function (GeneratedResponseCase $case): void { $model = ObjectSerializer::deserialize( $case->bodyAsObject(), IntrospectResponse::class, ); $case->assertRoundTrip( ObjectSerializer::sanitizeForSerialization($model), ); }); ``` Component schemas use the spec's OpenAPI version, JSON Schema dialect, response-side read/write semantics, and discriminator context. The returned cases have `null` `status` and `contentType`, while `replaySnippet()` records an `exploreComponent()` call. Unknown, malformed, recursive, and otherwise unsupported component schemas throw instead of producing an empty green run. ## Exercise every mapped response in a spec Use `exploreSpec()` when one test should discover the selected operations and exercise every mapped JSON response schema: ```php $summary = OpenApiResponseExplorer::exploreSpec('front', seed: 1) ->includeTags(['public']) ->mapResponse( operationId: 'introspect', status: 200, decode: static fn (GeneratedResponseCase $case): mixed => ObjectSerializer::deserialize( $case->bodyAsObject(), IntrospectResponse::class, ), encode: static fn (mixed $model): mixed => ObjectSerializer::sanitizeForSerialization($model), ) ->failOnUnmapped() ->assertRoundTrips(); ``` Mappings are explicit `(operationId, declared response status)` pairs. The status accepts an integer/exact string such as `200`, a range key such as `2XX`/`2xx`, or `default`. One mapping applies to every JSON media type under that response; Gesso never guesses model names. The decode callback receives the `GeneratedResponseCase` and `ExploredOperation`. The encode callback receives the decoded value followed by the same case and operation, so either callback can use response and replay metadata. The plan supports the same include/exclude filters for tags, methods, paths, operation IDs, and deprecated operations as whole-spec request exploration. Deprecated operations are excluded by default. Operations without an `operationId` remain discoverable, but their schemas are mapping gaps because there is no stable mapping key to guess. Every operation receives a crc32-derived seed based on the spec name, method, path, and global seed. Mapping registration and spec traversal order therefore do not change an existing operation's generated cases. `extraCases` appends the requested deterministic cases to every explored schema. When all mapped callbacks succeed, `assertRoundTrips()` returns a `ResponseSpecExplorationSummary` with executed operation, response-schema, and case counts plus structured skips. Unmapped JSON schemas are always skips with `mappingGap: true`; `failOnUnmapped()` promotes all such gaps to one assertion failure after discovery. No-content, non-JSON-only, missing-schema, and `itemSchema` responses are explicit non-mapping skips with structured reasons. Malformed spec nodes fail immediately with the shared response resolver's location-aware diagnostic. Decoder exceptions and encode/round-trip failures are collected across all remaining cases, then reported under separate `Decode failures` and `Round-trip failures` headings. Each row includes the operation, declared and representative wire statuses, content type, seed, case, pinned branch, and `OpenApiResponseExplorer::explore()` replay expression. ## SDK exercise coverage Gesso records an SDK exercise attempt immediately before it invokes a decoder callback. Both supported callback paths contribute: - `GeneratedResponseCases::each()` for one operation response; - the decode callbacks executed by `exploreSpec(...)->assertRoundTrips()`. Generating a collection or iterating it manually with `foreach` does not prove that a decoder ran and records nothing. Use `each()` when you want direct exploration to contribute to SDK exercise coverage. Named `exploreComponent()` cases also remain outside this metric because a component does not identify a response `(method, path, status, content-type)`. The denominator is derived independently from the live spec. It contains every resolved JSON-compatible response media type with a schema, and excludes no-content responses, non-JSON media types, missing or non-JSON schemas, and OpenAPI 3.2 `itemSchema` streams. An attempt counts even if the decoder then throws: coverage answers whether the boundary was exercised, while the thrown exception or round-trip mismatch remains a normal test failure. The console, Markdown, HTML, JUnit, and JSON coverage reports show exercised and unexercised SDK response schemas. See the [coverage threshold gate](coverage.md#coverage-threshold-gate) to enforce a minimum percentage. ## Laravel The `ExploresOpenApiEndpoint` trait uses the same spec-name precedence as the validation traits: method/class `#[OpenApiSpec]`, `openApiSpec()`, then `default_spec`. ```php use PHPUnit\Framework\TestCase; use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Fuzz\GeneratedResponseCase; use Studio\Gesso\Laravel\ExploresOpenApiEndpoint; #[OpenApiSpec('front')] final class IntrospectSdkTest extends TestCase { use ExploresOpenApiEndpoint; public function test_sdk_accepts_every_introspection_shape(): void { $this->exploreResponseSchema( 'POST', '/oauth/introspect', 200, seed: 1, )->each(function (GeneratedResponseCase $case): void { $model = ObjectSerializer::deserialize( $case->bodyAsObject(), IntrospectResponse::class, ); $case->assertRoundTrip(ObjectSerializer::sanitizeForSerialization($model)); }); } } ``` For a spec-wide plan, replace the single-response call with `$this->exploreResponseSpec(seed: 1)`. It returns the same `OpenApiResponseSpecExploration` builder shown above while resolving the spec name from the method/class attribute, `openApiSpec()`, or `default_spec`. Schema-to-model mapping deliberately stays in your test. Gesso does not guess class names or conventions from openapi-generator, Kiota, or another SDK tool. ## Branch-complete cases For a fixed converted schema and seed, the explorer produces at least one case for every branch of every reachable supported choice point: - `oneOf` and `anyOf` branches; - `if`/`then`/`else` and conditional `allOf` branches; - nullable branches; - presence and omission of optional object properties, including choice points nested beneath those properties. The number of cases is therefore derived from the schema. `extraCases` defaults to `0` and appends deterministic rotation-only cases when you want more values; it does not replace any branch-pinned case. The existing supported-subset and enumeration bounds remain loud. Every generated value is self-validated before your callback runs. Omitting `seed` uses the replayable default seed `0`; the effective seed is stored on every case and included in `replaySnippet()`. ## Case values and fidelity `GeneratedResponseCase` is readonly and exposes: | Member | Meaning | |---|---| | `body` | Raw generated PHP value | | `bodyAsObject()` | JSON round-trip with objects decoded as `stdClass`; arrays and scalars retain their JSON shape | | `bodyAsArray()` | Lossy associative compatibility view: empty objects become arrays, even when nested; scalar bodies throw. Prefer `bodyAsObject()` for SDK input | | `status`, `contentType` | Selected wire status and declared media-type key; both are `null` for named components | | `seed`, `caseIndex` | Deterministic replay identity | | `pinnedBranch` | Target JSON Pointer plus zero-based branch, for example `/properties/aud/oneOf@0`; `null` for an extra case | | `replaySnippet()` | Minimal `explore(...)` or `exploreComponent(...)` reproduction | `assertRoundTrip()` makes two assertions in order: 1. The re-encoded value must satisfy the same converted JSON Schema used to generate the case, including the selected OpenAPI dialect and discriminator enforcement. 2. Every generated object key/value must survive recursively. SDK-specific extra object keys are allowed, while generated JSON lists and scalar values compare exactly, including scalar types. Assertion failures include the seed, case index, pinned branch descriptor, and replay snippet. Exceptions thrown directly by the SDK decoder remain visible to PHPUnit unchanged. ## Loud unsupported responses Exploration never returns an empty collection. Resolution failures and response shapes that cannot represent one buffered JSON document throw `InvalidArgumentException` with a structured outcome name. These include: - responses without a `content` block, including 204-style responses; - responses declaring only non-JSON media types; - selected media types without `schema`; - selected non-JSON schemas; and - OpenAPI 3.2 streaming responses using `itemSchema`. Use `explore()` for one resolved operation response, `exploreComponent()` for one named model schema, or `exploreSpec()` for explicit mappings across a spec. --- Source: https://studio-design.github.io/gesso/contract-testing-symfony.md # Contract testing a Symfony API with OpenAPI Contract testing a Symfony API with OpenAPI takes four moving parts: the OpenAPI spec, a `WebTestCase` (or a plain `Request`/`Response` pair), one trait, and its assertions. This guide covers all four, then shows the full runnable example. ## What is OpenAPI contract testing? **OpenAPI contract testing verifies that your live HTTP requests and responses conform to the OpenAPI schema you publish.** The spec is the source of truth; the tests confirm the implementation still matches it — nothing more, nothing less. This is the *provider-side, spec-driven* flavour of contract testing. It is different from consumer-driven contract testing (Pact and friends), which starts from what each consumer expects. Both are valid. Provider-side spec testing shines when you already publish OpenAPI and want a fast, per-endpoint check that the implementation hasn't drifted. ## What you'll need - PHP 8.3+ and Symfony 6.4, 7.x, or 8.x - A Symfony application with `symfony/http-foundation`, `symfony/http-kernel`, and — for the `WebTestCase` / `KernelBrowser` flow — `symfony/framework-bundle` installed - An OpenAPI 3.0, 3.1, or 3.2 document you want to enforce - Gesso (`studio-design/gesso`), the framework-independent core plus its Symfony adapter Install: ```bash composer require --dev "studio-design/gesso:^2.0" \ symfony/http-foundation symfony/http-kernel symfony/framework-bundle ``` The Symfony packages are suggestions, not hard requirements of Gesso — the core stays framework-independent, so you pull them in yourself to use the Symfony adapter. Skip `symfony/framework-bundle` if you only want the plain `Request`/`Response` form (shown further down); include it for the `WebTestCase` flow. ## The four moving parts ### 1. The spec Gesso finds spec files through the PHPUnit extension, not a Symfony bundle or a `config/packages/*.yaml` file. Register the extension in `phpunit.xml`: ```xml ``` `spec_base_path` is the directory holding your documents; `specs` lists the ones to report coverage for. YAML or JSON, 3.0.x, 3.1.x, or 3.2.x — the same loader accepts all of them (YAML needs `symfony/yaml`). Which spec a given test uses is pinned by a `#[OpenApiSpec]` attribute or by overriding `openApiSpec()`; without either, set `spec.default` in [`gesso.php`](configuration.md) (or the `default_spec` extension parameter). ### 2. A WebTestCase Extend Symfony's `WebTestCase` (or a plain PHPUnit class if you're constructing requests by hand): ```php use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; class PetsApiTest extends WebTestCase { // ... } ``` ### 3. The trait Mix in the Gesso Symfony assertion trait and name the spec: ```php use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Symfony\OpenApiAssertions; #[OpenApiSpec('petstore')] class PetsApiTest extends WebTestCase { use OpenApiAssertions; } ``` The trait adds three assertion methods: `assertResponseMatchesOpenApiSchema($request, $response)` for responses, `assertRequestMatchesOpenApiSchema($request)` for requests, and `assertClientMatchesOpenApiSchema($client)`, which runs both against the last call made by a test client. ### 4. The assertion Either shape works. When you already have a `KernelBrowser` from `static::createClient()`: ```php public function test_get_pets_matches_spec(): void { $client = static::createClient(); $client->request('GET', '/pets'); $this->assertResponseIsSuccessful(); $this->assertClientMatchesOpenApiSchema($client); } ``` When you're constructing `Request` and `Response` objects directly — useful for unit-style tests that don't boot the kernel: ```php use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; $request = Request::create('/pets', 'GET'); $response = new Response( '[{"id":1,"name":"Fido"}]', 200, ['Content-Type' => 'application/json'], ); $this->assertRequestMatchesOpenApiSchema($request, 200); $this->assertResponseMatchesOpenApiSchema($request, $response); ``` Both forms record spec coverage. ## The full example The `examples/symfony` fixture in the Gesso repository installs and runs in CI on every push. To reproduce locally: ```bash git clone https://github.com/studio-design/gesso.git cd gesso/examples/symfony composer install composer test ``` The PHPUnit output ends with an `OpenAPI Contract Test Coverage` report: per spec, the share of endpoints fully covered and of `(method, path, status, content-type)` response rows validated, then a per-endpoint breakdown marking each row validated, skipped, uncovered, or request-only. That report is the point of running the tests: passing tests prove *these* operations conform; the coverage report shows which ones you haven't covered yet. ## What to check next Contract enforcement is the entry point. Three follow-ups matter for a real Symfony service: - **Coverage in CI.** Set `min_endpoint_coverage` / `min_response_coverage` on the PHPUnit extension so a PR that quietly stops testing an operation fails the build. - **Preflight your spec.** Run `vendor/bin/gesso doctor --spec=openapi/petstore.json --strip-prefix=/api` in CI. It reports whether Gesso can load and enforce the document — version and dialect, references, warned-about keywords, structurally valid operations — and exits non-zero when it can't. - **Schema-driven exploration.** Once the happy paths are green, generate request cases from the spec's schemas with `OpenApiSpecExplorer::explore()` and assert each dispatched response with the same validators the trait uses. ## FAQ **Does this replace consumer-driven contract testing?** No. Provider-side spec testing verifies that your implementation matches the spec you publish. Consumer-driven testing (Pact) verifies that specific consumers' expectations are still met. If you publish OpenAPI and every consumer generates from it, provider-side testing covers the contract. If you have consumers with expectations the spec doesn't capture, keep Pact for those. **Does it work with a generated spec, like API Platform's?** Yes. Gesso loads a spec file; it does not care how the file was produced. OpenAPI documents generated by other tools — including API Platform — are treated the same as hand-authored ones, so long as the output is a valid OpenAPI 3.0/3.1/3.2 document on disk. **What about performance in a large test suite?** The parsed spec is cached and reused across assertions in the same test run. Validation cost per assertion scales with response body size and schema depth, not the total number of tests. **Does it validate requests too, or only responses?** Both, but with separate calls. `assertClientMatchesOpenApiSchema($client)` validates the outbound request and then the response of the last client call. `assertResponseMatchesOpenApiSchema($request, $response)` checks only the response — the `Request` is there to supply the method and path. Use `assertRequestMatchesOpenApiSchema($request)` for the request side of hand-built pairs. **Which OpenAPI versions are supported?** 3.0.x, 3.1.x, and 3.2.x. `oneOf`/`anyOf`/`allOf`/`not` are validated in every supported dialect, and `discriminator` mappings are enforced by default rather than treated as a hint. OpenAPI 3.1/3.2 keep native JSON Schema 2020-12 semantics — `const`, `prefixItems`, `unevaluatedProperties`/`unevaluatedItems`, `dependentSchemas`/`dependentRequired`, `$dynamicRef`/`$dynamicAnchor`. OpenAPI 3.0 runs through a Draft 07 compatibility pipeline, so those 2020-12-only keywords warn instead of being enforced there. ## Next step The Symfony adapter is one of four; the same core powers Laravel, Pest, and PSR-7 setups. Start from the [Symfony quickstart](quickstarts/symfony.md), or jump to the [Schema-driven fuzzing guide](fuzzing.md) once your happy paths are green. Source and issue tracker: [github.com/studio-design/gesso](https://github.com/studio-design/gesso). MIT licensed. --- Source: https://studio-design.github.io/gesso/spec-driven-vs-consumer-driven.md # Spec-driven vs consumer-driven contract testing in PHP: when to use Gesso vs Pact PHP contract testing splits into two shapes: **spec-driven**, where an OpenAPI document is the source of truth and every request and response is validated against it, and **consumer-driven**, where each consumer records its expectations and the provider replays them. Pact PHP is the reference tool for the second shape. Gesso is a reference tool for the first. This guide is a decision framework for picking between them. ## What is spec-driven contract testing? **Spec-driven contract testing verifies that the live implementation still matches an OpenAPI document you publish.** The spec is the source of truth. The tests confirm that every request the provider accepts and every response it returns conforms to that spec — nothing more, nothing less. In PHP, this is what Gesso (`studio-design/gesso`) does. You point it at an OpenAPI 3.0/3.1/3.2 file, add one trait to your PHPUnit or Pest test case, and each assertion validates the traffic against the spec while accumulating endpoint coverage. ## What is consumer-driven contract testing? **Consumer-driven contract testing verifies that a provider still satisfies the expectations recorded by each of its consumers.** The consumers own the contracts. Each consumer runs a mock server, records the requests it sends and the responses it expects, and publishes the resulting pact file. The provider then verifies those pacts against a live implementation. In PHP, this is what Pact PHP (`pact-foundation/pact-php`) does. Since v10 it binds to the shared Pact core used by every Pact language implementation, so matching rules stay consistent across stacks (this requires ext-ffi with `ffi.enable=true`). It gives you a mock server for consumer tests, and a verifier that replays pacts against your provider. ## Comparison at a glance | | Spec-driven (Gesso) | Consumer-driven (Pact PHP) | |---|---|---| | **Source of truth** | OpenAPI document | Consumer-recorded pacts | | **Who writes the contract** | The provider team (API author) | Each consumer | | **What is verified** | Live request/response conform to the spec | Live provider still satisfies each consumer's expectations | | **Runs where** | Provider's PHPUnit / Pest suite | Consumer's test suite (records the pact) + the provider's verification run — also PHPUnit-based | | **Shared infrastructure** | None — the spec file lives in the repository | None required — pact files can move as CI artifacts; a Pact Broker / PactFlow is what makes cross-team sharing and `can-i-deploy` practical | | **Framework surface** | Framework-independent core + Laravel / Symfony / Pest / PSR-7 adapters | Framework-agnostic: provider verification targets any HTTP-reachable app, so no per-framework adapter is needed (v10 runs on the shared Pact core via ext-ffi) | | **What the tooling reports** | Which spec operations, statuses, and content types are exercised | Verification status for each interaction, per consumer version and environment (via the broker) | | **Consumers you don't control** | Covered as far as the spec describes them — anything your suite exercises is checked against the same document they code against | Out of scope by design — a contract exists only for consumers that publish one | | **Expectations the spec doesn't encode** | Out of scope by design — Gesso enforces exactly what the document states | This is the point — each consumer pins what it actually depends on | | **Detects drift from published docs** | Yes | Not its concern — pacts describe consumer expectations, not the published document (PactFlow's bi-directional contract testing adds this comparison separately) | ## When spec-driven (Gesso) fits better Reach for spec-driven testing when the spec is the contract in practice. - **You publish OpenAPI as your API's public documentation.** Every consumer — internal, external, unknown — is expected to code against the spec. Drift between docs and implementation is the failure mode you care about. - **You own the API and don't control every consumer.** Public APIs, partner APIs, and internal APIs consumed by teams you don't sit with all share this shape. - **You already generate SDKs, mocks, or docs from OpenAPI.** The spec is load-bearing; contract testing should reinforce it, not compete with it. - **You want per-endpoint coverage as a first-class output.** Gesso reports the share of operations and `(method, path, status, content-type)` rows exercised by your suite, so a merged PR that quietly stops testing an endpoint shows up in the coverage delta. - **You want contract enforcement to be a plain assertion in the suite you already run.** No artifact to publish, nothing to coordinate between repositories — the check runs on the same PR that changes the code. Minimal shape (Symfony; the Laravel, Pest, and plain-PSR-7 variants read almost identically): ```php use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Symfony\OpenApiAssertions; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; #[OpenApiSpec('petstore')] class PetsApiTest extends WebTestCase { use OpenApiAssertions; public function test_get_pets_matches_spec(): void { $client = static::createClient(); $client->request('GET', '/pets'); $this->assertResponseIsSuccessful(); $this->assertClientMatchesOpenApiSchema($client); } } ``` Every passing assertion also records coverage against the pinned spec. ## When consumer-driven (Pact PHP) fits better Reach for consumer-driven testing when consumers have expectations the spec can't or shouldn't capture. - **You own several services that call each other.** You control both sides. Consumers can publish pacts, providers can verify them, and the broker becomes the integration ledger for the whole platform. - **Consumer expectations are narrower than the spec.** A consumer may only care about three fields of a response even though the spec defines twenty. Pact makes that narrow expectation explicit and lets the provider evolve the rest safely. - **You want a cross-service break to block the deploy of the service that caused it.** Provider verification runs against a candidate build, and a failed verification makes `can-i-deploy` refuse that service's release — the failure lands on the provider's pipeline instead of surfacing in each consumer later. - **You want to answer "can I deploy this?" for a specific environment.** The Pact Broker's `can-i-deploy` check is the workflow output; there is no direct equivalent in spec-driven testing. Minimal shape on the consumer side (Pact PHP v10; requires `ffi.enable=true`): ```php use PhpPact\Consumer\InteractionBuilder; use PhpPact\Consumer\Model\ConsumerRequest; use PhpPact\Consumer\Model\ProviderResponse; use PhpPact\Standalone\MockService\MockServerConfig; $config = (new MockServerConfig()) ->setConsumer('PetsWebApp') ->setProvider('PetsApi') ->setHost('127.0.0.1') ->setPort(7200); $request = (new ConsumerRequest())->setMethod('GET')->setPath('/pets'); $response = (new ProviderResponse()) ->setStatus(200) ->setBody([['id' => 1, 'name' => 'Fido']]); $builder = new InteractionBuilder($config); $builder ->uponReceiving('a request for pets') ->with($request) ->willRespondWith($response); // must be last — registers the interaction $result = (new PetsClient($config->getBaseUri()))->list(); $this->assertSame('Fido', $result[0]['name']); $this->assertTrue($builder->verify()); ``` The provider later verifies this pact against a real handler wired into its test harness. ## A short decision framework Pick spec-driven (Gesso) when at least two of these are true: 1. OpenAPI is already the artifact you publish for consumers to code against. 2. You don't fully control every consumer of the API. 3. You want contract enforcement to live inside PHPUnit or Pest with no extra infrastructure. 4. Drift between docs and implementation is the specific failure you keep hitting. Pick consumer-driven (Pact PHP) when at least two of these are true: 1. You own the consumers and the provider, and they ship independently. 2. Consumer expectations are narrower or more specific than the full spec. 3. You want `can-i-deploy` gating on a broker as part of your release flow. 4. Multiple consumer teams need to encode expectations the provider team doesn't get to write for them. ## When *not* to use Gesso Spec-driven testing isn't a universal answer. Gesso is not the right pick when: - **You don't publish an OpenAPI document.** Gesso needs a spec on disk, and writing one only to enable testing is the wrong order. Consumer-driven contracts don't need a spec at all — if you want contract testing now, Pact is the shorter path. - **The contracts you care about are cross-service, consumer-owned expectations.** Two internal services with a small, stable integration surface get more from a pact than from a full OpenAPI validation. - **You need `can-i-deploy` gating tied to a broker.** Gesso doesn't provide it; the Pact ecosystem does — the check itself ships with the Pact Broker / PactFlow, not with pact-php. - **You test against non-HTTP transports** — asynchronous messages (Pact's message pact), or gRPC via the Pact Protobuf plugin. Gesso validates HTTP requests and responses against OpenAPI; it has no message-pact equivalent. - **Your team is drowning in flaky end-to-end tests between two services you both own.** In that shape, consumer-driven contracts replace those end-to-end tests more directly, because the pact is the thing the two teams argue about. ## Can you use both? Yes, and larger PHP shops often do. A common pattern: - Gesso enforces that the provider's implementation still matches the OpenAPI document on every PR. Public / partner consumers rely on the spec being trustworthy. - Pact covers the handful of internal services with narrow, cross-team expectations that don't belong in the public spec — think an internal billing pipeline consuming three fields from an account service that publishes twenty. The two tools don't fight; they answer different questions. ## FAQ **Is Gesso a Pact alternative?** Only for teams whose question is "does my implementation still match my OpenAPI spec?" If the question is "does my provider still satisfy every consumer's recorded expectations?", Pact PHP is the right tool. The two solve overlapping but distinct problems. **Do I need a broker to use Gesso?** No. The OpenAPI file lives in the repository and Gesso runs inside PHPUnit or Pest, so there is no additional service in the loop. **Which OpenAPI versions does Gesso support?** 3.0.x, 3.1.x, and 3.2.x. `oneOf`/`anyOf`/`allOf`/`not` are validated in every supported dialect, and `discriminator` mappings are enforced rather than treated as a hint. 3.1 and 3.2 run against native JSON Schema 2020-12 semantics where the underlying validator supports them; the handful of 2019-09/2020-12 keywords the validator doesn't implement emit a one-shot warning rather than being silently ignored. 3.0 runs through a Draft 07 compatibility pipeline. **Can Gesso test consumer code, the way Pact does on the consumer side?** Not in the pact sense. Gesso validates HTTP traffic against a spec on either side of the wire, so you can point it at a consumer's outgoing requests to check they match the spec. It won't, however, record a pact that a provider then verifies — that workflow belongs to Pact. **Does spec-driven testing catch the same regressions as consumer-driven testing?** It catches every drift between the implementation and the published spec. It does *not* catch consumer expectations that the spec never encoded — for example, a consumer that depends on an optional field always being present in practice, or on one particular enum value it branches on. The spec permits both variations; the pact records that this consumer needs one of them. If those expectations matter, Pact is complementary, not redundant. **Where does Dredd fit in?** Dredd is closer to spec-driven than consumer-driven — it runs the examples in an API description document as tests. In PHP projects that already publish OpenAPI, Gesso runs that same style of check from inside the existing suite, with coverage as an output. Dredd still fits when the checks need to run outside a PHP test runner. ## Next step If your API is spec-first and you want to try spec-driven contract testing today, start from the [Core quickstart](quickstarts/core.md), or jump straight to the framework you use: [Laravel](quickstarts/laravel.md), [Symfony](quickstarts/symfony.md), or [Pest](quickstarts/pest.md). See also [Contract testing a Symfony API with OpenAPI](contract-testing-symfony.md) for a full end-to-end walkthrough of the spec-driven path. If your integration surface is consumer-owned and you'd get more from pacts, the [Pact PHP documentation](https://docs.pact.io/implementation_guides/php/readme) is the right starting point — the choice is a fit question, not a loyalty one. Source and issue tracker: [github.com/studio-design/gesso](https://github.com/studio-design/gesso). MIT licensed. --- Source: https://studio-design.github.io/gesso/php-openapi-validation-faq.md # PHP OpenAPI Validation FAQ Direct answers to the ten questions engineers ask most often about validating HTTP requests and responses in a PHP API against an OpenAPI spec. Every code block is drawn from the Gesso repository and runs in CI. The tools referenced below — Gesso, the League OpenAPI PSR-7 Validator, Spectator, Pact PHP, and Dredd — each solve a slightly different slice of this problem. Where the fit is closer than "Gesso wins", the answer says so. ### How do I validate HTTP requests against an OpenAPI spec in PHP? Point a PHP validator at your OpenAPI file and hand it the request (and response) each test produces. Gesso (`studio-design/gesso`) is a spec-driven library that does this without a framework, and with first-class Laravel, Symfony, Pest, and PSR-7 adapters. Install it, register the PHPUnit coverage extension so it can locate your specs, and assert against the spec inside your existing test suite: ```php use Studio\Gesso\OpenApiResponseValidator; use Studio\Gesso\Validation\Strict\StrictRequiredTracker; $result = (new OpenApiResponseValidator(new StrictRequiredTracker()))->validate( 'petstore', 'GET', '/pets', 200, [['id' => 1, 'name' => 'Fido']], 'application/json', ); self::assertTrue($result->isValid(), $result->errorMessage()); ``` The League OpenAPI PSR-7 Validator and Spectator solve the same shape of problem. Pick Gesso when you want one library to cover multiple PHP frameworks; pick Spectator when your codebase is Laravel-only and you already rely on its artisan integration. ### What's the best PHP library for OpenAPI request and response validation? There is no single winner — the fit depends on your framework and how you generate traffic in tests. Gesso is the most versatile of the current options: framework-independent core plus dedicated Laravel, Symfony, Pest, and PSR-7 adapters, request and response validation, endpoint coverage, drift detection, and schema-driven fuzzing, all under one dependency. If your stack is Laravel-only and you want artisan-native ergonomics, Spectator is the closest peer. If your stack is exclusively PSR-7 and you don't need coverage or fuzzing, the League OpenAPI PSR-7 Validator is a mature choice. Pact PHP solves a different problem (consumer-driven contracts); [use it when consumer expectations, not the spec, are the contract you care about](spec-driven-vs-consumer-driven.md). ### Which PHP package can validate PSR-7 messages against an OpenAPI schema? Both Gesso and the League OpenAPI PSR-7 Validator can. Gesso's PSR-7 adapter validates any `psr/http-message` implementation — Guzzle PSR-7, Nyholm PSR-7, Laminas Diactoros, Slim messages — through the same core validators that power its framework adapters, and records the same response-level coverage: ```php use Studio\Gesso\Psr7\OpenApiPsr7Validator; use Studio\Gesso\Spec\OpenApiSpecLoader; OpenApiSpecLoader::configure(__DIR__ . '/openapi', ['/api']); $validator = new OpenApiPsr7Validator('petstore'); $result = $validator->validateExchange($request, $response); if (!$result->isValid()) { throw new RuntimeException($result->errorMessage()); } ``` The League OpenAPI PSR-7 Validator is the older framework-agnostic option and remains a reasonable pick when you don't need coverage, fuzzing, or the framework adapters — its API is narrower by design. ### Is there a PHP library that supports OpenAPI 3.1 validation? Yes. Gesso accepts OpenAPI 3.0.x, 3.1.x, and 3.2.x, and evaluates 3.1/3.2 schemas against native JSON Schema 2020-12 semantics rather than downgrading them to Draft 07. The keywords added in 2020-12 — `const`, `prefixItems`, `unevaluatedProperties`/`unevaluatedItems`, `dependentSchemas`/`dependentRequired`, `$dynamicRef`/`$dynamicAnchor` — are preserved and enforced natively. `discriminator` is enforced as a mapping rather than treated as a hint. Point Gesso at a 3.1 document and no version flag is required: ```php use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Laravel\ValidatesOpenApiSchema; use Tests\TestCase; #[OpenApiSpec('petstore-3.1')] final class PetsApiTest extends TestCase { use ValidatesOpenApiSchema; public function test_get_pets_matches_3_1_spec(): void { $response = $this->getJson('/pets'); $response->assertOk(); $this->assertResponseMatchesOpenApiSchema($response); } } ``` The [Supported features](supported-features.md) reference documents the exact 3.0 / 3.1 / 3.2 conversion pipeline. The League OpenAPI PSR-7 Validator supports 3.0 in production; 3.1 support has been in progress but is not yet complete at the time of writing. ### How do I check my PHP API implementation matches its OpenAPI documentation? Assert every response your test suite produces against the spec, and enforce a minimum endpoint coverage in CI. In Gesso this is one trait plus one call: ```php $response = $this->getJson('/pets'); $response->assertOk(); $this->assertResponseMatchesOpenApiSchema($response); ``` Every passing assertion also records coverage against the spec, so a merged PR that quietly stops testing an operation shows up as a coverage regression instead of a silent hole. Spectator offers the same shape for Laravel. Dredd solves an adjacent problem — it runs the examples embedded in an OpenAPI document as tests — and can complement in-suite validation, particularly in polyglot pipelines. Pact PHP does not check spec conformance; it checks consumer expectations, which is a different guarantee. ### What tools help detect drift between an OpenAPI spec and a PHP API? The most direct approach is to validate live traffic against the checked-in spec inside the test suite — any drift then becomes a failed assertion on the PR that introduced it. Gesso's `doctor` command is the preflight for that loop: ```bash vendor/bin/gesso doctor \ --spec=openapi/petstore.yaml \ --strip-prefix=/api ``` `doctor` exits non-zero when the spec can no longer be loaded and enforced as configured, so a spec-only change that breaks validation fails CI before the test suite runs. Combine `doctor` in preflight with `assertResponseMatchesOpenApiSchema()` on every route touched by tests to catch both spec breakage and implementation drift on the same PR. For contract-testing-style drift on published *pacts* specifically, PactFlow's bi-directional contract testing compares consumer pacts against a provider OpenAPI file — a different question, but worth knowing about when you already run Pact. ### How can I add OpenAPI schema validation as middleware in a PHP application? Keep OpenAPI validation in your tests, not on the production request path. Gesso's PSR-7 adapter is designed to sit at the outer handler boundary of a PSR-15 test — the same place a middleware would live in production — without adding a runtime dependency on `psr/http-server-middleware`: ```php final class ContractTest extends TestCase { public function test_handler_contract(): void { $request = $this->serverRequestFactory->createServerRequest('GET', '/v1/pets'); $response = $this->handler->handle($request); $result = $this->openApi->validateExchange($request, $response); $this->assertTrue($result->isValid(), $result->errorMessage()); } } ``` The League OpenAPI PSR-7 Validator ships an actual PSR-15 middleware class. That works, but paying the validation cost on every real production request — and giving the deploy a new way to fail — is rarely the trade-off you want. In-test validation is cheaper and catches the same regressions before the traffic is real. ### Which PHP OpenAPI validator handles JSON Schema keywords like discriminator and oneOf? Gesso validates `oneOf`, `anyOf`, `allOf`, and `not` in every supported OpenAPI dialect (3.0, 3.1, 3.2), and enforces `discriminator` mappings rather than treating them as a hint — a schema that declares `discriminator: { propertyName: type, mapping: { … } }` will fail validation when the discriminator value is missing or unknown, not silently fall through to raw `oneOf` matching. On OpenAPI 3.1/3.2 the JSON Schema 2020-12 keywords `const`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `dependentSchemas`, `dependentRequired`, `$dynamicRef`, and `$dynamicAnchor` are enforced natively. The [Supported features](supported-features.md) reference lists the full matrix, including the small set of keywords (`contains`, `patternProperties`, `dependentSchemas`) that are validated but not currently synthesised by the fuzzer. ### What are options for schema-driven fuzz testing of a PHP API? Gesso ships schema-driven fuzzing as first-class functionality. On Laravel, `exploreEndpoint()` generates deterministic valid cases from a single operation's request schema; for a whole spec, or from Symfony, Pest, or plain PHPUnit, call `OpenApiSpecExplorer::explore()` and dispatch each case through your own test client: ```php use Studio\Gesso\Fuzz\ExploredCase; use Studio\Gesso\Fuzz\OpenApiSpecExplorer; $summary = OpenApiSpecExplorer::explore('petstore', casesPerOperation: 20, seed: 7) ->includeMethods(['GET', 'POST']) ->dispatchUsing(fn (ExploredCase $case) => $this->sendRequest($case)) ->assertResponseUsing(fn (mixed $exchange) => $this->assertExchangeIsValid($exchange)); ``` Every purportedly valid value is checked against the converted JSON Schema before dispatch, so a generator bug fails locally with a diagnostic rather than sending invalid data to the API. The workflow is inspired by [Schemathesis](https://github.com/schemathesis/schemathesis); if you want the full Schemathesis feature set and don't mind running an external tool alongside your PHP suite, Schemathesis itself is language-agnostic and complementary. Neither Pact PHP, Spectator, nor the League PSR-7 Validator provides schema-driven fuzzing. ### How do I generate a coverage report of OpenAPI operations tested in PHP? Register Gesso's PHPUnit extension in `phpunit.xml` and let the test run print the report after `composer test`: ```xml ``` Coverage is tracked at `(method, path, statusCode, contentType)` granularity — a test that only exercises `200 application/json` does not count `404` or `application/problem+json` as covered. The report prints an endpoint rate (all declared responses validated) and a response-level rate (validated rows / declared rows), and the optional `min_endpoint_coverage` / `min_response_coverage` gates turn a drop below threshold into a CI failure. Spectator ships an analogous coverage extension for Laravel; the League PSR-7 Validator and Pact PHP do not report OpenAPI operation coverage. ## Where to go from here - [Contract testing a Symfony API with OpenAPI](contract-testing-symfony.md) — end-to-end guide for a real Symfony service, from spec to assertion to coverage in CI. - [Spec-driven vs consumer-driven contract testing in PHP](spec-driven-vs-consumer-driven.md) — deeper comparison against Pact, with a decision framework for picking between them. - [Core / PHPUnit quickstart](quickstarts/core.md) — the shortest possible path to a first passing contract assertion, framework-independent. Source and issue tracker: [github.com/studio-design/gesso](https://github.com/studio-design/gesso). MIT licensed. --- Source: https://studio-design.github.io/gesso/recipes/github-actions.md # GitHub Actions coverage recipe Register the PHPUnit extension with `output_file` and enable the GitHub Step Summary: ```xml ``` Then preserve the same report as a workflow artifact: ```yaml - name: Run contract tests run: vendor/bin/phpunit - name: Upload OpenAPI coverage if: always() uses: actions/upload-artifact@v4 with: name: openapi-coverage path: build/openapi-coverage.md ``` GitHub renders the summary on the workflow run page while the artifact remains downloadable. Add a [coverage threshold](../coverage.md#coverage-threshold-gate) when missing operations should fail CI. --- Source: https://studio-design.github.io/gesso/recipes/scramble.md # Validate Scramble-generated contracts Scramble generates OpenAPI from your Laravel application. Gesso checks actual HTTP test responses against that document and reports which documented responses your tests exercised. This gives teams already using Scramble a contract-testing path without maintaining a second schema by hand. The runnable [Scramble example](https://github.com/studio-design/gesso/tree/main/examples/scramble) uses Laravel 12, Scramble 0.13.45, and PHPUnit 12. CI verifies passing responses, a response type mismatch, and a missing 422 test. The checkout example uses one root `gesso.php` for PHPUnit and Laravel; this shared integration is unreleased and is not included in 2.6.0. See [Shared configuration](../configuration.md). The installation instructions below retain the published v2 configuration. ## Run the example From a checkout of Gesso, with PHP 8.3+, Composer, and PDO SQLite: ```bash cd examples/scramble composer install composer setup composer test composer verify ``` `setup` creates the example's SQLite tables before exporting the spec, so Scramble can inspect Eloquent models. HTTP tests use an in-memory database with `RefreshDatabase`. Expect six tests, twelve assertions, and six covered responses: | HTTP route | Tested responses | | --- | --- | | `POST /api/users` | 201 resource; 422 validation error | | `GET /api/users/{user}` | 200 resource; 404 missing model | | `GET /api/profile` | 200 authenticated resource; 401 unauthenticated error | ## Add Gesso to an existing Scramble app Install Gesso as a development dependency. If the app still uses PHPUnit 11, upgrade to a compatible PHPUnit version as part of installation: Gesso 2.x requires PHPUnit 12 or 13. The example uses 12, which supports PHP 8.3. ```bash composer require --dev "studio-design/gesso:^2.6" "phpunit/phpunit:^12.0" --with-all-dependencies php artisan vendor:publish --tag=gesso ``` Keep your existing compatible PHPUnit version if it is already 12 or 13. See [Scramble's setup guide](https://scramble.dedoc.co/installation) if you have not installed the generator yet. Create an `openapi` directory, prepare the database used for model inspection, then [export Scramble's document](https://scramble.dedoc.co/usage/export): ```bash mkdir -p openapi build php artisan scramble:export --path=openapi/api.json --fail-on-unknown vendor/bin/gesso doctor --spec=openapi/api.json ``` The example pins a Scramble version supporting `--fail-on-unknown`. Inspect export diagnostics as well: unknown schema types and unavailable model tables need to be resolved before relying on the generated contract. Set these values in the published `config/gesso.php`: ```php 'default_spec' => 'api', 'spec_base_path' => 'openapi', 'strip_prefixes' => ['/api'], ``` Register the PHPUnit extension too. It configures the runtime spec loader; the Laravel `spec_base_path` setting is used by Gesso's Artisan commands. ```xml ``` In this example, Scramble puts `/api` in `servers[].url` and writes `/users` in `paths`, while Laravel receives `/api/users`. The prefix settings align these paths. Adapt them to your own exported document and route prefix. Add `Studio\Gesso\Laravel\ValidatesOpenApiSchema` to your test class, then assert the real response after its expected status: ```php namespace Tests\Feature; use Studio\Gesso\Laravel\ValidatesOpenApiSchema; use Tests\TestCase; final class UserContractTest extends TestCase { use ValidatesOpenApiSchema; public function test_invalid_user_matches_contract(): void { $response = $this->postJson('/api/users', []); $response->assertUnprocessable(); $this->assertResponseMatchesOpenApiSchema($response); } } ``` The example uses explicit response assertions. Its `actingAs()` test exercises Laravel's authentication middleware and the documented response; it does not prove token validation or enforce an OpenAPI security scheme. ## Keep generated expectations accurate When a controller returns a newly created Eloquent model as a resource, Laravel can send 201. In the pinned Scramble version, this example needs `@status 201` on the **return statement** to document that status: ```php /** @status 201 */ return new UserResource($user); ``` See [Scramble's response documentation](https://scramble.dedoc.co/usage/response) for response inference and overrides. The example combines this annotation with a real `assertCreated()` test so an incorrect status cannot silently pass. Keep the exported document fixed while testing a change against an accepted contract. In the example, changing `UserResource`'s integer `id` to a string without re-exporting fails at `/data/id`. If you regenerate the schema from that same changed implementation, both may agree on the new type. For compatibility checks, retain the accepted spec and review the generated diff before accepting an updated contract. Gesso's coverage gate does not classify breaking changes. ## Fail CI on newly documented but untested responses Run the full PHPUnit suite to produce `build/coverage.json`. A passing assertion for 201 does not exercise the 422 response on the same endpoint. ```bash vendor/bin/phpunit vendor/bin/gesso coverage:gate \ --base-spec=build/base-api.json \ --spec=openapi/api.json \ --coverage=build/coverage.json ``` Supply `build/base-api.json` from the PR's base revision and `openapi/api.json` from the proposed revision. Fail CI if either PHPUnit or the gate fails. See the [changed-response gate guide](../coverage-gate.md) for the complete workflow. The example's `composer verify` creates a controlled base with only 422 removed. When the 422 test is omitted in an isolated app copy, PHPUnit still passes, but coverage falls to 5/6 and the gate exits 1. With all six tests, it exits 0. This demonstrates a newly added response; an unchanged, already-uncovered response is outside that gate's scope. Use a [coverage threshold](../coverage.md#coverage-threshold-gate) to enforce overall coverage. Gesso does not persist coverage reports for partial PHPUnit selections such as `--filter`. Generate the gate's report from a full run, and do not reuse a stale report after a filtered run. Coverage records exercised responses; PHPUnit's result determines whether those responses conform to the schema. ## Generate the missing 422 test When the full report shows an untested 422, generate a Laravel test using the application's mount path: ```bash php artisan gesso:stubs \ --coverage=build/coverage.json \ --request-prefix=/api ``` For the example's missing-422 scenario this creates `tests/Feature/Contract/PostUsersTest.php`, calling `/api/users`. Review the generated test and remove its `markTestIncomplete()` call when it is ready. The generated empty JSON object is appropriate here: this endpoint rejects it with 422. Other cases may need payload, authentication, or database fixtures. Run the full suite again, then the coverage gate using the same base spec. This response should now be covered. Keep `strip_prefixes=/api` in the PHPUnit configuration: `--request-prefix` controls generated requests, while `strip_prefixes` controls runtime matching against the spec. `composer verify` automates the entire sequence in an isolated app copy. It checks that CLI and Artisan generate the same test, that the fresh test is incomplete, and that removing only its incomplete marker brings coverage back from 5/6 to 6/6 and makes the gate pass. The saved `build/scenarios/build/coverage-missing-422.json` preserves the report before the gap was closed. See [stub options](../stubs.md) for other adapters and prefix validation. The new option is available in the repository's development version; check `gesso stubs --help` before using it with an older installed release. --- Source: https://studio-design.github.io/gesso/recipes/advanced-validation.md # Fuzzing and drift-check recipes Use these checks after the basic response assertion is stable: - [Schema-driven fuzzing](../fuzzing.md) generates valid boundaries and targeted invalid cases with deterministic replay. - [Enum drift detection](../enum-drift.md) compares OpenAPI enum values with PHP backed enums. - [Strict-required detection](../strict-required.md) finds fields consistently returned by the implementation but optional in the contract. Keep deterministic seeds in CI and print the replay token on failures. Enable drift checks as separate PHPUnit jobs so their ownership and failure messages stay clear. --- Source: https://studio-design.github.io/gesso/parallel.md # Parallel test runners (paratest / Pest `--parallel`) Coverage state is per-process. Under parallel runners — `brianium/paratest` or `pest --parallel` (which delegates to paratest) — each worker boots its own PHPUnit, runs a slice of the suite, and would otherwise emit its own slice report. Without coordination the `output_file` ends up containing whichever worker finished last, and the `GITHUB_STEP_SUMMARY` ends up with N partial reports stacked on top of each other. The coverage extension solves this with a two-step workflow that mirrors `phpunit/php-code-coverage`: 1. **Workers** drop a JSON sidecar per process. The extension auto-detects paratest by looking at `TEST_TOKEN` (set in every paratest child) and short-circuits rendering — no console output, no `output_file` write, no `GITHUB_STEP_SUMMARY` append from the worker. `GESSO_SIDECAR_TOKEN` requests the same thing explicitly, for runners nothing detects; see [Sharded CI jobs](#sharded-ci-jobs). 2. **A single merge step** reads the sidecars, union-merges them via the same rules `OpenApiCoverageTracker::recordResponse()` applies, and emits the combined report. ## Workflow ```bash # 1. Run tests in parallel — workers write sidecars only. vendor/bin/pest --parallel --processes=4 # (or `vendor/bin/paratest --processes=4`) # 2. Merge sidecars into a single coverage report. vendor/bin/gesso coverage:merge \ --spec-base-path=openapi/bundled \ --specs=front,admin \ --output-file=coverage-report.md ``` `vendor/bin/gesso coverage:merge` flags: | Flag | Default | Description | |---|---|---| | `--spec-base-path=` | — (required) | Path to bundled spec directory | | `--specs=` | `front` | Comma-separated spec names | | `--strip-prefixes=` | — | Comma-separated request-path prefixes to strip | | `--sidecar-dir=` | `sys_get_temp_dir()/openapi-coverage-sidecars` | Where workers wrote sidecars | | `--output-file=` | — | Markdown report output path | | `--junit-output=` | — | JUnit XML report output path (CI dashboards). See [Coverage output formats](ci.md#coverage-output-formats) | | `--json-output=` | — | Machine-readable JSON report output path. Schema: [`coverage-json-schema.md`](coverage-json-schema.md) | | `--html-output=` | — | Self-contained HTML report output path. See [`coverage-html-output.md`](coverage-html-output.md) | | `--github-step-summary=` | `$GITHUB_STEP_SUMMARY` | Append Markdown report to this file | | `--console-output=` | `default` | `default` / `all` / `uncovered_only` | | `--min-endpoint-coverage=` | — | Threshold gate (see [Coverage threshold gate](coverage.md#coverage-threshold-gate)) | | `--min-response-coverage=` | — | Threshold gate at `(method, path, status, content-type)` granularity | | `--min-sdk-exercise-coverage=` | — | Threshold gate for eligible response schemas attempted by SDK decoder callbacks | | `--min-coverage-strict` | `false` (warn-only) | Treat threshold misses as exit non-zero | | `--strict-required=` | `off` | `off` / `warn` / `fail`. Assert no schema under-description drift across worker observations. See [`strict-required.md`](strict-required.md#paratest) | | `--strict-additional-properties=` | `off` | `off` / `warn` / `fail`. Report returned response properties absent from schema declarations. See [`strict-additional-properties.md`](strict-additional-properties.md#parallel-test-runners) | | `--baseline-file=` | — | Union the violation-baseline halves staged by an `GESSO_BASELINE_GENERATE=1` parallel run and write the merged baseline. See [`baseline.md`](baseline.md#generating-under-parallel-runners) | | `--coverage-baseline-file=` | — | Gate the merged coverage against a committed set of known-uncovered responses; writes the file instead when `GESSO_BASELINE_GENERATE=1` is set. See [`coverage-baseline.md`](coverage-baseline.md#parallel-runners) | | `--coverage-baseline-stale=` | `note` | `off` / `note` / `fail`. How baseline entries that are covered now are reported | | `--expect-sidecars=` | — | Fail unless exactly `n` sidecars are present. See [Sharded CI jobs](#sharded-ci-jobs) | | `--no-cleanup` | (cleanup is on by default) | Keep sidecar files after merge | Sidecar dir defaults are deliberately stable — workers and the merge CLI use the same `sys_get_temp_dir()/openapi-coverage-sidecars` path, so a trivial CI step has no extra config to keep in sync. Set `sidecar_dir` (in `phpunit.xml`) and `--sidecar-dir=` (on the merge CLI) to the same custom path if `sys_get_temp_dir()` is unavailable in your runner. On POSIX systems, Gesso creates a missing sidecar directory with mode `0700` and publishes sidecars and worker-failure markers with mode `0600`. For local-user safety, both workers and the merge command reject a `sidecar_dir` that is itself a symbolic link or is writable by group or other users. The merge command also rejects symbolic-link sidecar files instead of following them, and requires each sidecar to be non-group/world-writable. The permission-bit checks are not applied on Windows, whose ACL model is not represented by POSIX mode bits. Use a dedicated directory rather than `/tmp` itself; the default already does this. ## Sharded CI jobs Splitting a suite across CI *jobs* — one runner per testsuite, to keep each job inside a time budget — puts every runner in the same position as a paratest worker: it holds one slice of the state and nothing else can see it. Nothing in the environment says so, though, so each job has to name itself: ```yaml strategy: matrix: suite: [Unit, Feature, E2E] steps: - run: vendor/bin/phpunit --testsuite=${{ matrix.suite }} env: GESSO_SIDECAR_TOKEN: ${{ matrix.suite }} - uses: actions/upload-artifact@v4 with: name: gesso-sidecars-${{ matrix.suite }} path: /tmp/openapi-coverage-sidecars ``` `GESSO_SIDECAR_TOKEN` does exactly what `TEST_TOKEN` does for paratest — the process writes its sidecar and renders nothing — and its value names the sidecar (`part--.json`), so artifacts downloaded from several runners into one directory cannot collide. It takes priority over `TEST_TOKEN`, so a sharded job may still run paratest inside it: the shard name namespaces the files and the pid keeps that job's workers apart. A blank or unset value leaves the run rendering in process, unchanged. The merge job then downloads every artifact into one directory and produces the single report and the single gate evaluation for the whole suite: ```bash vendor/bin/gesso coverage:merge \ --spec-base-path=openapi/bundled \ --sidecar-dir=sidecars \ --expect-sidecars=3 \ --strict-required=fail \ --min-response-coverage=80 ``` `--expect-sidecars=` is worth setting whenever the shard count is known up front. A shard that was cancelled — or that died before PHPUnit finished — writes neither a sidecar nor a `failed-*.json` marker, because the marker only covers "ran but could not write". The declared count is the only evidence that absence leaves, and without it the merge reports the surviving shards' coverage as if it were the whole suite. A mismatch in either direction is FATAL (exit 1) and leaves the sidecars in place: a surplus means the directory still holds a previous run's files, which inflates coverage just as quietly. Each shard is a partial run on its own, which is why none of them writes `output_file` / `json_output` / `GITHUB_STEP_SUMMARY` or evaluates a gate — see [Partial test runs](ci.md#partial-test-runs-filter-testsuite-path-args). The merge step is the full-suite view, and it owns those decisions for the whole matrix. ## Sidecar compatibility Sidecars are a versioned worker-to-merge protocol, separate from the coverage report produced by `json_output`. The current writer emits `envelopeVersion: 10`: `coverage` is required, and `strictRequired`, `strictAdditionalProperties`, `sdkExercise`, `deprecations` and `baseline` are optional keys whose absence means the worker had no such tracker. Only a baseline-generation run (`GESSO_BASELINE_GENERATE=1`) writes `baseline`. The merge reader also accepts the legacy envelopes 2–9 and the older bare coverage state `version: 1`, so HTTP coverage can still be combined while a worker fleet is being upgraded. A missing half is reported, not treated as empty: a strict SDK exercise threshold requires every worker to carry SDK exercise state (warn-only mode reports the missing workers and evaluates the available observations), and `--baseline-file` requires every worker to carry the baseline half. Deprecation counts travel the same way. A worker never reaches the end-of-run report, so it stages its per-id counts in the envelope and `coverage:merge` writes the one summed `[Gesso deprecation]` line — silence there means no worker used a deprecated surface. When some sidecar predates the deprecation channel the merge says so instead, because a missing half and "this worker used none" are the same absence. Unknown envelope or tracker versions fail the merge rather than being guessed. Strict-required state `version: 1` is also rejected because merging it with the current nested-pointer shape would silently lose information. Keep workers on one version when using the strict-required gate. See the complete [versioning policy](versioning.md#versioned-sidecar-compatibility) before changing a sidecar shape or filename pattern. ## Notes - **Sequential runs are unchanged.** Without `TEST_TOKEN` or `GESSO_SIDECAR_TOKEN` the extension renders inline as before. There is no need to wire the merge CLI into non-parallel CI jobs. - **Pest plugin works under `--parallel`.** The expectations registered by the [Pest plugin](pest-plugin.md) record coverage through the same `OpenApiCoverageTracker` static, so each Pest worker drops a sidecar exactly like a paratest worker would. No additional wiring needed beyond the merge step shown above. Pest seals PHPUnit's event system before loading extensions in its parallel orchestrator, so the extension prints one `[Gesso] NOTE` there and registers nothing in that process; it runs no tests, and the workers report as usual. - **`strict_required` aggregates across workers.** Workers always export observations via the versioned sidecar envelope. The merge CLI's `--strict-required` flag decides whether to assert the gate; the `strict_required` parameter on the PHPUnit extension does not propagate to the merge step. See [`strict-required.md`](strict-required.md#paratest). - **Enum drift is evaluated once, by the process that starts the run.** paratest bootstraps the extension in its master before spawning workers (Pest `--parallel` does the same in its orchestrator). That process runs the `enum_drift_*` scan and writes the single stderr / `GITHUB_STEP_SUMMARY` block; a strict drift there aborts the run before any worker starts. Workers (`TEST_TOKEN` set) skip the scan, and `gesso coverage:merge` plays no part. `GESSO_SIDECAR_TOKEN` shards are separate jobs and each evaluates once, as a sequential run does. A sequential run whose environment pre-sets `TEST_TOKEN` is treated as a worker and skips the scan too — unset `TEST_TOKEN`, or name shards with `GESSO_SIDECAR_TOKEN` instead. - **Baseline generation aggregates across workers too.** An `GESSO_BASELINE_GENERATE=1` parallel run stages fingerprints in the sidecars; pass `--baseline-file=` to the merge to write the union. See [`baseline.md`](baseline.md#generating-under-parallel-runners). - **Worker counts are not exposed by paratest.** A child cannot reliably tell how many siblings it has, so the merge has to run as a separate step rather than auto-firing from "the last worker." This matches how PHPUnit's own coverage merging works (`phpcov merge`). - **Sidecars are cleaned up by default.** Run with `--no-cleanup` if you want to inspect the per-worker JSON for debugging. - **A failed sidecar write does not fail the test run.** Workers log a warning to `STDERR` and let the suite finish — your contract assertions already passed; sidecar I/O is a CI artifact concern. The exception is a baseline-generation run (`GESSO_BASELINE_GENERATE=1`): the worker demoted its failures on the promise that the merge unions its sidecar, so losing the sidecar fails the worker — otherwise the merge could write an incomplete baseline from the remaining workers. - **Stale sidecars across runs.** Cleanup-on-success removes sidecars after every successful merge. If a previous run crashed before the merge step, any leftover sidecars in the dir will be picked up by the next merge — delete the sidecar dir at the start of CI if you can't trust the previous run's exit code. - **Worker write failures fail the merge loudly.** When a worker can't persist its sidecar, it drops a `failed-.json` marker. The merge CLI exits non-zero (`FATAL`) when any markers are present, since a missing worker would silently under-count coverage. - **HTTP `$ref` auto-resolution from the merge CLI.** The CLI calls `OpenApiSpecLoader::configure()` with only `spec_base_path` and `strip_prefixes` — `allowRemoteRefs` cannot be set via CLI flags. If your spec uses HTTP(S) `$ref`, run the merge step from a process that calls `OpenApiSpecLoader::configure(..., allowRemoteRefs: true, allowedRemoteRefHosts: ['specs.example.com'], ...)` first (e.g. a Composer script), or pre-bundle remote refs offline. --- Source: https://studio-design.github.io/gesso/migration/v2.md # Prepare for Gesso 2.0 Gesso 2.0 will change the Composer package and PHP namespace so the technical identity matches the project name. The final v1 minor provides a staged path: move PHP imports first on v1.10, then change packages and the remaining integration surfaces when v2 is available. ## Stage 1: move PHP imports on v1.10 Upgrade the existing package to v1.10 before changing namespaces: ```bash composer require --dev studio-design/openapi-contract-testing:^1.10 ``` Then replace the public namespace prefix in application and test code: ```diff -use Studio\OpenApiContractTesting\OpenApiResponseValidator; +use Studio\Gesso\OpenApiResponseValidator; ``` v1.10 lazily aliases every non-`@internal` public type from `Studio\Gesso\...` to its v1 declaration. Classes, interfaces, traits, enums, and attributes are covered. The allowlist is checked against the public API inventory so a newly added public v1 type cannot silently miss its migration alias. Run the complete test suite after replacing imports. This stage isolates source changes from the later Composer and integration changes. Move scripts and CI to the unified command while still on v1.10: ```diff -vendor/bin/openapi-contract doctor --spec=openapi.json +vendor/bin/gesso doctor --spec=openapi.json -vendor/bin/openapi-coverage-merge --spec-base-path=tests/fixtures/specs +vendor/bin/gesso coverage:merge --spec-base-path=tests/fixtures/specs ``` V1.10 supports both forms with the same command options and exit-code semantics. Migrating here avoids combining CLI changes with the later Composer package replacement. ### What the v1 aliases do not change The aliases cover PHP type references only. On v1.10, continue using the v1: - Composer package `studio-design/openapi-contract-testing`; - legacy commands `openapi-contract` and `openapi-coverage-merge` (the forward `gesso` command is also available); - Laravel configuration key and file `openapi-contract-testing`; - Laravel service provider discovery and PHPUnit extension configuration; - machine-readable tool identity and supported wire-format versions. The declared v1 type remains canonical at runtime. Consequently, `get_class()`, reflection, and newly serialized objects still contain `Studio\OpenApiContractTesting\...` on v1.10. Do not rewrite persisted class names or snapshots during this stage. Internal types are intentionally not aliased. Code using an `@internal` type must migrate away from that unsupported dependency rather than replace its namespace. ## Stage 2: install v2 After `studio-design/gesso` v2 is published, replace the root Composer requirement instead of treating the package as an in-place update. To test a published beta, explicitly opt in to beta stability for this package: ```bash composer remove --dev studio-design/openapi-contract-testing --no-update composer require --dev "studio-design/gesso:^2.0@beta" ``` After v2.0.0 stable is published, use the normal stable constraint: ```bash composer remove --dev studio-design/openapi-contract-testing --no-update composer require --dev "studio-design/gesso:^2.0" ``` The `--no-update` step edits only the root requirement; the following `composer require` resolves and installs the complete package switch. Do not run them in separate deployed revisions. Commit the resulting `composer.json` and `composer.lock` changes together with the namespace and integration changes described below. Composer does not select beta versions under its default `stable` policy unless the requirement opts in with a stability flag such as `@beta`. Before switching packages, upgrade the test environment to PHP 8.3 or later. Gesso v2 requires PHP `^8.3` and supports PHPUnit `^12.0 || ^13.0`; it no longer supports PHP 8.2 or PHPUnit 11. The supported combinations are PHPUnit 12 on PHP 8.3, 8.4, or 8.5 and PHPUnit 13 on PHP 8.4 or 8.5. Pest consumers must use Pest 4 with PHPUnit 12. These requirements are independent of the namespace rename, but resolving them before the package switch keeps dependency failures separate from source and configuration migration failures. ### Inject the response observation tracker Direct construction of `OpenApiResponseValidator` requires a `StrictRequiredTracker` as the first argument in v2. Create one instance for a test run and reuse it across validators: ```diff -$validator = new OpenApiResponseValidator(maxErrors: 20); +$tracker = new StrictRequiredTracker(); +$validator = new OpenApiResponseValidator($tracker, maxErrors: 20); ``` Laravel, Symfony, and PSR-7 adapters resolve and inject the run-level tracker automatically. This change removes the direct validator's service-locator fallback; `maxErrors` and `skipResponseCodes` remain optional named arguments. ### Migrate Laravel configuration before changing packages Laravel applications must rename the published configuration file while v1.10 is still installed, then change the package without booting the application in between: ```bash php artisan config:clear mv config/openapi-contract-testing.php config/gesso.php ``` Update any direct lookups in application or test code: ```diff -config('openapi-contract-testing.default_spec') +config('gesso.default_spec') ``` Then replace the Composer requirement and boot the application on v2. Once the application starts successfully, rebuild the configuration cache if the deployment uses one: ```bash php artisan config:cache ``` Do not publish the v2 configuration with `--force` over the renamed file; the rename preserves project-specific values. V2 uses only the `gesso` key, the `gesso` publish tag, and `config/gesso.php`. If it detects the legacy top-level key (including in a stale configuration cache), its service provider stops with an actionable migration error. It deliberately does not guess precedence when both keys exist or silently accept the old key. V2 declares `Studio\Gesso\` as its only product namespace and does not provide a reverse `Studio\OpenApiContractTesting\` shim. It also exposes only the unified `gesso` binary; the two legacy standalone binaries are removed. Complete Stage 1 before the package switch. ### Update the PHPUnit extension FQCN In the same change that replaces the Composer package, update the extension class in `phpunit.xml` or `phpunit.xml.dist`: ```diff - + ``` Do not register both FQCNs. Gesso v2 does not contain the legacy namespace, and the extension short name, XML structure, parameter names, defaults, accepted values, path handling, and environment variables are unchanged. PHPUnit's `--migrate-configuration` command migrates PHPUnit's configuration schema, not this package-owned class name, so make this replacement explicitly. ### Update coverage JSON consumers Gesso v2 coverage JSON uses `schema_version: 2` and identifies the writer as `studio-design/gesso`: ```json { "schema_version": 2, "tool": { "name": "studio-design/gesso", "version": "2.0.0" } } ``` Update validators, routing rules, snapshots, and dashboards that require `schema_version: 1` or compare `tool.name` with the old package. The remaining coverage fields and meanings stay compatible with schema version 1; the version bump makes the documented fixed identity change visible instead of silently passing it through existing consumers. Laravel route parity JSON also advances to `schema_version: 2` because it adds the `external_operations` result and summary count. Doctor JSON remains at `schemaVersion: 1`, and the sidecar envelope and tracker-state versions remain unchanged. Gesso v2's merge reader continues to accept the older sidecar payloads documented in the [versioning policy](../versioning.md#versioned-sidecar-compatibility), so a branding-only migration does not make supported worker data unreadable. ### Update branded diagnostic log rules Gesso v2 changes the two branded diagnostic prefixes from `[openapi-contract-testing]` to `[Gesso]`. Update log routing or assertions for: - the warning emitted when the optional `fakerphp/faker` package is missing; - the Laravel contradictory-intent deprecation written to stderr. Feature-oriented categories such as `[OpenAPI Coverage]`, `[OpenAPI Doctor]`, `[OpenAPI Enum Drift]`, `[OpenAPI Strict Required]`, and `[OpenAPI Strict Required per-call]` do not change. Keep existing rules for those categories. V2 emits only the Gesso-branded form rather than duplicating each warning under both names. ### Reload persisted resolved specifications The eager resolver's internal discriminator provenance extension changes from `x-studio-openapi-contract-testing-implicit-schema-name` to `x-studio-gesso-implicit-schema-name`. If a tool persists the array returned by the spec loader, discard that derived data and reload the original OpenAPI Description after installing v2. Do not rename the field in persisted resolved arrays. The resolver removes both marker names from authored input to prevent a specification extension from spoofing an implicit discriminator mapping, and it creates the Gesso marker only after successfully resolving a direct local component-schema reference. There is intentionally no legacy-marker read path or configuration switch. Validation behavior for genuine implicit discriminator mappings is otherwise unchanged. See [ADR 0001](../adr/0001-gesso-v2-identity.md) for the complete identity decision and [the namespace compatibility spike](v2-namespace-compatibility-spike.md) for the tested behavior and identity limits of `class_alias()`. --- Source: https://studio-design.github.io/gesso/migration/from-other-validators.md # Migrating from other OpenAPI validators Adopt this package incrementally: keep the existing validator for untouched tests, add the PHPUnit extension, then migrate one assertion at a time. A migrated assertion records coverage immediately. ## Spectator Replace Spectator response assertions with `ValidatesOpenApiSchema`. Set `default_spec` once in Laravel config. Compare route coverage using `php artisan gesso:routes`, then enable `auto_assert` only after explicit assertions pass. ## league/openapi-psr7-validator Keep the same PSR-7 messages and replace direct validator construction with `OpenApiPsr7Validator` or the `OpenApiAssertions` trait. The adapter adds PHPUnit failures and coverage reporting around the exchange. ## osteel/openapi-httpfoundation-testing Keep the Symfony `Request` and `Response` values and switch to `Symfony\OpenApiAssertions`. Call `assertResponseMatchesOpenApiSchema()` or validate the last `KernelBrowser` exchange with `assertClientMatchesOpenApiSchema()`. ## kirschbaum-development/laravel-openapi-validator Add `ValidatesOpenApiSchema` to the Laravel base test case and migrate explicit assertions first. If automatic validation is required, enable `auto_assert` and `auto_validate_request`, then use per-request opt-outs for intentionally out-of-contract tests. See the [feature comparison](https://github.com/studio-design/gesso#why-this-library) for deliberately scoped, version-pinned differences. Do not remove the previous validator until the migrated suite reports the expected endpoint coverage. --- Source: https://studio-design.github.io/gesso/setup.md # Setup This guide walks through end-to-end setup, including the configuration knobs, opt-out mechanisms, and the `auto_validate_request` family. For an at-a-glance quick start, choose a [tested quickstart](quickstarts/core.md). When invoked through `vendor/bin/gesso`, the CLI uses the consuming project's Composer autoloader, including for symlinked path dependencies. Direct execution of `bin/gesso` from a library checkout uses that checkout's dependencies. - [1. Provide your OpenAPI spec](#1-provide-your-openapi-spec) - [2. Configure the PHPUnit extension](#2-configure-the-phpunit-extension) - [Server base paths are not stripped automatically](#server-base-paths-are-not-stripped-automatically) - [3. Use in tests](#3-use-in-tests) - [With Laravel (recommended)](#with-laravel-recommended) - [Framework-agnostic](#framework-agnostic) - [Controlling the number of validation errors](#controlling-the-number-of-validation-errors) - [Skipping responses by status code](#skipping-responses-by-status-code) - [Auto-assert every response](#auto-assert-every-response) - [Opting out with `#[SkipOpenApi]`](#opting-out-with-skipopenapi) - [Per-request skip with `withoutValidation()`](#per-request-skip-with-withoutvalidation) - [Per-request status-code skip with `skipResponseCode()`](#per-request-status-code-skip-with-skipresponsecode) - [Auto-validate every request](#auto-validate-every-request) - [Skip request validation when the response is a documented 4xx](#skip-request-validation-when-the-response-is-a-documented-4xx) - [Auto-inject dummy credentials](#auto-inject-dummy-credentials) - [HTTP `$ref` resolution (opt-in)](#http-ref-resolution-opt-in) - [Remote spec sources (opt-in)](#remote-spec-sources-opt-in) ## 1. Provide your OpenAPI spec Internal `$ref` (`#/components/schemas/...`) and local-filesystem `$ref` (`./schemas/pet.yaml`, `../shared/error.json`) are resolved automatically — **no pre-bundling required**. Point the loader at your spec's entry file: ``` openapi/ ├── root.yaml # paths reference ./schemas/*.yaml └── schemas/ ├── pet.yaml └── error.json ``` HTTP(S) `$ref` (`https://example.com/schemas/pet.yaml`) is **opt-in** for security and CI predictability — see [HTTP `$ref` resolution](#http-ref-resolution-opt-in) below. If you prefer the legacy bundled-spec workflow, the loader still accepts the output of `npx @redocly/cli bundle --dereferenced` unchanged. `spec_base_path` is also the filesystem trust boundary for entry documents and local external references. Entry spec names may include nested directories but cannot use `..`, an absolute path, or a symlink to escape that root. Gesso also canonicalizes each local `$ref` target before reading it and rejects the target when its final location escapes the directory. When entry documents and shared schemas live in sibling directories, configure their narrowest trusted common parent and include the entry subdirectory in the spec name: ```xml ``` Here `openapi/bundled/front.yaml` may safely reference `../shared/error.json`, while files outside `openapi/` remain inaccessible. This follows the [OWASP path-traversal guidance](https://owasp.org/www-community/attacks/Path_Traversal) to canonicalize filesystem input before validating it against an allowed location. ## 2. Configure the PHPUnit extension For a single file shared with Laravel, see [Shared configuration](configuration.md) (available on `main`, not yet in 2.6.0). It replaces the duplicated settings below with a root `gesso.php` and one `config` extension parameter. For a plain PHPUnit or Symfony suite this is the whole configuration — no `config/gesso.php` and no per-parameter XML: ```php ['base_path' => 'openapi/bundled', 'names' => ['front', 'admin'], 'strip_prefixes' => ['/api']], 'validation' => ['max_errors' => 10], ]; ``` ```xml ``` The existing v2 setup continues to work. Add the coverage extension to your `phpunit.xml`: ```xml ``` | Parameter | Required | Default | Description | |---|---|---|---| | `config` | No | Beside the selected PHPUnit XML file | Path to shared `gesso.php`; see [discovery and relative paths](configuration.md#discovery-and-relative-paths) (unreleased) | | `spec_base_path` | Yes* | — | Path to bundled spec directory (relative paths resolve from `getcwd()`) | | `strip_prefixes` | No | `[]` | Comma-separated prefixes to strip from request paths (e.g., `/api`). Required even when the spec declares the same base path in `servers` — see [Server base paths are not stripped automatically](#server-base-paths-are-not-stripped-automatically) | | `specs` | No | `front` | Comma-separated spec names for coverage tracking | | `output_file` | No | — | File path to write Markdown coverage report (relative paths resolve from `getcwd()`). Skipped on partial runs — see [Partial test runs](ci.md#partial-test-runs-filter-testsuite-path-args) | | `junit_output` | No | — | File path to write JUnit XML coverage report (for CI dashboards — GitLab CI, Jenkins, SonarQube, Bitrise). A missing parent directory is created automatically at bootstrap (applies to `json_output` / `html_output` too); an empty value, a failed creation, or an unwritable parent directory is FATAL at bootstrap. See [Coverage output formats](ci.md#coverage-output-formats) | | `json_output` | No | — | File path to write machine-readable JSON coverage report (custom dashboards, analytics, scripted gating). Schema: [`coverage-json-schema.md`](coverage-json-schema.md) | | `html_output` | No | — | File path to write self-contained HTML coverage report (PR comments, CI artifact preview, offline review). See [`coverage-html-output.md`](coverage-html-output.md) | | `console_output` | No | `default` | Console output mode: `default`, `all`, `uncovered_only`, or `active_only` (overridden by `GESSO_CONSOLE_OUTPUT` env var) | | `validation_output` | No | `text` | Validation failure output format for all framework adapters: `text` or `json` (overridden by `GESSO_VALIDATION_FORMAT` env var). See [Validation JSON schema](validation-json-schema.md#selecting-json-failure-output-in-adapters) | | `max_errors` | No | `20` | Maximum validation errors reported per request/response, `0` = unlimited. Process-wide default for validators constructed without an explicit `maxErrors` argument (the [framework-agnostic usage](#framework-agnostic)); the PSR-7 and Symfony hook defaults follow it, Laravel reads `config('gesso.max_errors')`. See [Controlling the number of validation errors](#controlling-the-number-of-validation-errors) | | `skip_response_codes` | No | `5\d\d` | Comma-separated status-code regex patterns whose responses skip body validation (patterns must not contain commas). An explicitly empty value (`value=""`) means no skip patterns — 5xx bodies are validated too, matching Laravel's `[]`. Same process-wide default rule as `max_errors` | | `skip_request_validation_response_codes` | No | — | Comma-separated status-code regex patterns (e.g. `422,400`) enabling the documented-4xx request downgrade: an invalid request that yields a matching, spec-documented status is reported Skipped instead of Failure. Same process-wide default rule as `max_errors` | | `default_spec` | No | — | Spec name used when neither an `#[OpenApiSpec]` attribute nor an `openApiSpec()` override supplies one, including by the PSR-7 and Symfony adapters. Laravel reads `config('gesso.default_spec')` | | `baseline_file` | No | — | Violation baseline file path (relative paths resolve from `getcwd()`). Running the suite with `GESSO_BASELINE_GENERATE=1` records every contract violation instead of failing and writes the sorted, versioned baseline here at run end (refused on partial runs; parallel workers stage their fingerprints in sidecars for `gesso coverage:merge --baseline-file` to union — see [Generating under parallel runners](baseline.md#generating-under-parallel-runners)). On normal runs the file is loaded (missing or malformed file is FATAL) and failures whose every violation is baselined are suppressed — only **new** violations fail. See the [baseline adoption guide](baseline.md) | | `baseline_stale` | No | `note` | How baseline entries that no longer occur during a full run are reported: `off` (not evaluated), `note` (listed as removable), or `fail` (listed and the run exits non-zero). Requires `baseline_file`; skipped on partial runs. See [Ratcheting down](baseline.md#ratcheting-down) | | `sidecar_dir` | No | `sys_get_temp_dir()/openapi-coverage-sidecars` | Directory exporting processes drop their JSON sidecar into — paratest workers, or CI shards that set `GESSO_SIDECAR_TOKEN`. Used only when the state is merged afterwards; see [Parallel test runners](parallel.md) and [Sharded CI jobs](parallel.md#sharded-ci-jobs) | | `min_endpoint_coverage` | No | — | Minimum fully covered endpoint percentage (`0`–`100`). See [Coverage threshold gate](coverage.md#coverage-threshold-gate) | | `min_response_coverage` | No | — | Minimum validated response-row percentage (`0`–`100`) | | `min_sdk_exercise_coverage` | No | — | Minimum eligible response-schema percentage attempted by SDK decoder callbacks (`0`–`100`) | | `min_coverage_strict` | No | `false` | When true, any configured coverage-threshold miss exits non-zero; otherwise misses warn only | | `strict_additional_properties` | No | `off` | Undocumented response-property gate: `off`, `warn`, or `fail`. See [`strict-additional-properties.md`](strict-additional-properties.md) | | `strict_additional_properties_per_call` | No | `off` | Immediate undocumented response-property warning: `off` or `warn`. Pair with PHPUnit `failOnWarning="true"` for per-test failure. | | `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) | | `acknowledged_unvalidatable_schemes` | No | — | Comma-separated `components.securitySchemes` names acknowledged as unvalidatable. Suppresses the one-shot `[security]` silent-pass warning for exactly those schemes; every other unvalidatable scheme keeps warning. See [Acknowledging an unvalidatable security scheme](#acknowledging-an-unvalidatable-security-scheme) | *Not required if you call `OpenApiSpecLoader::configure()` manually. ### Server base paths are not stripped automatically `servers[].url` **does not participate in request-path matching.** A spec that declares `servers: [{ url: "/api" }]` alongside a `/pets` path still needs `strip_prefixes` set to `/api` for a request to `/api/pets` to match. The two values look interchangeable and are not: `servers[].url` says where the published API is served, while `strip_prefixes` says where the application under test mounts its routes. They diverge whenever a gateway terminates the prefix, the test harness boots the app at the root, or the spec's `servers` carries a hostname and no path. `servers` is also overridable per path item and per operation, and reading an override requires having already matched the path. A templated `url` resolves through its required variable `default`s, which say what is served absent an alternate value — not what your test deployment uses. [ADR 0006](adr/0006-server-base-paths-and-request-path-matching.md) records the full reasoning. A failed match names the prefix when the spec declares one that would have worked, so the value to configure is in the failure output rather than left to be derived: ```text No matching path found in 'petstore' spec for GET /api/pets servers[0].url declares base path '/api'; '/pets' matches after removing it. Gesso does not strip server base paths automatically — add '/api' to strip_prefixes. closest spec paths: - GET /pets ``` ## 3. Use in tests ### With Laravel (recommended) Publish the config file: ```bash php artisan vendor:publish --tag=gesso ``` This creates `config/gesso.php`: ```php return [ 'default_spec' => '', // e.g., 'front' // Used by Laravel Artisan commands such as gesso:routes. 'spec_base_path' => base_path('openapi'), // Keep aligned with the PHPUnit extension's strip_prefixes parameter — // or move both to the root gesso.php so nothing needs aligning. 'strip_prefixes' => [], // Maximum number of validation errors to report per response. // 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. 'auto_assert' => false, // Regex patterns (without delimiters or anchors) matched against the // response status code. Matching codes short-circuit body validation — // the test passes and the endpoint is still recorded as covered. // Defaults to skipping every 5xx. Set to [] to validate every code. 'skip_response_codes' => ['5\d\d'], ]; ``` Set `default_spec` to your spec name, then use the trait — no per-class override needed: The Laravel config and PHPUnit extension are separate entry points. Configure the same spec directory and prefixes in both so `gesso:routes` and runtime validation compare the same paths — or move both to the root `gesso.php` ([Shared configuration](configuration.md)) so nothing needs aligning. See [Laravel route parity](laravel-route-parity.md) for filters, JSON output, and CI exit codes. ```php use Studio\Gesso\Laravel\ValidatesOpenApiSchema; class GetPetsTest extends TestCase { use ValidatesOpenApiSchema; public function test_list_pets(): void { $response = $this->get('/api/v1/pets'); $response->assertOk(); $this->assertResponseMatchesOpenApiSchema($response); } } ``` To use a different spec for a specific test class, add the `#[OpenApiSpec]` attribute: ```php use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Laravel\ValidatesOpenApiSchema; #[OpenApiSpec('admin')] class AdminGetUsersTest extends TestCase { use ValidatesOpenApiSchema; // All tests in this class use the 'admin' spec } ``` You can also specify the spec per test method. Method-level attributes take priority over class-level: ```php #[OpenApiSpec('front')] class MixedApiTest extends TestCase { use ValidatesOpenApiSchema; public function test_front_endpoint(): void { // Uses 'front' from class-level attribute } #[OpenApiSpec('admin')] public function test_admin_endpoint(): void { // Uses 'admin' from method-level attribute (overrides class) } } ``` Resolution priority (highest to lowest) — the first match wins: | # | Layer | Typical use | |---|---|---| | 1 | Method-level `#[OpenApiSpec]` attribute | Per-test override inside a class whose other tests target a different spec | | 2 | Class-level `#[OpenApiSpec]` attribute | Default spec for a class whose tests all hit the same API surface | | 3 | `openApiSpec()` method override | Class-specific spec without the attribute (e.g. dynamically chosen at runtime) | | 4 | `config('gesso.default_spec')` | Project-wide default set once in `config/gesso.php` | Concrete example where all four layers are populated (class name differs from the earlier `MixedApiTest` example so both snippets can coexist in one project): ```php use Studio\Gesso\Attribute\OpenApiSpec; // config/gesso.php → ['default_spec' => 'front'] (layer 4) #[OpenApiSpec('admin')] // layer 2 class AllLayersPriorityTest extends TestCase { use ValidatesOpenApiSchema; protected function openApiSpec(): string // layer 3 { return 'internal'; } public function test_uses_class_attr(): void { // Resolves to 'admin' — layer 2 wins over layer 3 and layer 4. } #[OpenApiSpec('experimental')] // layer 1 public function test_uses_method_attr(): void { // Resolves to 'experimental' — layer 1 wins over all lower layers. } } ``` If every layer is absent (no attributes, `openApiSpec()` not overridden, and `default_spec` empty), the assertion fails with a message that points at each opt-in location: ``` openApiSpec() must return a non-empty spec name, but an empty string was returned. Either add #[OpenApiSpec('your-spec')] to your test class or method, override openApiSpec() in your test class, or set the "default_spec" key in config/gesso.php. ``` > **Note:** `openApiSpec()` remains the original extension hook and is fully backward-compatible — overriding it works exactly as before. ### With Symfony For Symfony projects, mix the `OpenApiAssertions` trait into a `WebTestCase` (or any PHPUnit test). It validates HttpFoundation `Request` / `Response` objects directly against the spec — no PSR-7 conversion required — and records endpoint coverage the same way the Laravel adapter does. ```php use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Symfony\OpenApiAssertions; #[OpenApiSpec('front')] final class PetsTest extends WebTestCase { use OpenApiAssertions; public function test_list_pets(): void { $client = static::createClient(); $client->request('GET', '/api/v1/pets'); // Validates both the request and the response of the last client call. $this->assertClientMatchesOpenApiSchema($client); } } ``` You can also pass HttpFoundation objects directly — useful when you are not driving the test through the kernel browser: ```php // Response only (the Request supplies the HTTP method and path): $this->assertResponseMatchesOpenApiSchema($request, $response); // Request only (optionally pass the response status for the documented-4xx downgrade): $this->assertRequestMatchesOpenApiSchema($request, $response->getStatusCode()); ``` Spec resolution uses the same `#[OpenApiSpec]` attribute / `openApiSpec()` chain as the framework-agnostic usage below. Without either, set `spec.default` in `gesso.php` (or the `default_spec` extension parameter); the attribute and an `openApiSpec()` override still win. The `max_errors` and skip-code hooks follow the same shared settings unless overridden. Spec files are still discovered through the [PHPUnit extension](#2-configure-the-phpunit-extension) (`spec_base_path`) or a direct `OpenApiSpecLoader::configure()` call. > **Requires `symfony/http-foundation`.** Symfony projects already depend on it; it is listed under `suggest` so standalone installs aren't forced to pull it in. ### Framework-agnostic You can use the `#[OpenApiSpec]` attribute with the `OpenApiSpecResolver` trait in any PHPUnit test. Validators constructed without explicit arguments pick up the extension's `max_errors`, `skip_response_codes`, and `skip_request_validation_response_codes` parameters, and when no attribute or `openApiSpec()` override names a spec, `resolveOpenApiSpec()` falls back to the `default_spec` parameter — so a plain suite can configure the validation policy entirely from `phpunit.xml`: ```php use Studio\Gesso\Attribute\OpenApiSpec; use Studio\Gesso\Spec\OpenApiSpecResolver; use Studio\Gesso\OpenApiResponseValidator; use Studio\Gesso\Validation\Strict\StrictRequiredTracker; #[OpenApiSpec('front')] class GetPetsTest extends TestCase { use OpenApiSpecResolver; public function test_list_pets(): void { $specName = $this->resolveOpenApiSpec(); // 'front' $validator = new OpenApiResponseValidator(new StrictRequiredTracker()); $result = $validator->validate( specName: $specName, method: 'GET', requestPath: '/api/v1/pets', statusCode: 200, responseBody: $decodedJsonBody, responseContentType: 'application/json', ); $this->assertTrue($result->isValid(), $result->errorMessage()); } } ``` Or without the attribute, pass the spec name directly: ```php use Studio\Gesso\OpenApiResponseValidator; use Studio\Gesso\Spec\OpenApiSpecLoader; use Studio\Gesso\Validation\Strict\StrictRequiredTracker; // Configure once (e.g., in bootstrap) OpenApiSpecLoader::configure(__DIR__ . '/openapi/bundled', ['/api']); // In your test $validator = new OpenApiResponseValidator(new StrictRequiredTracker()); $result = $validator->validate( specName: 'front', method: 'GET', requestPath: '/api/v1/pets', statusCode: 200, responseBody: $decodedJsonBody, responseContentType: 'application/json', // optional: enables content negotiation ); $this->assertTrue($result->isValid(), $result->errorMessage()); ``` ## Controlling the number of validation errors By default, up to **20** validation errors are reported per response. You can change this via the constructor: ```php $tracker = new StrictRequiredTracker(); // Report up to 5 errors $validator = new OpenApiResponseValidator($tracker, maxErrors: 5); // Report all errors (unlimited) $validator = new OpenApiResponseValidator($tracker, maxErrors: 0); // Stop at first error $validator = new OpenApiResponseValidator($tracker, maxErrors: 1); ``` For Laravel, set the `max_errors` key in `config/gesso.php`. For plain PHPUnit, PSR-7, and Symfony suites, `validation.max_errors` in `gesso.php` (or the extension's `max_errors` parameter) sets the process-wide default that validators constructed without an explicit `maxErrors` argument, and the adapters' `openApiMaxErrors()` hooks, pick up — see the [parameter table](#2-configure-the-phpunit-extension). ## Reproduction commands in failure output Every adapter assertion failure (Laravel, Symfony, Pest, PSR-7) ends with a one-line curl command that reproduces the validated request: ```text OpenAPI schema validation failed for POST /v1/pets (spec: front): [/name] The data (integer) must match the type: string Reproduce: curl -X POST 'https://api.example.test/v1/pets' -H 'Authorization: ' -H 'Content-Type: application/json' --data '{"name":42}' ``` The PSR-7 exchange assertion prefixes each error line with its `[request]` / `[response]` side label and ends with one `Reproduce:` line for the whole exchange. In the JSON failure output mode the same command is carried in the document's `reproduce_command` field instead of a trailing line — see [validation-json-schema.md](validation-json-schema.md). Header and query values that look sensitive are redacted before the command is rendered: - `Authorization`, `Proxy-Authorization`, and `Cookie` header values, plus any header whose name contains `api-key` / `api_key` / `apikey`, `token`, or `secret` (case-insensitive), become ``. - Query-string values whose parameter name matches the same pattern are redacted too (OpenAPI supports `apiKey` security in query parameters). - The request body is rendered only for JSON content types and is **not** redacted. A body can itself carry credentials (a login password, a token refresh payload), so treat the failure output as sensitive as a whole — the redaction keeps header and query secrets out of CI logs, it does not make the output safe to share. Redaction in adapter failure output cannot be disabled. Only the fuzzing API's `ExploredCase::curlSnippet(redactSensitiveHeaders: false)` opts out, for local debugging ([fuzzing.md](fuzzing.md)). The command is rendered only when an assertion actually fails, so a passing PSR-7 assertion never re-reads the body stream for output — validation itself still decodes seekable streams (and restores the cursor) either way. ## Skipping responses by status code Production error responses (typically `5xx`) are often deliberately left out of the OpenAPI spec. Without special handling, a test that hits a `500` would fail twice: once from the underlying bug, and again from "Status code 500 not defined". To avoid that noise, every `5xx` response is **skipped by default** — body validation is not performed, the assertion passes, and the endpoint is still recorded as covered. Override via `skip_response_codes` in `config/gesso.php`: ```php return [ // Default — skip all 5xx 'skip_response_codes' => ['5\d\d'], // Widen to also skip all 4xx 'skip_response_codes' => ['4\d\d', '5\d\d'], // Disable entirely — validate every status code 'skip_response_codes' => [], ]; ``` Or pass directly to `OpenApiResponseValidator`: ```php $validator = new OpenApiResponseValidator( new StrictRequiredTracker(), skipResponseCodes: ['5\d\d'], ); ``` 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. `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. ## Auto-assert every response Forgetting `$this->assertResponseMatchesOpenApiSchema($response)` in a test means the contract is silently unchecked. Enable `auto_assert` to validate every response produced by Laravel's HTTP helpers automatically — just include the trait: ```php // config/gesso.php return [ 'default_spec' => 'front', 'auto_assert' => true, ]; ``` ```php use Studio\Gesso\Laravel\ValidatesOpenApiSchema; class GetPetsTest extends TestCase { use ValidatesOpenApiSchema; public function test_list_pets(): void { // Contract is checked automatically — no explicit assert call needed. $this->get('/api/v1/pets')->assertOk(); } } ``` Notes: - Defaults to `false` so existing test suites keep their explicit-assert behavior. - Auto-assert hooks into `MakesHttpRequests::createTestResponse()`. Responses you construct manually (outside `$this->get()`, `$this->post()`, etc.) are not touched. - Idempotency is keyed on the `(spec, method, path)` tuple. Calling `assertResponseMatchesOpenApiSchema($response)` after auto-assert with the matching signature is a no-op. Calling it with a different `method`/`path` — or a different `#[OpenApiSpec]` — runs validation again. - When auto-assert fails, the exception is thrown from inside `$this->get(...)`, so any chained assertion on the same line (`$this->get(...)->assertOk()`) will not run. This is usually what you want — the schema failure takes precedence over status-code checks. - `auto_assert` accepts boolean-compatible values (`true`/`false`/`"1"`/`"0"`/`"true"`/`"false"`) so `'auto_assert' => env('OPENAPI_AUTO_ASSERT')` works. Unrecognized values fail the test loudly with a clear message, not silently. - Streamed responses (`StreamedResponse`, binary downloads) cause `getContent()` to return `false`, which fails auto-assert with a clear message. If you use `auto_assert=true` on tests that exercise streams, scope the config change per-test or fall back to explicit manual asserts. ## Opting out with `#[SkipOpenApi]` Some tests intentionally return responses that violate the spec (error-injection tests, experimental endpoints with a not-yet-finalized contract, etc.). For these, use the `#[SkipOpenApi]` attribute to opt out of auto-assert without turning the feature off globally: ```php use Studio\Gesso\Attribute\SkipOpenApi; class ExperimentalApiTest extends TestCase { use ValidatesOpenApiSchema; #[Test] #[SkipOpenApi(reason: 'endpoint is behind an experimental flag')] public function test_experimental_endpoint(): void { $this->get('/v1/experimental'); // auto-assert is skipped } } ``` The attribute can also be applied at the class level to skip every method in that class. A method-level `#[SkipOpenApi]` fully shadows the class-level one — only the method-level attribute (and its `reason`) is inspected. Notes: - `#[SkipOpenApi]` suppresses **auto-assert only**. Explicit calls to `assertResponseMatchesOpenApiSchema()` still run — the assertion is the user's direct intent. - When auto-assert is skipped and no explicit assertion is made, no coverage is recorded for that request (the endpoint is treated as uncovered in the report). If you call `assertResponseMatchesOpenApiSchema()` explicitly on a skipped test, validation runs and coverage is recorded as usual. - If a test is marked `#[SkipOpenApi]` and still calls `assertResponseMatchesOpenApiSchema()` explicitly, an advisory warning is written to `STDERR` and a user deprecation is raised to flag the contradictory intent. The assertion is not suppressed — fix the cause by removing either the attribute or the explicit call. - The attribute is resolved via reflection on the direct class only; a class-level `#[SkipOpenApi]` on an abstract parent is **not** inherited by subclasses. Apply the attribute on each concrete test class (or per method) instead. ## Per-request skip with `withoutValidation()` When only a single request should skip validation — e.g., exercising a legacy endpoint during a staged migration — use the fluent `withoutValidation()` API instead of annotating the whole method: ```php class PetApiTest extends TestCase { use ValidatesOpenApiSchema; public function test_legacy_endpoint(): void { // Only this HTTP call skips validation. $this->withoutValidation() ->get('/v1/pets/legacy') ->assertOk(); // The next call is validated as usual. $this->get('/v1/pets')->assertOk(); } } ``` Three scopes are available: - `withoutValidation()` — skip both request and response validation - `withoutResponseValidation()` — skip response validation only - `withoutRequestValidation()` — skip request validation only (active when `auto_validate_request` is on) Notes: - The flag applies to **exactly one HTTP call**. It is consumed on the next `$this->get()` / `$this->post()` / etc., then automatically resets — a second consecutive call validates normally. - Scoped to auto-assert only, like `#[SkipOpenApi]`. An explicit `assertResponseMatchesOpenApiSchema()` call still runs regardless of the flag. - No coverage is recorded for the skipped request. - Each method returns `$this`, so both `$this->withoutValidation()->get(...)` and the step-by-step form (`$this->withoutValidation(); $this->get(...);`) work. ## Per-request status-code skip with `skipResponseCode()` `withoutValidation()` is all-or-nothing for a request. When you only need to suppress a specific status code — e.g., a flaky `404` while a fixture is being repaired — `skipResponseCode()` adds a one-off skip on top of the config-level set: ```php class PetApiTest extends TestCase { use ValidatesOpenApiSchema; public function test_endpoint_returning_a_documented_404(): void { // Only this call treats 404 as a skip; other calls keep the default behavior. $this->skipResponseCode(404) ->get('/v1/pets/missing') ->assertNotFound(); } } ``` Argument shapes: - **`int`** — exact match, anchored. `skipResponseCode(500)` matches `"500"` only, never `"5000"` or `"50"`. - **`string`** — regex pattern (anchored automatically). `skipResponseCode('4\d\d')` matches the entire `4xx` range. - **`array`** — expanded one level. Mixed types are allowed: `skipResponseCode([404, '5\d\d'])`. - **Variadic** — multiple positional arguments work too: `skipResponseCode(404, 500, '5\d\d')`. Notes: - **Merged** with `skip_response_codes` from config — per-request codes ADD to the config set rather than replacing it. With the default config, `skipResponseCode(404)` skips both `404` and every `5xx`. - **One HTTP call**: same consumption model as `withoutValidation()`. The codes are consumed on the next auto-assert attempt and reset, so the next call falls back to the config-level set. - **Auto-assert only**. Explicit `assertResponseMatchesOpenApiSchema()` calls ignore per-request codes — explicit calls are the user's direct intent. - **Chainable**: returns `$this`. Multiple chained calls accumulate (`$this->skipResponseCode(404)->skipResponseCode(503)` registers both). ## Auto-validate every request Request-side contract drift (missing query params, body-shape divergence, absent security headers) goes undetected unless the test explicitly checks for it. Enable `auto_validate_request` to run `OpenApiRequestValidator` against every request Laravel's HTTP helpers dispatch: ```php // config/gesso.php return [ 'default_spec' => 'front', 'auto_validate_request' => true, 'auto_inject_dummy_credentials' => true, // optional — see below ]; ``` Validation covers path / query / header parameters, request body (JSON Schema), and security schemes. Failures raise a PHPUnit assertion error from inside the HTTP call, exactly like `auto_assert`. Notes: - Independent of `auto_assert` — either side can be enabled on its own. Both default to `false` for backward compatibility. - `withoutRequestValidation()` and `#[SkipOpenApi]` both opt a single call (or a whole test) out of request validation, with the same per-request semantics already documented for response-side auto-assert. - `auto_validate_request` accepts boolean-compatible values (`"true"`, `"1"`, etc.) like `auto_assert`. Unrecognized values fail the test loudly. - Coverage is recorded for every matched request path, so enabling auto-validate-request without auto-assert still lights up your coverage report. ## Skip request validation when the response is a documented 4xx Tests that intentionally send invalid input to verify the impl returns a documented 422 / 400 would otherwise double-fail under `auto_validate_request`: the request is genuinely spec-invalid (that's the point), but the test only cares about asserting the 4xx response. By default, the library downgrades the request validation result from Failure to Skipped when the response status matches `skip_request_validation_response_codes` AND the spec documents that status for the operation. Default `['422', '400']`: ```php return [ // default — downgrade documented 422 / 400 responses, keep undocumented 4xx loud 'skip_request_validation_response_codes' => ['422', '400'], // strict — every spec violation surfaces, even for documented 4xx 'skip_request_validation_response_codes' => [], // wider net — downgrade every documented 4xx 'skip_request_validation_response_codes' => ['4\d\d'], ]; ``` Notes: - **Documented-only**: the downgrade only applies when the response status is in the matched operation's `responses` map (exact match, range key like `4XX`, or `default`). An undocumented 4xx still fails — that's a real spec gap. - **Failure-only**: a request that passes validation cleanly stays Success even if the response is a documented 4xx (legitimate business-logic 422 on a perfectly-shaped payload is not demoted). - **Coverage**: downgraded requests are recorded with the skip reason so the coverage report distinguishes "request was validated cleanly" from "request was downgraded because of a documented 4xx". ## Auto-inject dummy credentials When `auto_validate_request=true`, endpoints whose spec declares an enforceable security scheme (`http` + `bearer`, or `apiKey` in header / cookie / query) fail the security check unless the test supplies the credential. For test suites that authenticate via `actingAs()` or middleware bypass — and therefore never set real credentials — set `auto_inject_dummy_credentials=true` to auto-inject fixed dummy values into the validator's view of the request: ```php class SecureEndpointTest extends TestCase { use ValidatesOpenApiSchema; public function test_secure_endpoint(): void { $this->actingAs(User::factory()->create()); // No header set, but auto-inject lets request validation pass. $this->get('/v1/secure/bearer')->assertOk(); } } ``` Notes: - **View-only rewrite**: the Symfony `Request` itself is not modified; Laravel has already dispatched by the time the trait runs. The inject exists purely to prevent the security check from false-failing. - **Injected kinds**: `Authorization: Bearer test-token` for `http` + `bearer`, and a fixed `test-api-key` value for every `apiKey` scheme (header / cookie / query) the operation declares. `oauth2`, `openIdConnect`, `mutualTLS`, and non-bearer `http` schemes are silent-passed by the validator and therefore not injected. - **Never overrides non-empty user values**: if the test already set a non-empty value for the credential slot — an `Authorization` header in any case, the named cookie, or the named query / header parameter — the user's value wins, even when it is deliberately malformed. Empty-string values count as absent and are injected over, matching the validator's own missing-credential definition. - **Requires `auto_validate_request=true`** — the inject is a sub-feature of request validation. Setting the inject flag alone has no effect. - **Deprecated predecessor**: `auto_inject_dummy_bearer` (bearer-only) still works, but enabling it emits a one-shot `E_USER_DEPRECATED` notice and the key is removed in Gesso 3.0. Its exact bearer-only behavior is available on `main` as `laravel.auto_inject_dummy_credentials = 'bearer'` in the shared `gesso.php` (unreleased; not accepted in 2.6.0). `true` also injects `apiKey` schemes — see [Deprecations in `UPGRADING.md`](https://github.com/studio-design/gesso/blob/main/UPGRADING.md#deprecations) for the migration options. When both flags are set, `auto_inject_dummy_credentials` wins. ## Acknowledging an unvalidatable security scheme The validator cannot enforce `oauth2`, `openIdConnect`, `mutualTLS`, or `http` schemes other than `bearer` (basic, digest, …). Requests secured only by such a scheme silently pass the security check, and the first encounter per scheme emits a one-shot `E_USER_WARNING` so the silent pass does not stay invisible (see the [warning channel](supported-features.md#diagnostic-channels-e_user_warning-and-e_user_deprecated)). Under Laravel that warning is converted into a test failure by the framework's error handler — and because it is one-shot, *which* test fails depends on execution order. When the scheme genuinely cannot be avoided (e.g. RFC 7009 / RFC 7662 mandate HTTP Basic client authentication for token revocation / introspection endpoints) and the authentication is covered by a dedicated test, acknowledge the scheme **by name** instead of filtering `E_USER_WARNING` globally: ```php // config/gesso.php (Laravel) return [ 'auto_validate_request' => true, // components.securitySchemes keys covered by a dedicated auth test. 'acknowledged_unvalidatable_schemes' => ['ClientBasicAuth'], ]; ``` ```xml ``` Notes: - **Scheme-scoped, not global**: only the listed scheme names stop warning. Any other unvalidatable scheme — including one added to the spec later — still warns. - **The list cannot rot**: acknowledging a name that is not defined in `components.securitySchemes`, or one the validator *can* enforce (`http` + `bearer`, `apiKey`), itself emits a one-shot warning. These rot warnings carry the `[Gesso]` prefix (not `[security]`) — route them accordingly in a prefix-matching error handler. - **Validation behavior is unchanged**: acknowledged schemes were already silently passed; the acknowledgement only silences the warning. - `gesso doctor` reflects the acknowledgement via `--acknowledge-unvalidatable-scheme` — see [the doctor guide](doctor.md#acknowledged-unvalidatable-security-schemes). ## HTTP `$ref` resolution (opt-in) Local `$ref` is resolved automatically. HTTP(S) `$ref` is **disabled by default**: a spec containing `$ref: 'https://example.com/pet.yaml'` rejects with `RemoteRefDisallowed` until you opt in. This keeps tests offline-by-default and prevents an attacker-controlled spec from making the test runner reach arbitrary URLs. To enable HTTP refs, install a PSR-18 client + PSR-17 request factory and pass them along with `allowRemoteRefs: true`: ```bash # Install your preferred PSR-18 client (Guzzle 7+ shown; Symfony HttpClient + adapter, Buzz, # or any other PSR-18 implementation works the same). composer require --dev guzzlehttp/guzzle ``` ```php use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Studio\Gesso\Spec\OpenApiSpecLoader; OpenApiSpecLoader::configure( basePath: 'openapi/', httpClient: new Client(), // PSR-18 ClientInterface requestFactory: new HttpFactory(), // PSR-17 RequestFactoryInterface allowRemoteRefs: true, allowedRemoteRefHosts: ['specs.example.com'], maxRemoteRefBytes: 10_485_760, // optional; 10 MiB default per document ); ``` The library does not bundle an HTTP client — pick whichever your project already uses. (Guzzle 7+ implements PSR-18 directly; Guzzle 6 needs an adapter.) Remote-reference diagnostics remove URL userinfo and query values before they reach exception messages or CI logs. Configure authentication on the HTTP client instead of embedding credentials in a `$ref` URL. Raw PSR-18 transport and response-body stream exceptions are not chained into the public exception because a nested cause may contain unsanitized request data; the sanitized top-level message is retained. The original `$ref` argument is also replaced with its diagnostic-safe form before network operations, so exception traces remain redacted even when `zend.exception_ignore_args=Off`. Remote access requires a non-empty `allowedRemoteRefHosts` list. Matching is case-insensitive against the URL's exact host; schemes, ports, paths, and userinfo do not belong in the list. Every nested remote `$ref` is checked, so a document served by an allowed host cannot redirect resolution to another host through an absolute `$ref`. Configure the injected PSR-18 client not to follow redirects and use the canonical URL instead: a client-side redirect would otherwise happen below Gesso's host-policy boundary. This follows OWASP's SSRF guidance to prefer an allowlist and disable redirect following. DNS and network-layer controls remain the application's responsibility because PSR-18 does not expose connection-level address policy. [See the OWASP SSRF Prevention Cheat Sheet.](https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html) Each remote document is limited to 10 MiB by default. Gesso checks a valid `Content-Length`, the PSR-7 stream's known size, and the bytes actually read, so a missing or false size header cannot bypass the limit. Set `maxRemoteRefBytes` to a positive integer only when a trusted contract is larger. A PSR-18 client may buffer a response before returning its PSR-7 stream; also configure client-level timeouts and transfer limits where the selected implementation supports them. This bounds the transport as well as Gesso's parser boundary. See the [PSR-7 stream rationale](https://www.php-fig.org/psr/psr-7/#13-streams). Misconfiguration is caught early: | Setup | Result | | --- | --- | | `allowRemoteRefs: true` without `$httpClient` / `$requestFactory` | `InvalidArgumentException` at `configure()` | | `allowRemoteRefs: true` without `allowedRemoteRefHosts` | `InvalidArgumentException` at `configure()` | | HTTP(S) `$ref` targets an unlisted host | `InvalidOpenApiSpecException` with reason `RemoteRefHostDisallowed`; no request is sent | | `$httpClient` set but `allowRemoteRefs: false` | `InvalidArgumentException` at `configure()` (silent misuse impossible) | | `allowRemoteRefs: true` + client + ref to URL that 4xx/5xx | `InvalidOpenApiSpecException` with reason `RemoteRefFetchFailed` | | `allowRemoteRefs: true` + client + ref to URL that 3xx | `RemoteRefFetchFailed` with redirect target — keep redirects disabled and use the canonical URL | | Remote response exceeds `maxRemoteRefBytes` | `RemoteRefFetchFailed` before parsing; raise the limit only for a trusted contract | | `allowRemoteRefs: true` + client + ref to URL with no detectable format | reason `UnsupportedExtension` (URL extension or `Content-Type` header is required) | Format detection prefers the URL's filename extension (`.json` / `.yaml` / `.yml`) and falls back to the response's `Content-Type` (`application/json`, `application/*+json`, `application/yaml`, `text/yaml`, etc.). URLs without a recognisable extension still work as long as the server sets a usable `Content-Type`. Inside an HTTP-loaded document, relative `$refs` resolve against the URL per RFC 3986: a `$ref: './pet.yaml'` inside `https://example.com/openapi.json` fetches `https://example.com/pet.yaml`. ## Remote spec sources (opt-in) Normally a named spec resolves to a file under `basePath`. When the contract lives in a central registry or a separate spec repository (spec-first orgs, API gateways), `remoteSpecs` lets a name resolve to an HTTP(S) entry document instead — including a private GitHub repository — without vendoring the file into every service repo: ```php use GuzzleHttp\Client; use GuzzleHttp\Psr7\HttpFactory; use Studio\Gesso\Spec\OpenApiSpecLoader; use Studio\Gesso\Spec\RemoteSpecSource; OpenApiSpecLoader::configure( basePath: 'openapi/', // still used for filesystem specs httpClient: new Client(['allow_redirects' => false]), requestFactory: new HttpFactory(), allowRemoteRefs: true, allowedRemoteRefHosts: ['raw.githubusercontent.com'], remoteSpecs: [ // Private GitHub repo via a fine-grained PAT with contents:read. 'front' => new RemoteSpecSource( 'https://raw.githubusercontent.com/acme/api-specs/main/bundled/front.json', authorizationEnv: 'GESSO_SPEC_TOKEN', ), // Unauthenticated internal registry: a plain URL works too. 'admin' => 'https://specs.internal.example.com/admin/openapi.yaml', ], ); ``` ```bash # GitHub raw content accepts a PAT as a bearer token. export GESSO_SPEC_TOKEN='Bearer github_pat_…' ``` After that, `front` and `admin` behave like any other spec name — in `specs=` for coverage, in `#[OpenApiSpec(...)]` attributes, and in the direct validators. Call `configure()` from your PHPUnit bootstrap file and omit the extension's `spec_base_path` parameter (the extension would otherwise re-configure the loader without your HTTP client); keep `specs=` so remote documents are eagerly fetched and structurally validated at bootstrap. Rules and behavior: - **Same opt-in as HTTP `$ref`s.** `remoteSpecs` requires `allowRemoteRefs: true`, the PSR-18/PSR-17 pair, and every URL's host in `allowedRemoteRefHosts` — all enforced at `configure()` time. Redirect rejection, the response-size limit, format detection, and URL redaction apply exactly as described in the previous section. - **`authorizationEnv` is provider-agnostic.** The environment variable's value is sent verbatim as the `Authorization` header (`Bearer …`, `token …`, `Basic …` — whatever the registry expects). A missing or empty variable fails loudly with reason `RemoteSpecAuthEnvMissing` before any request is sent. The value itself never appears in diagnostics. - **Credentials are origin-scoped.** The header is sent to the entry document's origin only — same scheme, host, and effective port (RFC 6454). Relative `$ref`s (which resolve against the entry URL per RFC 3986) and same-origin absolute `$ref`s carry it; anything else never does: not a cross-host `$ref` (even to another allowlisted host), not another port on the same host, and not an `http://` downgrade of an `https://` entry document. - **A mapped name never touches the filesystem.** If `front` is in `remoteSpecs`, `openapi/front.json` is ignored for that name. - **Fetched once per process.** The resolved document is cached under its spec name like a local spec; parallel workers fetch once each. For CI predictability, pin the document to the exact bytes you reviewed: ```php 'front' => new RemoteSpecSource( 'https://raw.githubusercontent.com/acme/api-specs/main/bundled/front.json', authorizationEnv: 'GESSO_SPEC_TOKEN', expectedSha256: '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', ), ``` A mismatch fails with reason `RemoteSpecHashMismatch` (showing expected and actual digests) before the document is parsed, so an upstream edit — or a compromised registry — cannot silently change what your tests enforce. Compute the pin with `curl -s … | shasum -a 256` and commit it; update it deliberately when the upstream contract changes. If you prefer freshness over pinning, point the URL at an immutable ref (a commit SHA instead of a branch) or add ETag caching middleware to the injected PSR-18 client — Gesso deliberately performs plain conditional-free GETs and leaves transport caching to the client. --- Source: https://studio-design.github.io/gesso/configuration.md # Shared PHPUnit and Laravel configuration > Available on `main`; not included in the published 2.6.0 release. Put shared settings in `gesso.php` at the application root. The PHPUnit extension and Laravel service provider read the same file, so spec paths, request prefixes, and validation policy need only be written once. The runnable [Scramble example](recipes/scramble.md#run-the-example) exercises this setup with real HTTP tests and Artisan commands. ## Minimal setup ```php [ 'base_path' => 'openapi', 'default' => 'api', 'names' => ['api'], 'strip_prefixes' => ['/api'], ], 'coverage' => ['report_output' => ['json' => 'build/coverage.json']], ]; ``` Register the extension in `phpunit.xml`: ```xml ``` Laravel discovers its application-root `gesso.php` automatically. Keep the usual `ValidatesOpenApiSchema` trait and assertions in your tests. You do not need to publish `config/gesso.php` for this setup. `spec.names` lists the specs PHPUnit must load and include in coverage; `spec.default` selects the fallback for assertions. Set both as above for a single-spec project. `spec.strip_prefixes` controls matching, independently of the `--request-prefix` option used to generate test requests. ## Discovery and relative paths - PHPUnit resolves the `config` parameter relative to the selected PHPUnit XML file. Without the parameter, it looks for `gesso.php` beside that XML file. If no XML file is selected, it uses the working directory. - Laravel looks in `Application::basePath()`, independently of the working directory. A custom PHPUnit config path should point to that same root file when sharing settings with Laravel. - Paths **inside** `gesso.php` resolve against that file's directory: spec base path, report outputs, sidecar directory, and baseline files. - An explicitly selected missing file, unknown key, invalid value, or PHP evaluation error aborts PHPUnit with exit 1 even without `failOnWarning`. An absent conventional file preserves existing behavior. For example, `cd tests && ../vendor/bin/phpunit -c ../phpunit.xml` reads the same specs and writes reports to the same paths as running from the root. Use plain PHP values and `__DIR__` if needed; Laravel-only helpers such as `base_path()` and `env()` may be unavailable when PHPUnit reads the file. ## Compatibility and precedence This is an additive v2 bridge. Existing XML parameters and Laravel config keys continue to work. Only keys explicitly declared in `gesso.php` contribute values; omitted or nullable settings left at `null` retain existing consumer defaults. In particular, an omitted `spec.names` retains PHPUnit's `front` default; an omitted `spec.base_path` retains the programmatic loader setup. In that case, an explicit `spec.strip_prefixes` still replaces the loader's prefixes without changing its base path, enum path, remote-reference settings, or cached specs. An empty list clears the prefixes; omitting the setting preserves the prefixes configured in bootstrap. - PHPUnit: existing format/console environment overrides, then explicit XML parameters, then declared shared settings, then existing defaults. - Laravel: existing `config('gesso.…')` values, then declared shared settings, then package defaults. Arrays are replaced as whole values, not concatenated. - Explicit per-test and per-call overrides keep their existing priority. To migrate, copy values into the root file, remove their matching XML parameters, and remove the copied keys from `config/gesso.php`. Once all values have moved, delete `config/gesso.php` and clear any Laravel configuration cache. Leaving copies behind means each adapter's old values still win, which can keep the two adapters inconsistent. `vendor:publish --tag=gesso` still publishes the existing v2 file; avoid republishing it after migration. Laravel's `config:cache` captures the resolved shared settings in its normal snapshot. While cached, the provider does not evaluate `gesso.php` again. Run `php artisan config:clear` after changing shared settings before testing; PHPUnit itself always reads the source file. This follows Laravel's [configuration cache lifecycle](https://laravel.com/docs/12.x/configuration#configuration-caching). Booleans in the shared file accept PHP booleans and the strings `true/false`, `1/0`, `yes/no`, `on/off`, or an empty string (false). Invalid strings fail. Lists stay arrays, including patterns containing commas: ```php 'validation' => [ 'max_errors' => 10, 'enforce_discriminator' => 'off', 'skip_response_codes' => ['5[0-9]{2,2}'], 'skip_request_validation_response_codes' => [], ], ``` For dummy request credentials, use `laravel.auto_inject_dummy_credentials`: `false` disables injection, `true` fills missing Bearer and API-key credentials, and `'bearer'` fills only missing HTTP Bearer credentials. All modes require `laravel.auto_validate_request => true`. Injection changes only the validator's view, never the dispatched request, and never replaces a populated credential. A missing API key still fails in `'bearer'` mode, including operations that require both Bearer and an API key. The named mode does not use the deprecated `auto_inject_dummy_bearer` setting or emit its deprecation. ## Settings connected in this release | Shared setting | PHPUnit parameter | Laravel config key (`gesso.` prefix) | | --- | --- | --- | | `spec.base_path` | `spec_base_path` | `spec_base_path` | | `spec.default` | `default_spec` | `default_spec` | | `spec.names` | `specs` | — | | `spec.strip_prefixes` | `strip_prefixes` | `strip_prefixes` | | `validation.max_errors` | `max_errors` | `max_errors` | | `validation.enforce_discriminator` | `enforce_discriminator` | `enforce_discriminator` | | `validation.acknowledged_unvalidatable_schemes` | `acknowledged_unvalidatable_schemes` | `acknowledged_unvalidatable_schemes` | | `validation.skip_response_codes` | `skip_response_codes` | `skip_response_codes` | | `validation.skip_request_validation_response_codes` | `skip_request_validation_response_codes` | `skip_request_validation_response_codes` | | `validation.format` | `validation_output` | — (run-wide PHPUnit selection) | | `strict.required.run` / `strict.required.per_call` | `strict_required` / `strict_required_per_call` | — | | `strict.additional_properties.run` / `strict.additional_properties.per_call` | `strict_additional_properties` / `strict_additional_properties_per_call` | — | | `coverage.min_coverage.endpoint` / `.response` / `.sdk_exercise` | `min_endpoint_coverage` / `min_response_coverage` / `min_sdk_exercise_coverage` | — | | `coverage.min_coverage.strict` | `min_coverage_strict` | — | | `coverage.report_output.markdown` / `.json` / `.junit` / `.html` | `output_file` / `json_output` / `junit_output` / `html_output` | — | | `coverage.console_report` | `console_output` | — | | `coverage.sidecar_dir` | `sidecar_dir` | — | | `baseline.violations` / `baseline.coverage` | `baseline_file` / `coverage_baseline_file` | — | | `baseline_stale.violations` / `baseline_stale.coverage` | `baseline_stale` / `coverage_baseline_stale` | — | | `enum_drift.enabled` / `.scan_namespaces` / `.fail_on_drift` | `enum_drift_enabled` / `enum_drift_scan_namespaces` / `enum_drift_fail_on_drift` | — | | `phpunit.default_testsuite_as_full` | `default_testsuite_as_full` | — | | `laravel.auto_assert` | — | `auto_assert` | | `laravel.auto_validate_request` | — | `auto_validate_request` | | `laravel.auto_inject_dummy_credentials` | — | `auto_inject_dummy_credentials` | | `laravel.route_parity.external_operation_ids` / `.external_openapi_paths` | — | `route_parity.external_operation_ids` / `.external_openapi_paths` | Enum-valued keys are matched after trimming and lower-casing, exactly like the equivalent PHPUnit parameters; the canonical lower-case value is what the surfaces receive. Use the existing [setup](setup.md) and [coverage](coverage.md) guides for each setting's behavior. `enum_spec_base_path` remains an XML-only v2 compatibility setting; it has no shared successor. This step connects PHPUnit and Laravel, including `gesso:routes` and `gesso:stubs`. Standalone `vendor/bin/gesso` commands still require their existing flags. The PSR-7 and Symfony policy hooks (`openApiSpec()`, `openApiMaxErrors()`, `openApiSkipResponseCodes()`, `openApiSkipRequestValidationResponseCodes()`) default to the shared `spec.default` and `validation.*` values; an override still wins. This does not yet migrate every surface or implement the complete [v3 configuration plan](adr/0005-v3-configuration-and-cli-naming.md). Legacy-input deprecations and removals are separate follow-up work. --- Source: https://studio-design.github.io/gesso/coverage.md # Coverage Report After running tests, the PHPUnit extension prints a coverage report. The output format is controlled by the `console_output` parameter (or `GESSO_CONSOLE_OUTPUT` environment variable). Coverage is tracked at **`(method, path, statusCode, contentType)` granularity**: a `GET /v1/pets` test that only exercises `200 application/json` does not count `404` or `application/problem+json` as covered. Per-endpoint markers reflect the resolved state across all declared response definitions: | Marker | Meaning | |--------|---------| | `✓` / `:white_check_mark:` | All declared `(status, content-type)` pairs validated | | `◐` / `:large_orange_diamond:` | Some pairs validated, others uncovered | | `⚠` / `:warning:` | Pair was skipped (e.g. `5XX` matched the default skip pattern) | | `✗` / `:x:` | No pair validated for this endpoint | | `·` / `:information_source:` | Endpoint reached via request-validation but no response asserted | Specification extensions (`x-` keys) on a Responses Object are not responses and are excluded from the declared set. The report also breaks the coverage rate into two numbers — the strict endpoint rate (all declared responses validated) and the response-level rate (`responseCovered / responseTotal`). SDK response exploration is reported separately as **SDK exercise coverage**. It measures whether eligible JSON response schemas were handed to a generated SDK decoder; it does not change HTTP validation coverage. A response can be covered by an HTTP contract assertion but remain unexercised by an SDK, or vice versa. - [Console modes](#console-modes) - [Coverage threshold gate](#coverage-threshold-gate) - Output formats — see [`ci.md`](ci.md#coverage-output-formats), [`coverage-html-output.md`](coverage-html-output.md), [`coverage-json-schema.md`](coverage-json-schema.md) - Parallel-runner merge — see [`parallel.md`](parallel.md) ## Console modes ### `default` mode (default) Shows endpoint summary lines only: ``` OpenAPI Contract Test Coverage ================================================== [front] endpoints: 12/45 fully covered (26.7%), 8 partial, 25 uncovered responses: 38/120 covered (31.7%), 4 skipped, 78 uncovered SDK responses: 20/24 exercised (83.3%), 4 unexercised -------------------------------------------------- Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type ✓ GET /v1/pets (3/3 responses) ◐ POST /v1/pets (1/2 responses) ◐ DELETE /v1/pets/{petId} (1/2 responses, 1 skipped) ✗ PUT /v1/pets/{petId} (0/2 responses) ``` > Endpoint markers come from a fixed set: `✓` all-covered, `◐` partial (any combination of validated, skipped, uncovered short of full coverage), `·` request-only, `✗` uncovered. The `⚠` marker is reserved for per-response sub-rows (skipped responses), never for endpoint summary lines. ### `all` mode Shows endpoint summaries with per-response sub-rows. Sub-row whitespace is illustrative — the renderer pads `statusKey` to 5 chars and `contentTypeKey` to 32 chars: ``` [front] endpoints: 12/45 fully covered (26.7%), 8 partial, 25 uncovered responses: 38/120 covered (31.7%), 4 skipped, 78 uncovered -------------------------------------------------- Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type ✓ GET /v1/pets (3/3 responses) ✓ 200 application/json [12] ✓ 400 application/problem+json [1] ✓ 422 Application/Problem+JSON [1] ◐ POST /v1/pets (1/2 responses) ✓ 201 application/json [3] ✗ 422 application/problem+json uncovered ◐ DELETE /v1/pets/{petId} (1/2 responses, 1 skipped) ✓ 204 * [2] ⚠ 5XX * skipped: status 503 matched skip pattern 5\d\d ``` ### `uncovered_only` mode Shows sub-rows only for partial / uncovered endpoints, keeping fully-covered ones compact: ``` [front] endpoints: 12/45 fully covered (26.7%), 8 partial, 25 uncovered responses: 38/120 covered (31.7%), 4 skipped, 78 uncovered -------------------------------------------------- Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type ✓ GET /v1/pets (3/3 responses) ◐ POST /v1/pets (1/2 responses) ✗ 422 application/problem+json uncovered ✗ PUT /v1/pets/{petId} (0/2 responses) ✗ 200 application/json uncovered ✗ 404 application/problem+json uncovered ``` To turn that list into pre-written tests, feed the same run's JSON output to [`gesso stubs`](stubs.md). ### `active_only` mode Useful for the local TDD loop with a multi-spec setup (e.g. `specs="front,store,admin"`). Specs that no test in this run touched are collapsed to a single line, so a focused single-test run no longer has to scroll past hundreds of `✗ uncovered` rows for unrelated specs. Specs with at least one validated, skipped, or request-only observation render the same one-line-per-endpoint view as `default`: ``` [front] no test activity (373 endpoints, 894 responses in spec) [store] no test activity (148 endpoints, 312 responses in spec) [admin] endpoints: 1/72 fully covered (1.4%), 0 partial, 71 uncovered responses: 1/172 covered (0.6%), 0 skipped, 171 uncovered -------------------------------------------------- Legend: ✓=validated ⚠=skipped ✗=uncovered ◐=partial ·=request-only *=any/no content-type ✓ GET /v2/admin/early_accesses (1/1 responses) ✗ POST /v2/admin/early_accesses (0/2 responses) ... ``` You can set the mode via `phpunit.xml`: ```xml ``` Or via environment variable (takes priority over `phpunit.xml`): ```bash GESSO_CONSOLE_OUTPUT=uncovered_only vendor/bin/phpunit ``` ## Coverage threshold gate Optional CI gate that fails the run when contract or SDK exercise coverage drops below a configured percentage — the contract-testing analogue of PHPUnit's own `--coverage-threshold`. All metrics are aggregated across every spec listed in `specs=`: - `min_endpoint_coverage` — percentage of endpoints with **all** declared `(status, content-type)` pairs validated. - `min_response_coverage` — percentage of `(method, path, status, content-type)` rows validated (the same rate the report calls "responses covered"). - `min_sdk_exercise_coverage` — percentage of eligible JSON response schemas for which an SDK decoder callback was attempted. The SDK denominator includes resolved JSON-compatible response media types with a schema. It excludes no-content responses, non-JSON media types, missing or non-JSON schemas, and OpenAPI 3.2 `itemSchema` streaming responses. A configured SDK gate fails as unevaluable when that denominator is empty. Default is **warn-only**: a miss prints `[OpenAPI Coverage] WARN: …` to stderr but the run exits 0. Flip `min_coverage_strict=true` to make a miss fail-fast with exit 1. > To stop coverage from *regressing*, prefer the > [coverage baseline](coverage-baseline.md): it gates the set of uncovered > responses instead of a percentage, so there is no threshold to keep raising > by hand, documenting a new response cannot fail an unrelated PR, and a > failure names the offending rows. To gate only what the pull request > actually touched, add the > [spec patch coverage gate](coverage-gate.md). ```xml ``` Failure looks like: ``` [OpenAPI Coverage] FAIL: endpoint coverage 67.4% < threshold 80%. response coverage 71.2% (>= 60%, ok). SDK exercise coverage 83.3% < threshold 100%. ``` Out-of-range or non-numeric values produce a `WARNING` to stderr and skip that gate (rather than silently treating the misconfiguration as `0%`). On a partial run (`--filter`, `--testsuite`, path args, …) the gate is skipped with a one-line NOTE instead of evaluating — a subset cannot prove a suite-wide coverage rate. See [Partial test runs](ci.md#partial-test-runs-filter-testsuite-path-args) for details. For paratest / `pest --parallel`, the merge CLI accepts the same options as flags: ```bash vendor/bin/gesso coverage:merge \ --spec-base-path=openapi/bundled \ --specs=front,admin \ --min-endpoint-coverage=80 \ --min-response-coverage=60 \ --min-sdk-exercise-coverage=100 \ --min-coverage-strict ``` The parallel strict SDK gate also requires every worker sidecar to contain SDK exercise state. A mixed old/new worker fleet is incomplete evidence and fails; warn-only mode reports the missing-worker count and evaluates the available observations. --- Source: https://studio-design.github.io/gesso/supported-features.md # Supported features and known limitations This is a contract-testing tool: where we can't enforce a constraint precisely, we prefer a loud failure or an explicit "skipped" outcome over silently accepting non-compliant data. The list below pins down what does and does not get checked so you can decide whether the gaps matter for your spec. The schema-conversion behaviour described here is measured against the official JSON Schema Test Suite on every CI run, and the loader against the OpenAPI Initiative's own example documents — see [conformance](conformance.md) for the current results. - [OpenAPI 3.0, 3.1, and 3.2](#openapi-30-31-and-32) - [`readOnly` / `writeOnly` enforcement](#readonly--writeonly-enforcement) - [Body validation](#body-validation) - [Parameter styles](#parameter-styles) - [Security schemes](#security-schemes) - [Schema features](#schema-features) - [HTTP methods](#http-methods) - [Spec features not consulted](#spec-features-not-consulted) - [Diagnostic channels (`E_USER_WARNING` and `E_USER_DEPRECATED`)](#diagnostic-channels-e_user_warning-and-e_user_deprecated) ## OpenAPI 3.0, 3.1, and 3.2 The package accepts OpenAPI `3.0.x`, `3.1.x`, and `3.2.x`. The root `openapi` field must be a string in explicit `major.minor.patch` form (for example, `3.0.4`, `3.1.2`, or `3.2.0`). Patch releases within a supported minor use the same feature set, following the [OpenAPI version policy](https://spec.openapis.org/oas/latest.html#versions-and-deprecation). Missing, empty, non-string, malformed, and unsupported values fail spec loading with `InvalidOpenApiSpecException`. This includes Swagger / OpenAPI 2.x, OpenAPI 3.3.x, and unknown future versions. They are never interpreted as 3.0. For supported versions, the package detects the OAS feature family from the `openapi` field and handles schema conversion accordingly: | Feature | 3.0 handling | 3.1 / 3.2 handling | |---|---|---| | `nullable: true` | Converted to type array `["string", "null"]`; `null` appended to `enum` if present | Not applicable (uses type arrays natively) | | Boolean `exclusiveMinimum` / `exclusiveMaximum` | Lowered with its numeric `minimum` / `maximum` to the Draft 07 numeric form | Native numeric JSON Schema keyword | | `prefixItems` | N/A | Preserved and enforced natively by JSON Schema 2020-12 | | `$dynamicRef` / `$dynamicAnchor` | N/A | Preserved and enforced natively | | `examples` (array) | Removed (Draft 2020-12 keyword, not Draft 07) | Removed (Draft 2020-12 keyword, not Draft 07) | | `const` | N/A | Preserved and enforced natively | | `readOnly` / `writeOnly` | Semantic enforcement (see below). Forbidden properties become boolean `false` subschemas; the keyword is dropped as OAS-only on surviving properties | Semantic enforcement (see below). Forbidden properties become boolean `false` subschemas; the keyword is preserved as an annotation on surviving properties | For OpenAPI 3.1/3.2, the root `jsonSchemaDialect` supplies the default dialect and a resource-root Schema Object's `$schema` overrides it. The OpenAPI base dialect is evaluated as JSON Schema 2020-12 after OpenAPI-specific semantics (`readOnly` / `writeOnly` and `discriminator`) are applied. Opis-supported JSON Schema Draft 06, Draft 07, 2019-09, and 2020-12 declarations are accepted. Unknown custom dialects fail with `InvalidOpenApiSpecException` / `UnsupportedJsonSchemaDialect` instead of silently falling back to Draft 07. OpenAPI 3.2 is backward compatible with 3.1, so ordinary 3.2 operations use the tested 3.1 conversion pipeline. The behavior below follows the official [OpenAPI 3.2 specification](https://spec.openapis.org/oas/v3.2.0.html) and [3.1-to-3.2 upgrade guide](https://learn.openapis.org/upgrading/v3.1-to-v3.2.html). The contract-relevant 3.2 additions have explicit behavior: - `QUERY` works in direct validators, PSR-7, Laravel, Symfony, Pest, fuzz exploration, and coverage reports. - Custom methods under `additionalOperations` resolve in direct request/response validators and the PSR-7 adapter, preserving their case-sensitive method spelling, and appear in coverage. The enum-based framework and fuzz adapters accept `QUERY` but not arbitrary custom method tokens; whole-spec exploration reports those operations as skipped with a reason instead of silently omitting them. - One `in: querystring` parameter with `application/x-www-form-urlencoded` content validates the entire framework-parsed query map against its schema. Mixing it with `in: query`, declaring it more than once, or omitting its schema fails loudly. Other query-string media types emit `[OpenAPI 3.2 querystring]` because the public validator receives a parsed map rather than the original serialized query string. - `discriminator.defaultMapping` is enforced for missing and unknown values when an explicit `mapping` is also present. With implicit mappings only, missing values use the fallback while unknown present values rely on the underlying `oneOf` / `anyOf`; `[OpenAPI 3.2 discriminator]` makes that residual limitation observable. - `itemSchema` streaming bodies are returned as `Skipped` with a reason and matched content type. Framework adapters buffer a complete body, while the PSR-7 adapter refuses to consume a non-seekable JSON stream; neither path can safely apply a schema independently to each SSE, JSON Lines, JSON Text Sequence, or multipart stream item. - A root `$self` emits `[OpenAPI 3.2 $self]`: relative references still resolve from the retrieved file path, so specs depending on a different `$self` base URI must be pre-bundled. ### `readOnly` / `writeOnly` enforcement Both validators apply OpenAPI's asymmetric semantics instead of letting the keywords pass as no-ops: - **Response validation** (`OpenApiResponseValidator`, Laravel trait): any property marked `writeOnly: true` must **not** appear in the response body. If it does, validation fails with the offending property named in the error. A `writeOnly + required` entry is treated as absent on the response side, so a compliant response that omits the property still validates. - **Request validation** (`OpenApiRequestValidator`): any property marked `readOnly: true` must **not** appear in the request body. `readOnly + required` is treated as absent on the request side, so a compliant request that omits the property still validates. Detection looks at each property schema's own top-level `readOnly` / `writeOnly`; markers nested inside the property's `allOf` / `oneOf` / `anyOf` children are not enforced in the current release. ## Body validation - **JSON body types**: framework and PSR-7 adapters preserve objects and arrays at every depth. `{}` satisfies `type: object`; `[]` does not. Literal `null` is a present value and is type-checked, while an absent required body fails. Direct callers can retain the same distinctions using [`DecodedBody::fromJsonValue()`](api-reference.md#decodedbody); legacy associative-array inputs keep their historical empty-object compatibility behavior. - **Required non-JSON requests**: Laravel and Symfony preserve body presence from raw content, parsed parameters, or uploaded files. PSR-7 defers opaque-body inspection until the resolved contract requires it, then uses parsed data, uploaded files, or stream size; an unknown-size stream is inspected only if its cursor can be restored. Unknown presence produces a read failure, not an empty-body diagnosis. Optional or undeclared opaque bodies are not read. This satisfies only the presence check; it does not imply that an opaque body's schema was validated. - **Validated**: `application/json` and any `+json` structured-syntax suffix (RFC 6838), and content keys using ranges (`application/*`, `*/*`) — the matcher tries exact match first, then `/*`, 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. When that fallback fires **and the body then fails**, the failure ends with a `Note:` line — last, after any response-header errors — naming the undeclared Content-Type, the key the body was validated against, and the declared keys — the shape mismatch on its own reads as "the body is wrong" when the actual fix is to document the second media type. The same line is a `response.content_type` issue in the [JSON failure document](validation-json-schema.md). A body that satisfies the schema it landed on stays a plain success with no note. - **Presence-only** (no schema validation): every other media type, including `application/xml`, `text/plain`, and `application/octet-stream` — and form bodies on the response side. 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. - **OpenAPI 3.2 streaming `itemSchema`**: explicitly `Skipped`, never counted as a clean validation. `prefixEncoding` / `itemEncoding` are therefore not enforced either. - **Form request bodies** (`application/x-www-form-urlencoded`, `multipart/form-data`): validated against the media type's schema. Values arrive as strings and are coerced to the declared property types first, exactly as query parameters are, so `age=3` satisfies `type: integer` while `age=three` fails at `/age`. Framework adapters hand the parsed field map to the validator (the PSR-7 adapter uses a `ServerRequest`'s parsed body and uploaded files; a client `RequestInterface` carrying raw urlencoded bytes is parsed by the validator). A raw `multipart/form-data` payload with no parsed parts is not reassembled — it stays `Skipped` with a reason. Response-side form bodies remain presence-only. - **Multipart file parts**: a file part's bytes are never read. Its presence satisfies `required`, its client filename stands in for the binary value (so a `minLength` / `pattern` on a binary property is measured against the filename, not the file), and its declared `Content-Type` is checked against the `encoding` object. Per RFC 7578 §4.4 a part with no Content-Type of its own counts as `text/plain` rather than matching anything. A file whose upload failed (anything other than `UPLOAD_ERR_OK` — no file sent, size limit, partial write) is dropped before validation, so it cannot satisfy a `required` part, and dropping it never leaves a hole in a file list. - **Binary properties must arrive as file parts**: a property describing raw bytes fails if the request sent a plain field instead, and so does any element of an array of them (how a multi-file upload is described). Raw bytes means OAS 3.0 `format: binary`, OAS 3.1's `contentMediaType` with **no** `type` (a JSON string cannot hold arbitrary bytes, which is why the 3.1 form omits it), or the empty schema `{}` — whose default media type is the octet stream, making the OAS 3.2 multi-file example `type: array, items: {}` a list of files. A declared `type` rules it out: per JSON Schema 2020-12 `type: string` with a `contentMediaType` and no `contentEncoding` is identity-encoded UTF-8 text, so it is validated as an ordinary field whatever the media type says. `format: byte` and an explicit `contentEncoding` are text on the wire too. - **Multipart `encoding` object**: `encoding..contentType` is enforced for file parts — a part whose Content-Type is outside the declared media type, range, or comma-separated list fails. A part whose contract admits nothing but JSON is decoded before its subschema is applied (parsing it confirms the declared type rather than guessing at it); when `contentType` is omitted the default is computed from the property type alone — `application/json` for `type: object`, the inner type's default for an array, `application/octet-stream` for a `format: binary` string or for any schema that declares no `type`, `text/plain` for the other primitives. Neither `contentMediaType` nor an untyped `properties` block feeds that computation, which is what OpenAPI prescribes. - **Unconfirmable parts are `Skipped`, not passed**: form parsing keeps no per-part headers, so a non-file part's own Content-Type is not observable. OAS 3.2 §4.15.4.1 resolves several declared media types by that header and rules out content sniffing, so where it is needed — a mixed list such as `application/json, application/xml`, or a single non-text type such as `image/png` on a plain field — the body is returned as `Skipped` with a reason naming the part. The schema still runs, unmodified, against the real data: withheld are only the violations that point into such a part, or that another permitted reading of it disagrees about. Each unresolved part is re-read on its own terms — the JSON value its bytes decode to (objects stay objects, and a JSON string reads as that string) when JSON is among its candidates, plus shape probes when one of them cannot be materialised at all — and the readings of several parts are combined, since each part chooses its media type independently. An array part keeps its container in every reading, since `encoding` applies to its items. That keeps every object-level constraint (`required`, `minProperties`, `additionalProperties`, a composed `required`) honest, while an `if` / `oneOf` / `dependentRequired` branch keyed on the part cannot fabricate a root-level failure out of the raw-string reading. A violation both readings agree on is reported as a failure and takes precedence over the skip. When the parts combine into more readings than the validator enumerates (64), the readings are not sampled — an unchecked combination may be the one that excuses a violation — and only what no reading can reach survives: a reading changes nothing but the unresolved parts' own values, so the schema is reduced to the keywords that read no more than the key set and one property's value at a time (`type`, `required`, `min`/`maxProperties`, `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `dependentRequired`, plus `allOf` / `dependentSchemas` reduced the same way) and validated against the same data. What it still reports — an unconditional `required`, `minProperties`, a plain field's own type — stays a failure. The reduction is an allowlist, so a keyword reading the whole object (`enum`, `const`), a conditional one (`if` / `then` / `else`, `anyOf`, `oneOf`, `not`, `unevaluated*`), and anything a future dialect adds cannot survive by going unrecognised. The rest is left unconfirmed and stated in the skip reason; a `$ref` the loader did not inline leaves nothing provably unconditional, so everything is left unconfirmed then. `encoding..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 walker descends through single-schema `items`, Draft 07 tuple-form `items`, and native 2020-12 `prefixItems`. Composition keywords and other ambiguous routing shapes fall through to keeping the original message untouched, so a real violation is never silently swallowed. ## Parameter styles - **Query**: `style: form` + `explode: true` (the OAS default) expects repeated keys, parsed into arrays by the framework. For `type: array` schemas the non-exploded serializations are split on their delimiter before validation: `form` + `explode: false` on `,`, `pipeDelimited` on `|` / `%7C` (the OAS Style Examples percent-encode this delimiter), `spaceDelimited` on `%20` / `+`. The framework adapters (PSR-7, Laravel, Symfony) pass the raw query string through, so splitting happens before percent-decoding and a `%2C` inside a `form`-style value stays data (`role=owner%2Cadmin,member` → `["owner,admin", "member"]`); a delimiter character inside a `pipeDelimited` / `spaceDelimited` value is unrepresentable (undefined per OAS Appendix E). The raw value is only used when it decodes to the framework-parsed value (PSR-7 allows the two to diverge; the parsed map wins). Direct `OpenApiRequestValidator` callers get the same by passing `rawQueryString`, and without it the decoded value is split as a best effort. An empty value (`?role=`) is the one-element list `[""]` — RFC 6570 omits an empty list entirely. `deepObject` and non-exploded `type: object` parameters are not parsed; type-mismatch errors will surface but they will point at the wrong cause. - **Query string (3.2)**: `in: querystring` with `application/x-www-form-urlencoded` validates the whole parsed query map. Other media types emit a categorized warning and skip query-string validation. - **Header / Path**: only `style: simple` for scalar values. `type: array` and `type: object` parameters are not parsed (the raw string is fed to the schema, which then mismatches). `style: matrix` and `style: label` for path parameters are not handled — the prefix is not stripped before validation. - **Cookie parameters** (`apiKey` security scheme aside): not validated. - **`parameters[].content`**: read only for OpenAPI 3.2 `in: querystring`; other parameter locations still use `parameters[].schema` only. ## Security schemes - **Validated**: `apiKey` (in `header` / `query` / `cookie`) and `http` + `bearer` — presence checks for the named header/query/cookie / RFC 6750 `Bearer` token. - **Loud `E_USER_WARNING` on first encounter**: `oauth2`, `openIdConnect`, `mutualTLS`, and `http` schemes other than `bearer` (`basic`, `digest`). When every scheme in a security requirement is unsupported the requirement still passes (false-negative avoidance — blocking the test for a spec we cannot evaluate is worse than letting it through), but the validator fires a one-shot per-scheme-name warning so the silent pass does not stay invisible. The warning is emitted as a single line (shown wrapped here for readability): ``` [security] OAuth2 scheme 'oauth2_user' is silently passed (no token check) — POST /v1/users. The opis/json-schema-based validator cannot verify oauth2 / openIdConnect / mutualTLS / http-basic / http-digest credentials. Your test will not detect a missing or invalid token. Workaround: split the bearer-token surface into a separate test, or assert the Authorization header presence manually. ``` Under `phpunit.xml` `failOnWarning="true"` this surfaces as a test failure on first encounter — the recommended setting if your spec contains any of these scheme types, since green tests against unauthenticated requests are the worst-class silent failure for a contract-testing tool. ## Schema features - **Validated in every supported dialect**: `type`, `enum`, `multipleOf`, `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum`, `minLength`/`maxLength`/`pattern`, `minItems`/`maxItems`/`uniqueItems`, `minProperties`/`maxProperties`/`required`, `additionalProperties` (`true` / `false` / schema), `allOf` / `oneOf` / `anyOf` / `not`. - **Native in OpenAPI 3.1/3.2**: `const`, `prefixItems`, `$dynamicRef`, `$dynamicAnchor`, `unevaluatedProperties`, `unevaluatedItems`, `dependentSchemas`, and `dependentRequired`. These are preserved and delegated to the selected JSON Schema dialect rather than lowered or discarded. - **`$ref` sibling keywords** (#536): where the effective JSON Schema dialect is 2019-09 or later — the OpenAPI 3.1/3.2 default — a Schema Object `$ref` is an in-place applicator, so keywords written next to it apply alongside the resolved target: `{$ref: '#/components/schemas/Name', minLength: 4}` enforces both. Reference resolution merges the two into one Schema Object (`{type: string, minLength: 4}`) rather than composing them as `allOf` branches. That flat merge is what keeps the resolved document readable by everything downstream that reads a schema's own top level: parameter/header coercion and query-style splitting read `type` and `items`, form decoding reads `properties`, `unevaluatedProperties` / `unevaluatedItems` read the annotations of *adjacent* keywords, and `readOnly` / `writeOnly` are enforced from the property schema's own top level. A keyword both sides declare is merged by its own semantics: property maps (`properties`, `patternProperties`, `dependentSchemas`, `$defs`) merge per name and recurse — with the boolean schemas `false` and `true` treated as the absorbing and identity elements they are, so a sibling `true` never re-opens a property the target closed with `false` — `required` unions, bounds tighten to the stricter one, and an identical restatement collapses to one. Merging only ever rewrites a well-formed schema: a malformed side of a collision (`minLength: "3"`, `required: [1]`, a `properties` or a subschema written as a JSON array, an empty or non-schema `allOf`, a `$schema` that is not a URI string) is kept exactly as written, because repairing it would turn a spec error into a schema the validator accepts. A lone keyword is never lifted out into a branch of its own — keywords in a schema object work on each other, so separating an `if` from its `then`, or an `unevaluatedProperties` from the `properties` it reads, changes what the schema means. When a collision has no meaning-preserving merge, the siblings are applied whole as an adjacent `allOf` branch, leaving the target's own top level untouched. The same fallback covers the one interaction that is not a collision: `additionalProperties` applies to the names *its own adjacent* `properties` do not match, so a side that constrains it reaches across to names only the other side declares — merging them into one object would exempt them, and with `additionalProperties: false` that turns a schema nothing can satisfy into one that accepts the union. (`unevaluatedProperties` is different: it reads the annotations of adjacent in-place applicators, `$ref` among them, so the flat merge is exactly what it already meant.) A target that declares its own `$schema` is a schema resource with a dialect of its own, and merging would put the *siblings* under that dialect, so unless it is the same dialect the referring schema is read under, the target is applied whole as a branch instead. Same *dialect*, not merely the same `$ref`-sibling rule: 2019-09 and 2020-12 both apply siblings and still disagree on array tuples (`items` vs `prefixItems`). Coercion follows through the branch — `TypeCoercer` reads `type` and `items` through `allOf`, and because `allOf` ANDs, the type it coerces to is the intersection of the declared type sets (with `integer` treated as the subset of `number` it is, so a union offering both coerces as `number`) and the item schema it coerces against is the conjunction of every `items` that applies, rather than whichever one sits at the top level. The same resource rule applies to a referenced external document — its own root `$schema` (or `jsonSchemaDialect`) decides whether `$ref` siblings apply inside it, not the entry document's — and to any schema resource that encloses the referenced fragment, in an external document or the entry document alike. A pointer is resolved against its document, so the referring schema's resource has no say: the dialect resets to the document's before the target's own ancestors are applied. Substitution would otherwise lift the target out of the resource that governed it, so the resource's own `$schema` declaration is written onto the resolved schema — without it a Draft 07 tuple `items: [ … ]` pulled into a 2020-12 document would be read as the single-schema 2020-12 `items` and rejected. It is re-attached verbatim: a `$schema` naming no dialect this package reads — a non-string value, or an unsupported URI — selects none, so nothing inside that resource applies `$ref` siblings or claims a dialect of its own, and the declaration always travels with the target: onto it, onto an `allOf` wrapper when the target is a resource root of its own, and back onto whatever replaced a `{$schema, $ref}` node. The converter then reports it wherever the target is referenced from, not only where the resource itself is. Siblings that carry no validation weight (`summary`, `description`, `title`, `$comment`, `deprecated`, `example`, `examples`, `externalDocs`, `xml`, and any `x-` specification extension) leave the node a plain substitution, so the common `{$ref, description}` and `{$ref, x-…}` shapes keep their old resolved form. The rule is positional: a Reference Object outside a Schema Object position — a Response, Parameter, Path Item, or Example reference — always resolves by substitution. Draft 06/07 require the opposite ("All other properties in a `$ref` object MUST be ignored"), so plain substitution also stays for OpenAPI 3.0 and for any 3.1/3.2 document — or single schema resource — that selects Draft 06/07 through `jsonSchemaDialect` or `$schema`. - **`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. - **Empty Schema Object `{}`** (#478): a `{}` in a schema position — `properties: {x: {}}`, `additionalProperties: {}`, `items: {}`, `not: {}`, `if`/`then`/`else`, `contains`, `propertyNames`, `patternProperties` values, `dependencies` / `dependentSchemas` values, `$defs` values — means "any value" and is normalised to the equivalent boolean schema `true`, because specs are decoded with `json_decode(..., true)` and `{}` is otherwise indistinguishable from `[]`. An empty map-valued keyword (`properties: {}`, `patternProperties: {}`, `dependencies: {}`, `dependentSchemas: {}`, `dependentRequired: {}`, `$defs: {}`, `definitions: {}`) constrains nothing and is dropped. Keyword-value positions keep empty arrays as arrays: `required: []` and `prefixItems: []` validate as written, and `enum: []` / `allOf: []` / `anyOf: []` / `oneOf: []` are rejected by opis as the empty-array forms its drafts forbid — Gesso does not rewrite them into "any value". - **Lowered**: `discriminator` + `mapping` / `defaultMapping` → an `allOf` of `if`/`then` conditionals (default; see `discriminator` below). OpenAPI 3.0 `nullable` is lowered for Draft 07 compatibility. - **Stripped**: `xml`, `externalDocs`, `example` / `examples`, `deprecated`, and OAS-only `nullable`/`readOnly`/`writeOnly` after enforcement (3.0). `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. - **OpenAPI 3.0 compatibility warnings**: `unevaluatedProperties`, `unevaluatedItems`, `dependentSchemas`, and `dependentRequired` still emit `E_USER_WARNING` when placed in a 3.0 Schema Object because its compatibility pipeline targets Draft 07. - **`contentSchema`**: preserved in 3.1/3.2. JSON Schema defines the content vocabulary as annotation-only by default, so it is not treated as a decoded-body assertion unless the validator's content hooks support that media type. - **`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 `` (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. - **OpenAPI 3.2 `defaultMapping`**: with explicit mapping keys, absent and unknown discriminator values validate against the fallback schema. Without explicit keys, only the absent-value fallback can be reconstructed after eager `$ref` resolution; a categorized warning exposes the unknown-value limitation. - **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 The PHPUnit coverage report counts `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, OpenAPI 3.2 `QUERY`, and every custom method declared under `additionalOperations`. Laravel, Symfony, Pest, and the fuzz explorer accept the six named enum methods including `QUERY`; arbitrary custom tokens are supported by the direct validators, PSR-7 adapter, and coverage tracker. Whole-spec exploration enumerates `HEAD`, `OPTIONS`, `TRACE`, and case-sensitive custom methods but records them as explicit skips because it cannot dispatch them through the enum-based generated-case API. Direct validator and PSR-7 adapter calls can still resolve them. ## Spec features not consulted Webhooks (3.1+), Callbacks, Response `Links`, Server URL templating (`servers` with `variables` — never used to match a request path, read only to explain a failed match; see [Server base paths are not stripped automatically](setup.md#server-base-paths-are-not-stripped-automatically)), Examples (`example` / `examples`, including 3.2 `dataValue` / `serializedValue` — not used for fuzzing or validation), 3.2 tag hierarchy (`summary` / `parent` / `kind`), `externalDocs`, and vendor extensions (`x-*` keys, ignored harmlessly). OAuth2 device authorization and other OAuth/OpenID schemes remain on the existing `[security]` warning path. ## Diagnostic channels (`E_USER_WARNING` and `E_USER_DEPRECATED`) The library uses PHP's native `trigger_error(..., E_USER_WARNING)` as the loud-signal channel for silent-pass conditions the validator cannot enforce. **This is the v1.0 official API**: warnings are dedup'd per-process and prefixed with a category tag so callers can route or filter them mechanically. A second channel, `trigger_error(..., E_USER_DEPRECATED)`, carries migration notices for surfaces that a future major removes. It follows the same dedup and category-prefix contract, but uses a distinct error level and a distinct prefix so an error handler can route migration work separately from operational signals. The `Level` column below says which channel each prefix arrives on. | Category prefix | Level | Source | Dedup key | |---|---|---|---| | `[security]` | `E_USER_WARNING` | `SecurityValidator` (`oauth2`, `openIdConnect`, `mutualTLS`, `http-basic`, `http-digest`) | scheme name | | `[OpenAPI Schema]` | `E_USER_WARNING` | `OpenApiSchemaConverter` (3.0-only `unevaluated*` / `dependent*`, unknown / malformed `format`) | per-keyword / per-format-value | | `[OpenAPI 3.2 querystring]` | `E_USER_WARNING` | `QueryParameterValidator` (serialized query media type cannot be reconstructed) | declared media-type set | | `[OpenAPI 3.2 discriminator]` | `E_USER_WARNING` | `OpenApiSchemaConverter` (`defaultMapping` with implicit mappings only) | process-wide limitation key | | `[OpenAPI 3.2 $self]` | `E_USER_WARNING` | `OpenApiSpecLoader` (`$self` base URI is not applied) | spec load/cache | | `[Gesso deprecation]` | `E_USER_DEPRECATED` | `Internal\Deprecations` (every deprecated configuration key, CLI flag, and PHP symbol) | deprecation id | **Not on either channel:** a rename whose old spelling still works writes a plain `[Gesso] WARNING:` line to STDERR instead — going through `E_USER_DEPRECATED` would make a `failOnDeprecation` suite fail for using a name that is still supported. The three `OPENAPI_*` environment variables and the `openapi:routes` / `openapi:stubs` Artisan commands are the current members; see [renamed spellings still accepted](versioning.md#renamed-spellings-still-accepted). **Deprecation notices:** each notice names what to use instead and the version that removes the surface, because the emitter cannot represent one that does not. Under the PHPUnit extension, one summary line is written to STDERR after the run listing every deprecated surface still in use with its call count; nothing is written when the run used none. Under paratest each worker stages its counts in the sidecar and `gesso coverage:merge` writes the one summed line, so the report survives a parallel run. Inside a booted Laravel app the framework's test error handler absorbs the raw `E_USER_DEPRECATED` — `failOnDeprecation` does not trip and the capture recipe below sees nothing — but the summary line is unaffected: it renders recorded counts, not the error channel. See [Deprecations in `UPGRADING.md`](https://github.com/studio-design/gesso/blob/main/UPGRADING.md#deprecations) for the current list and [versioning](versioning.md) for the rule that ties a removal to a prior deprecation. **How to consume:** - **Default** (PHPUnit `failOnWarning="true"`): the first warning fails the test. Recommended for contract-testing pipelines, since silent-pass on auth or unknown formats is the worst-class failure mode. - **Stay green, surface warnings in output**: omit `failOnWarning` (PHPUnit 10+ default is `false`). Warnings show in the test report but do not fail. - **Capture programmatically** (e.g. for a custom report): ```php set_error_handler(static function (int $errno, string $errstr): bool { if ($errno === E_USER_WARNING && str_starts_with($errstr, '[security]')) { MyReport::record($errstr); return true; // suppress } return false; // bubble }); ``` - **Acknowledge a specific unvalidatable security scheme**: for the `[security]` category, prefer the scheme-scoped `acknowledged_unvalidatable_schemes` setting (Laravel config key, PHPUnit extension parameter, `gesso doctor --acknowledge-unvalidatable-scheme`) over an error handler — see [Acknowledging an unvalidatable security scheme](setup.md#acknowledging-an-unvalidatable-security-scheme). Only the named schemes stop warning; an acknowledged name that is absent from the spec, or that the validator can actually enforce, itself warns so the list cannot rot (those rot warnings carry the `[Gesso]` prefix). - **Suppress one category** (e.g. acknowledged limitation in another category): match on the category prefix in your error handler. Do not blanket-suppress all `E_USER_WARNING`s — unrelated warnings would silently disappear. **Why not exceptions / PSR-3 logger / structured payload on `OpenApiValidationResult`?** The simple channel is zero-dep, integrates with every PHP framework's existing error handler, and stays out of the v1.0 SemVer surface. A structured channel (`WarningCollector`, PSR-3 sink, or `result->warnings()`) can be added in v1.x as additive without breaking — we are deliberately deferring until real-world usage demands it. See [issue #149](https://github.com/studio-design/gesso/issues/149) for the design discussion. **Per-process dedup vs per-test:** the dedup state is process-global. PHPUnit runs all tests in one process by default, so a warning fired in test A is not fired again in test B even if both schemas exhibit the issue. The `*::resetWarningStateForTesting()` helpers (annotated `@internal`) exist as test seams for the converter / security validator's own tests; downstream tests rarely need them. --- Source: https://studio-design.github.io/gesso/conformance.md # Conformance Gesso is measured against two corpora nobody here maintains, both pinned by commit SHA in `composer.json` and asserted on every CI run: | Corpus | Question it answers | Result | | --- | --- | --- | | [OAI/learn.openapis.org][learn] | Does Gesso understand the documents the OpenAPI Initiative itself publishes? | 22 of 22 files load without error; one document reports three not-enforced security schemes | | [JSON Schema Test Suite][suite] | What does Gesso's schema rewriting change about a schema's meaning? | 11 of 3,784 verdicts move; each one listed with a reason | Both results are committed as machine-readable baselines, so a number can only move when someone updates the record. ## OpenAPI example documents Corpus: [`OAI/learn.openapis.org`][learn] at `43756549c27cbf84107b190b82c65e0336f2f09f` (CC-BY-4.0) — the example API descriptions published by the OpenAPI Initiative alongside the specification. Every document for OAS 3.0, 3.1, and 3.2 is loaded through `OpenApiSpecLoader` and diagnosed by `gesso doctor`, in **both** its JSON and its YAML form: 11 documents, 22 files. The `examples/v2.0` directory is Swagger 2.0, which this package does not accept by design, and is not measured. | Document | OAS | Operations | Responses | Diagnosis | | --- | --- | ---: | ---: | --- | | `v3.0/api-with-examples` | 3.0.0 | 2 | 4 | clean | | `v3.0/callback-example` | 3.0.0 | 1 | 1 | clean | | `v3.0/link-example` | 3.0.0 | 6 | 6 | clean | | `v3.0/petstore` | 3.0.0 | 3 | 6 | clean | | `v3.0/petstore-expanded` | 3.0.0 | 4 | 8 | clean | | `v3.0/uspto` | 3.0.1 | 3 | 5 | clean | | `v3.1/non-oauth-scopes` | 3.1.0 | 1 | 0 | clean | | `v3.1/tictactoe` | 3.1.0 | 3 | 5 JSON / 6 YAML | `warning` — 3 security schemes reported as not enforced | | `v3.1/webhook-example` | 3.1.0 | 0 | 0 | clean; `webhooks`-only, nothing to enforce | | `v3.2/3.2-query-example` | 3.2.0 | 1 | 1 | clean | | `v3.2/3.2-tags-example` | 3.2.0 | 4 | 0 | clean | Nothing here produces an error: `gesso doctor` exits `0` on all 22 files and reports `status: ok` for ten of the eleven documents. `tictactoe` is the exception at `status: warning`, from three `skipped` diagnostics — its HTTP Basic and OAuth2 schemes are recognized and resolved, then reported as not enforced rather than silently passed. "Clean" in the table above means no issue of any severity. Recorded in `tests/fixtures/compatibility/v1-oas-example-documents.json` and asserted by `tests/Integration/Conformance/OasExampleDocumentTest.php`. Issues are pinned as `severity/category` pairs, not prose: message wording is not a compatibility surface (see [versioning](versioning.md)) and is pinned by the doctor's own unit tests. Four things the table is deliberately not hiding: - **`webhook-example` yields zero operations.** The document describes only `webhooks`, which Gesso deliberately does not consult (see [supported features](supported-features.md#spec-features-not-consulted)). It loads without complaint, and the zero is the honest signal — not a pass. - **`tictactoe` counts 5 responses as JSON and 6 as YAML.** That difference is upstream, not in the pipeline: the YAML form declares a `202` on `POST /board/{row}/{column}` that the JSON form omits. Every other document is diagnosed identically in both serializations, and the test asserts that. - **The YAML forms are copied out of the corpus before they are read.** The corpus ships `petstore.json` and `petstore.yaml` side by side, and the loader resolves a bare spec name to the JSON sibling first — the doctor reports that shadowing as a configuration error rather than diagnosing a file the runtime would not have loaded. Copying the YAML forms into a tree of their own exercises the YAML pipeline for real instead of pinning that diagnostic. - **Three of these documents used to fail.** `webhook-example`, `non-oauth-scopes`, and `3.2-tags-example` omit `paths` or an operation's `responses`, which OAS 3.1 made optional; the doctor reported all three as structure errors until [#479](https://github.com/studio-design/gesso/pull/479) made the check version-aware. That is what this corpus is for. ## JSON Schema conversion delta Gesso rewrites your OpenAPI Schema Objects before validating anything: OAS 3.0 is lowered to Draft 07, `discriminator` becomes `if`/`then` conditionals, `nullable` becomes a type array, annotation keywords are stripped, and `readOnly` / `writeOnly` become boolean subschemas. That rewriting is the part of a contract-testing tool most likely to quietly change what your spec means. This section measures exactly where it does. Of 3,784 official test-suite cases put through both pipelines, conversion changes the verdict on 11, all of them deliberate. Every one is listed with a reason. ### What is measured 3,784 of the 3,824 cases in the [official JSON Schema Test Suite][suite] are validated twice: 1. **bare** — the schema exactly as the suite wrote it, through [opis/json-schema][opis] alone; 2. **converted** — the same schema after `Studio\Gesso\Spec\OpenApiSchemaConverter::convert()`, through the same validator. The remaining 40 are not compared at all. 4 are excluded outright (see below), and 36 use a boolean `true` / `false` root schema, which a Schema Object cannot express — `convert()` has no counterpart to produce, so there is no second verdict to compare against. Both counts are published in the baseline rather than silently dropped. Only the cases where the two verdicts differ are recorded. The recorded set is committed as `tests/fixtures/compatibility/v1-json-schema-conversion-delta.json` and asserted by `tests/Integration/Conformance/JsonSchemaConversionDeltaTest.php` on every CI run. A new entry fails the build; so does a disappeared one. **This does not claim that Gesso is a conformant JSON Schema validator.** That claim belongs to opis, and it is measured independently by [Bowtie][bowtie] via the `php-opis-json-schema` harness — a report Gesso has no hand in producing. What is measured here is the delta Gesso adds on top of opis, because that is the part no external report covers. ### Current result Corpus: [`json-schema-org/JSON-Schema-Test-Suite`][suite] at `cf2e5e0ff2e3d90239c3b59e68ac4c080bd4ac92` (MIT), pinned by commit SHA in `composer.json`. `composer.lock` is not committed for a library, so `composer.json` is the pin. | Suite | OAS pipeline | In corpus | Excluded | Boolean root | Compared | Verdict changed | | --- | --- | ---: | ---: | ---: | ---: | ---: | | `draft7` | 3.0 → Draft 07 | 1,657 | 0 | 18 | 1,639 | 0 | | `draft2020-12` | 3.1 / 3.2 → 2020-12 | 2,167 | 4 | 18 | 2,145 | 11 | | **Total** | | **3,824** | **4** | **36** | **3,784** | **11** | Required and `optional/` cases are both included, `optional/format/` among them. The suite's `remotes/` directory is served at `http://localhost:1234/` as the suite expects, so remote `$ref` cases genuinely resolve instead of failing identically on both sides. OAS 3.2 shares the 3.1 conversion pipeline, so running it would reproduce the 3.1 numbers exactly and it is not run twice. #### The 11 differences Two causes. Each recorded case cites one of them by key in the baseline's `reasons` map. The `draft7` suite converts with no verdict change at all. **`unsupported-dialect` — 9 cases** (`vocabulary.json`, `format-assertion.json`). These declare a custom metaschema URI in `$schema`. `OpenApiSchemaDialect::assertSupported()` rejects unknown dialects with `InvalidOpenApiSpecException` instead of falling back to one with different semantics. Deliberate, and documented in [supported features](supported-features.md#openapi-30-31-and-32). **`stripped-annotation-ref` — 2 cases** (`refOfUnknownKeyword.json`). The schema points `$ref` at `#/examples/0`. The converter strips `examples` as an OAS annotation keyword, so the reference target no longer exists and opis reports an unresolved reference. A spec that references into an `examples` array is unsupported; the outcome is a loud error, never a silent pass. Referencing annotation internals is not something OpenAPI sanctions, so this is documented rather than fixed. #### The 4 exclusions `unevaluatedItems with $dynamicRef` and `unevaluatedProperties with $dynamicRef` (2020-12) are skipped before they run. Bare opis recurses without bound on them and exhausts the stack — the conversion layer is not involved, and the resulting fatal error is not catchable from PHP, so the groups have to be excluded statically. They are listed in the baseline with that reason so the exclusion stays visible instead of silently shrinking the corpus. ## Running it ```bash composer install # fetches both pinned corpora into vendor/ vendor/bin/phpunit tests/Integration/Conformance ``` Both corpora are normal dev dependencies, declared as inline `repositories` entries in `composer.json` because neither is published to Packagist and neither carries usable upstream tags. `composer.lock` is not committed for a library, so `composer.json` is the pin. ## Updating a pinned corpus 1. Bump `dist.url`, `dist.reference`, and `source.reference` in the `composer.json` repository entry to the new commit SHA, and bump the package `version` (Composer will not refresh a package repository unless the version changes). 2. `composer update json-schema-org/json-schema-test-suite` or `composer update oai/learn.openapis.org`. 3. Run the test. It will report the new counts and anything that moved. 4. Update `corpus.commit` and the moved entries in the corresponding baseline, and update the tables above. For the conversion delta, every entry must cite a key in the baseline's `reasons` map. A delta citing an unknown reason, or a published reason no longer cited by anything, fails a second assertion, so an unexplained regression cannot be papered over by regenerating the file. If a newly added JSON Schema group triggers the same unbounded opis recursion, the test process will die with a fatal error rather than fail cleanly. Add the group to `EXCLUDED_GROUPS` in the test and to `excluded_groups` in the baseline. A newly added OpenAPI example document simply appears as an extra entry, and a removed one as a missing entry; both fail until the baseline records the change. [suite]: https://github.com/json-schema-org/JSON-Schema-Test-Suite [learn]: https://github.com/OAI/learn.openapis.org [opis]: https://opis.io/json-schema [bowtie]: https://bowtie.report --- Source: https://studio-design.github.io/gesso/api-reference.md # API Reference - [`OpenApiResponseValidator`](#openapiresponsevalidator) - [`DecodedBody`](#decodedbody) - [`OpenApiPsr7Validator`](#openapipsr7validator) - [`OpenApiResponseExplorer`](#openapiresponseexplorer) - [`OpenApiSpecExplorer`](#openapispecexplorer) - [`OpenApiContractChecks`](#openapicontractchecks) - [`OpenApiSpecLoader`](#openapispecloader) - [`OpenApiCoverageTracker`](#openapicoveragetracker) ## `DecodedBody` Both core validators accept a `DecodedBody` as their body argument. Use it when decoding wire JSON yourself so `{}`, `[]`, literal `null`, and an absent body remain distinct: ```php use Studio\Gesso\DecodedBody; $body = $rawBody === '' ? DecodedBody::absent() : DecodedBody::fromJsonValue(json_decode($rawBody, false, flags: JSON_THROW_ON_ERROR)); ``` `fromJsonValue()` wraps an already-decoded value; it does not parse JSON text. Decode with `associative: false`, including nested objects. The validators then know that an empty array is genuinely `[]`, so it fails `type: object`. Framework and PSR-7 adapters do this for you; decoder provenance is internal. Existing bare PHP arrays and `DecodedBody::present($value)` retain their legacy behavior: a top-level empty array is interpreted as an empty object when the schema explicitly accepts objects. Bare `null` still means an absent body; `DecodedBody::present(null)` and `DecodedBody::fromJsonValue(null)` mean present literal null. `DecodedBody::fromLegacy()` preserves an existing envelope. ## `OpenApiResponseValidator` Main validator class. Validates a response body against the spec. The constructor requires a `StrictRequiredTracker` that receives successful response-body observations. Reuse one tracker for validators that belong to the same test run. The optional `maxErrors` parameter (default: `20`) 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. 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 $tracker = new StrictRequiredTracker(); $validator = new OpenApiResponseValidator($tracker, maxErrors: 20); $result = $validator->validate( specName: 'front', method: 'GET', requestPath: '/api/v1/pets/123', statusCode: 200, responseBody: ['id' => 123, 'name' => 'Fido'], responseContentType: 'application/json', ); $result->outcome(); // OpenApiValidationOutcome (Success | Failure | Skipped) $result->isValid(); // bool (true for both successes AND skipped results) $result->isSkipped(); // bool (true when the status code matched skip_response_codes) $result->errors(); // string[] $result->errorMessage(); // string (joined errors) $result->issues(); // list (structured view of errors()) $result->matchedPath(); // ?string (e.g., '/v1/pets/{petId}') $result->skipReason(); // ?string (non-null when skipped) ``` `issues()` mirrors `errors()` one-to-one: each `ValidationIssue` carries the same `message` plus a stable `category` slug (e.g. `request.security`, `response.body` — the full list is in [versioning.md](versioning.md#whats-covered-by-semver)) and the operation context the validator resolved (`method`, `path`, `statusCode`, `contentType`). A context field is `null` when that dimension does not apply or was not resolved — request-side issues never carry a `statusCode`, and `contentType` is set only on body issues and on the `response.content_type` note (the resolved spec media-type key in both cases). Assert on `category` and context instead of message wording — the prose is not a compatibility surface. On schema violations, `instancePath` carries the RFC 6901 JSON Pointer (`''` = document root — the `message` prefix renders it as `[/]` for historical reasons) and `keyword` the failing JSON Schema keyword (`type`, `required`, …): for `request.body` / `response.body` issues the pointer is into the validated body, for parameter / response-header issues into the named value. `keyword` also carries synthetic violation kinds — `required` when a required parameter, header, or security credential is missing, `format` when a credential is present but not usable, and `parse` when the PSR-7 adapter could not read or JSON-decode a body. Both stay `null` for structural and spec-malformation errors, including non-schema body errors such as a missing required body. Results built by code that predates the structured API derive issues with category `unknown`. To hand the full result to machine consumers (CI ingestion, IDE annotations), render it as a versioned JSON document: ```php use Studio\Gesso\JsonValidationResultRenderer; $json = JsonValidationResultRenderer::render($result); // Optionally embed a (pre-redacted) reproduction command: $json = JsonValidationResultRenderer::render($result, $curlCommand); ``` The document shape (`schema_version` 1) is a compatibility surface — see [validation-json-schema.md](validation-json-schema.md). Framework adapters (Laravel, Symfony, Pest, PSR-7) can emit the same document in their assertion failure messages instead of the plain text shape. Select the mode process-wide with the `GESSO_VALIDATION_FORMAT` environment variable (`text` | `json`), programmatically, or via the PHPUnit extension's `validation_output` parameter — the environment variable wins when set: ```php use Studio\Gesso\ValidationOutput; use Studio\Gesso\ValidationOutputFormat; ValidationOutput::use(ValidationOutputFormat::Json); // e.g. in tests/bootstrap.php ValidationOutput::reset(); // restore the text default ``` See [validation-json-schema.md](validation-json-schema.md#selecting-json-failure-output-in-adapters) for the failure message shape and resolution rules. Prefer `outcome()` when you need to distinguish all three states explicitly — PHPStan enforces `match` exhaustiveness, so adding a future outcome cannot silently slip past a caller: ```php use PHPUnit\Framework\AssertionFailedError; use Studio\Gesso\OpenApiValidationOutcome; match ($result->outcome()) { OpenApiValidationOutcome::Success => null, // schema matched OpenApiValidationOutcome::Failure => throw new AssertionFailedError($result->errorMessage()), OpenApiValidationOutcome::Skipped => logger()->info('skipped', ['reason' => $result->skipReason()]), }; ``` ## `OpenApiPsr7Validator` Adapts PSR-7 messages to the request and response validators and records the same coverage as framework integrations: ```php use Studio\Gesso\Psr7\OpenApiPsr7Validator; $validator = new OpenApiPsr7Validator('front'); $exchange = $validator->validateExchange($request, $response); $requestResult = $exchange->requestResult(); $responseResult = $exchange->responseResult(); ``` Use `validateRequest()`, `validateResponse()`, or `validateResponseForOperation()` when only one side is available. See the [PSR-7 guide](psr7.md) for PHPUnit assertions, stream handling, and a PSR-15 test integration recipe. ## `OpenApiResponseExplorer` Generates deterministic, branch-complete valid payloads for one response schema and returns a non-empty `GeneratedResponseCases` collection: ```php use Studio\Gesso\Fuzz\GeneratedResponseCase; use Studio\Gesso\Fuzz\OpenApiResponseExplorer; $cases = OpenApiResponseExplorer::explore( specName: 'front', method: 'POST', path: '/oauth/introspect', status: 200, contentType: 'application/json', // optional; first JSON type when null seed: 1, extraCases: 2, ); $cases->each(function (GeneratedResponseCase $case): void { $decoded = sdk_decode($case->bodyAsObject()); $case->assertRoundTrip(sdk_encode($decoded)); }); ``` To exercise an SDK model by its exact, case-sensitive `components.schemas` name without inventing an operation, use the same facade: ```php $cases = OpenApiResponseExplorer::exploreComponent( specName: 'front', schemaName: 'IntrospectResponse', seed: 1, extraCases: 2, ); $cases->each(function (GeneratedResponseCase $case): void { $decoded = sdk_decode($case->bodyAsObject()); $case->assertRoundTrip(sdk_encode($decoded)); }); ``` For explicit mappings across every selected operation response, build a spec-wide plan: ```php $summary = OpenApiResponseExplorer::exploreSpec('front', seed: 1) ->includeTags(['public']) ->mapResponse( 'introspect', 200, fn (GeneratedResponseCase $case) => sdk_decode($case->bodyAsObject()), fn (mixed $model) => sdk_encode($model), ) ->failOnUnmapped() ->assertRoundTrips(); ``` `OpenApiResponseSpecExploration` exposes the same tag, method, path, operation-ID, and deprecated filters as `OpenApiSpecExploration`. `mapResponse()` accepts an exact status, an OpenAPI range key, or `default`. The returned `ResponseSpecExplorationSummary` contains execution counts, executed `ExploredOperation` rows, categorized `decodeFailures` and `roundTripFailures`, and `ResponseSpecExplorationSkip` rows. Callback failures produce one aggregate assertion after all cases run; each `ResponseSpecExplorationFailure` preserves the original throwable and replay metadata. Mapping gaps are skips unless `failOnUnmapped()` is enabled. Named components are converted with the spec's OpenAPI version and JSON Schema dialect, response-side read/write semantics, and discriminator context. Their cases have `null` `status` and `contentType` because no operation was selected; `replaySnippet()` renders the corresponding `exploreComponent()` call. Unknown, malformed, recursive, and otherwise unsupported schemas throw loudly instead of returning an empty collection. The collection is `Countable` and `IteratorAggregate`. Each readonly case exposes `body`, nullable `status` and `contentType`, `seed`, `caseIndex`, and `pinnedBranch`; `bodyAsObject()` supplies decoded JSON shapes to SDKs, `bodyAsArray()` is a lossy compatibility view for array-typed consumers (empty objects become arrays, including nested ones), and `replaySnippet()` renders a focused reproduction. `assertRoundTrip()` first validates the SDK output against the exact converted response schema, then requires all generated object keys and values to survive recursively; JSON lists compare exactly. The case count is derived from reachable schema choice points, not supplied by the caller. An omitted `seed` uses `0`, and `extraCases` appends deterministic rotation-only cases. No-content, non-JSON-only, schema-less, and OpenAPI 3.2 `itemSchema` responses throw a loud `InvalidArgumentException` carrying the structured resolver outcome. Laravel's `ExploresOpenApiEndpoint::exploreResponseSchema()` resolves the configured spec and delegates to the same facade; `exploreResponseSpec()` returns the spec-wide plan. See the [SDK round-trip guide](sdk-roundtrip.md). ## `OpenApiSpecExplorer` Builds a deterministic whole-spec exploration plan around the existing single-operation generator: ```php use Studio\Gesso\Fuzz\OpenApiSpecExplorer; $summary = OpenApiSpecExplorer::explore('front', casesPerOperation: 20, seed: 1) ->includeTags(['public']) ->excludeOperations(['admin.users.destroy']) ->dispatchUsing(fn ($case, $operation) => dispatch_request($case)) ->assertResponseUsing(fn ($response) => assert_contract($response)) ->assertResponses(); ``` Filters are available for tags, methods, paths, operation IDs, and deprecated operations. `authenticateUsing()`, `setUpUsing()`, `tearDownUsing()`, and `mutateCasesUsing()` provide framework/auth/stateful-ID hooks. The returned `SpecExplorationSummary` exposes executed operation/case counts, the executed `ExploredOperation` rows (including their coverage keys), and a list of `ExplorationSkip` entries. See [schema-driven request fuzzing](fuzzing.md). Each `ExploredCase` exposes `bodyAsJson(): string` for raw HTTP content without losing empty or nested objects, arrays, scalars, or numeric object keys. Encoding errors throw `JsonException`; a null value encodes as `'null'`, so omit content separately for no-body operations or missing-body probes. `bodyAsArray()` remains a lossy compatibility view, not the recommended HTTP dispatch path. Call `negativeCases([4])` to switch a whole-spec plan to targeted invalid inputs and carry explicit expected response classes on each `ExploredCase`. For one operation, use `OpenApiEndpointExplorer::exploreInvalid(..., expectedStatusClasses: [4])` or Laravel's `exploreInvalidEndpoint()`. `FailureReducer::reduce()` minimizes a failing body while preserving a caller-defined failure classification. ## `OpenApiContractChecks` Builds a plan of named negative contract checks — probes for protocol-level holes schema validation cannot see: | `ContractCheck` case | Probe | Default pass statuses | |---|---|---| | `IgnoredAuth` | the valid request without credentials, then with invalid ones | `401`, `403` | | `MissingRequiredHeader` | the valid request with one `required: true` header omitted, gated behind a 2xx unmutated control request; state-changing methods are skipped unless the dispatcher is registered with `dispatchIsolatedUsing()` | any `4xx` | | `UnsupportedMethod` | one deterministically chosen undocumented method per documented path | `405` | ```php use Studio\Gesso\Contract\ContractCheck; use Studio\Gesso\Contract\OpenApiContractChecks; $summary = OpenApiContractChecks::run('front', seed: 7) ->checks([ContractCheck::IgnoredAuth, ContractCheck::UnsupportedMethod]) ->includeTags(['public']) ->expectedStatuses(ContractCheck::UnsupportedMethod, [405, 404]) // optional override ->expectedStatusClasses(ContractCheck::IgnoredAuth, [4]) // optional override ->dispatchUsing(fn ($case) => dispatch_request($case)) ->report(); self::assertSame([], $summary->failures, $summary->describeFailures()); ``` The plan shares the exploration filter set (tags, methods, paths, operation IDs, deprecated) and the `authenticateUsing()` / `setUpUsing()` / `tearDownUsing()` hooks — except that `IgnoredAuth` probes bypass `authenticateUsing()` by design. Either `expectedStatuses()` or `expectedStatusClasses()` replaces a check's whole default expectation. `dispatchUsing()` may return an `int`, a PSR-7 response, or any object exposing `getStatusCode(): int`; `dispatchIsolatedUsing()` is the same registration plus the caller's promise that every dispatch is state-isolated, which `MissingRequiredHeader` requires before probing a state-changing method. Probes never throw on a status mismatch — the returned `ContractCheckSummary` collects every `ContractCheckFailure` (naming the check, operation, dispatched mutation, and a replayable curl command) and every explained `ContractCheckSkip`, plus `probedPaths` / `dispatchedProbes` counts. See [named contract checks](contract-checks.md). ## `OpenApiSpecLoader` Manages spec loading and configuration. ```php OpenApiSpecLoader::configure('/path/to/bundled/specs', ['/api']); $spec = OpenApiSpecLoader::load('front'); OpenApiSpecLoader::reset(); // For testing ``` ## `OpenApiCoverageTracker` Tracks which endpoints have been exercised, at `(method, path, statusCode, contentType)` granularity. `OpenApiRequestValidator` and `OpenApiResponseValidator` record automatically for every result whose path matched the spec, so every entry point — the framework traits, the PSR-7 adapter, and direct validator calls — feeds the tracker without an explicit call. Manual recording remains available for observations that never went through a validator, and every `recordRequest()` / `recordResponse()` call counts as its own observation: the arguments carry no exchange identity, so the tracker never guesses that a manual call refers to an exchange a validator already recorded. Suites still carrying the pre-2.6 quickstart's manual `recordResponse()` after `validate()` therefore report two hits for that exchange — delete the manual call; `validate()` already records. (`hits` is display-and-export only: covered/skipped states and coverage gates are unaffected either way.) ```php use Studio\Gesso\Coverage\OpenApiCoverageTracker; // Request-side: an endpoint was reached without a response assertion OpenApiCoverageTracker::recordRequest('front', 'GET', '/v1/pets'); // Response-side: full granularity (status + content-type spec keys) OpenApiCoverageTracker::recordResponse( specName: 'front', method: 'GET', path: '/v1/pets', statusKey: '200', // spec key, or literal status when skipped contentTypeKey: 'application/json',// spec key (case preserved); null → "*" schemaValidated: true, // false → state=skipped skipReason: null, ); $coverage = OpenApiCoverageTracker::computeCoverage('front'); // [ // 'endpoints' => [...per-endpoint EndpointSummary, includes per-response sub-rows...], // 'endpointTotal' => 45, // 'endpointFullyCovered' => 12, // 'endpointPartial' => 8, // 'endpointUncovered' => 25, // 'responseTotal' => 120, // 'responseCovered' => 38, // 'responseSkipped' => 4, // 'responseUncovered' => 78, // ... // ] ``` `OpenApiCoverageTracker::current()->hasAnyCoverageOn(spec): bool` is a fast presence check; the static `hasAnyCoverage(spec)` wrapper is deprecated since 2.7 and removed in 3.0. `getCovered()` is retained as a diagnostic shim returning `array>`. See [CHANGELOG.md](https://github.com/studio-design/gesso/blob/main/CHANGELOG.md) for the migration from the pre-#111 endpoint-level shape. --- Source: https://studio-design.github.io/gesso/adr/index.md # Architecture decision records Decisions that shape what Gesso is, rather than how one part of it works. A change that contradicts an accepted ADR needs a new ADR that supersedes it, not just a pull request. | ADR | Status | Date | Decision | | --- | --- | --- | --- | | [0001 — Align the v2 technical identity with Gesso](0001-gesso-v2-identity.md) | Accepted | 2026-07-13 | Namespace, package, and CLI rename to the Gesso identity, with the scope boundaries the rename did not authorize. | | [0002 — Phase Arazzo workflow execution behind shared runtime expressions](0002-arazzo-workflow-execution.md) | Accepted | 2026-07-30 | Arazzo support lands in phases, starting from a runtime-expression evaluator shared with the existing validators. | | [0003 — Branch-complete response payloads and an SDK round-trip harness](0003-sdk-roundtrip-harness.md) | Accepted | 2026-07-31 | Close the SDK ⇄ spec gap by feeding spec-derived payloads through a generated decoder. | | [0004 — v3 consistency policy and protected core](0004-v3-consistency-policy-and-protected-core.md) | Accepted, amended 2026-08-12 | 2026-08-06 | v3 changes shape, not capability: the invariants that may not regress, the inclusion criterion and its non-goals, and the rule a reduction PR is held to. Amendments: the #508 exception; the sequencing rule that the final v2 minors carry the adopter-visible value while v3.0 deletes; and the condition — not a date — that opens the v3.0 window. | | [0005 — v3 configuration and CLI naming](0005-v3-configuration-and-cli-naming.md) | Accepted | 2026-08-06 | Resolves the naming conflicts between the four v3 rename issues, and fixes the `gesso.php` key set and the `coverage:merge` flag set they all target. | | [0006 — Server base paths and request-path matching](0006-server-base-paths-and-request-path-matching.md) | Accepted | 2026-08-07 | `servers[].url` stays out of path matching; `strip_prefixes` remains authoritative, and a failed match names the server base path that would have matched. | ## Writing one Follow the most recent file. The header carries `Status`, `Date`, `Issue`, and `Related`; 0004 adds `Supersedes`, which every later ADR should carry even when the value is `none`. Number files sequentially and add the row above in the same change, so this page never lags the directory. --- Source: https://studio-design.github.io/gesso/versioning.md # Versioning and support policy This library follows [Semantic Versioning 2.0](https://semver.org/). v1.0.0 is the API stability commitment: anything not marked `@internal` in v1.0.0 is covered by SemVer for the entire v1.x line. - [What's covered by SemVer](#whats-covered-by-semver) - [What's NOT covered by SemVer](#whats-not-covered-by-semver) - [Deprecation policy](#deprecation-policy) - [Support policy](#support-policy) - [v2 maintenance lifecycle](#v2-maintenance-lifecycle) - [v1 maintenance lifecycle](#v1-maintenance-lifecycle) - [Release checklist](#release-checklist) ## What's covered by SemVer - Public class names and namespaces (anything not marked `@internal`) - Public method signatures (parameters, return types, visibility) - Public constants and their values - Every non-`@internal` member composed by a public trait, including protected and private methods, properties, and constants - Enum cases (additions are minor; removals or renames are major) - The `OpenApiValidationResult` shape (`outcome()`, `errors()`, `issues()`, `matchedPath()`, `skipReason()`, `isValid()`, `isSkipped()`) - The `ValidationIssue` shape and its `category` slugs (`request.spec`, `request.path_match`, `request.method`, `request.parameter.path`, `request.parameter.query`, `request.parameter.header`, `request.security`, `request.body`, `response.spec`, `response.request_context`, `response.status`, `response.body`, `response.header`, `response.content_type`, and the legacy fallback `unknown`). New categories may be added in minor releases; renaming or removing one is major. `instancePath` / `keyword` are populated on schema violations — for body issues the pointer is into the validated body, for parameter / response-header issues into the named value — and `keyword` additionally carries synthetic violation kinds (`required` for a missing required parameter / header / credential, `format` for a present but unusable credential); both are `null` for structural and spec-malformation errors. New `keyword` values may appear in minor releases. `message` remains explicitly outside the contract (see below). - The validation JSON document rendered by `JsonValidationResultRenderer` (`schema_version` 1) — see [validation-json-schema.md](validation-json-schema.md) for the field-level rules (additions are minor, removals/renames/type changes are major and bump `schema_version`). - The validation failure output selection: the `ValidationOutputFormat` enum (`text` | `json`), the `ValidationOutput` methods (`format()`, `use()`, `reset()`), the `GESSO_VALIDATION_FORMAT` environment variable, and the json-mode failure shape (one header line followed by the versioned JSON document; for the PSR-7 exchange assertion, one `[request]` / `[response]` labelled document per failing side). - The violation baseline file (`baseline_version` 1): the entry fields (`spec`, `method`, `path`, `status_code`, `content_type`, `category`, `parameter`, `instance_path`, `keyword`), the fingerprint composition those fields encode (the human-readable message is deliberately excluded; fixed HTTP methods are normalized to uppercase while OpenAPI 3.2 custom `additionalOperations` methods stay case-sensitive; numeric `instance_path` segments are canonicalized to `*`; non-body issues are distinguished by the parameter / response-header / security-scheme name in `parameter`, and parameter / response-header schema violations additionally by `keyword` and `instance_path` — missing required parameters and headers carry `keyword: required`, security-scheme satisfaction failures carry `keyword: required` (credentials absent) or `keyword: format` (present but unusable), and adapter body-decode failures (unparseable JSON, unreadable stream) carry `keyword: parse` so they stay distinct from a genuinely empty body), the `GESSO_BASELINE_GENERATE` environment variable, and the `baseline_file` / `baseline_stale` extension parameters (`baseline_stale` values: `off` | `note` | `fail`, default `note`). The enforcement semantics are part of the contract too: with `baseline_file` configured, a failing assertion is suppressed only when **every** one of its violations is baselined, and entries that no longer occur during a full run are reported as stale per `baseline_stale`. Unknown `baseline_version` values are rejected. - The coverage baseline file (`coverage_baseline_version` 1): the `uncovered_responses` entry fields (`spec`, `method`, `path`, `status`, `content_type`) and what they identify — one declared response the run did not validate, at `(method, path, status, content-type)` granularity, where `status` is the spec response key and `content_type` is `*` for responses without a `content` block; fixed HTTP methods normalize to uppercase while OpenAPI 3.2 custom `additionalOperations` methods stay case-sensitive. The `coverage_baseline_file` / `coverage_baseline_stale` extension parameters (same values as `baseline_stale`), the matching `--coverage-baseline-file` / `--coverage-baseline-stale` merge flags, and the shared `GESSO_BASELINE_GENERATE` generation switch are covered too. The enforcement semantics are part of the contract: responses that were not validated — including responses skipped by `skip_response_codes` — are baselined, an uncovered response absent from the file fails the run, and entries that are covered now are reported per the stale mode. Unknown `coverage_baseline_version` values are rejected. - Named contract checks (`Studio\Gesso\Contract\`; the pre-2.7 `Studio\Gesso\Fuzz\` spellings are deprecated aliases removed in v3.0): `ContractCheck` names and default expected statuses and status classes, the `OpenApiContractChecks`/`ContractCheckPlan` fluent surface, and the `ContractCheckSummary`/`ContractCheckFailure`/`ContractCheckSkip` shapes. Failure/skip prose is not a contract, and neither is the placeholder value an `ignored_auth` invalid-credential probe writes. - CLI surfaces by major (commands, flags, exit codes, and versioned inputs and output where applicable): - v1.x: `bin/openapi-contract`, `bin/openapi-coverage-merge`, and the v1.10 `bin/gesso` entry point - v2.x: the `doctor`, `coverage:merge`, `coverage:gate`, and `stubs` subcommands of `bin/gesso`; the legacy standalone binaries are not shipped - The Laravel `gesso:routes` and `gesso:stubs` command surfaces (flags, exit codes, and versioned JSON output) - The four environment variables Gesso owns — `GESSO_VALIDATION_FORMAT`, `GESSO_CONSOLE_OUTPUT`, `GESSO_BASELINE_GENERATE`, and `GESSO_SIDECAR_TOKEN` — and the values each accepts. `TEST_TOKEN` and `GITHUB_STEP_SUMMARY` are read but belong to paratest and GitHub Actions; they are not Gesso's to version. - The `OpenApiCoverageExtension` PHPUnit configuration parameters (`spec_base_path`, `strip_prefixes`, `specs`, `output_file`, `console_output`, `validation_output`, `baseline_file`, `coverage_baseline_file`, `strict_required`, `strict_additional_properties`, `strict_additional_properties_per_call`, …) - The shared `gesso.php` keys and their types, the PHPUnit `config` parameter, and the Laravel `gesso` config keys documented in [Shared configuration](configuration.md). The additive v2 bridge preserves existing inputs and their precedence; their removal requires a major release and the deprecation process below. - The Laravel `ValidatesOpenApiSchema` trait's public methods - The category prefixes used in `E_USER_WARNING` messages (`[security]`, `[OpenAPI Schema]`, and the `[OpenAPI 3.2 ...]` categories) and in `E_USER_DEPRECATED` messages (`[Gesso deprecation]`). Adding a category is a minor change; renaming or removing one is major. ### Versioned sidecar compatibility The PHP methods that produce and consume sidecar state (`exportState()` and `importState()`) are `@internal`; their signatures are not a public PHP API. The persisted payloads accepted by the released merge CLI are nevertheless a compatibility surface because workers and the merge step can run different installed versions during an upgrade. The v1.9 writer emits a sidecar envelope with `envelopeVersion: 2`, containing coverage state `version: 1` and strict-required state `version: 2`. The v1.9 reader accepts that envelope and the legacy bare coverage state `version: 1`. It recognises `part-*.json` sidecars and `failed-*.json` failure markers. The current writer emits `envelopeVersion: 10`. `envelopeVersion` and `coverage` are required; every other key is optional, and its absence means the writer had no such tracker. A new tracker half is a new optional key, not a new envelope version. | Key | Inner version owned by | | --- | --- | | `coverage` (required) | coverage state `version: 1` | | `strictRequired` | strict-required state `version: 2` | | `strictAdditionalProperties` | strict-additional-properties state `version: 1` | | `sdkExercise` | SDK exercise state `version: 1` | | `deprecations` | deprecation state `version: 1` | | `baseline` | violation-baseline document `baseline_version: 1` (generation runs only) | The 2.x reader also accepts the legacy envelopes 2–9 written by Gesso 1.9–2.6 workers, and the legacy bare coverage state. In those envelopes the version number enumerated the halves: 3/5/7/9 carry `baseline` and their even sibling must not; 4–9 carry strict-additional-properties state, 6–9 SDK exercise state, and 8/9 deprecation state. Those rules still apply to legacy inputs, and a half that a legacy version does not carry reads as absent. Absence is reported, never treated as "this worker recorded none". The merge says its deprecation count is a lower bound when a sidecar has no deprecation half; `coverage:merge --baseline-file` refuses to write a union when any sidecar lacks the baseline half; a strict parallel SDK exercise threshold rejects any worker sidecar without the SDK half, while warn-only SDK gating reports the missing-worker count and evaluates the available observations. `--strict-additional-properties=fail` fails loudly when any worker lacks that state, or when no worker exported an evaluation, instead of treating an incomplete/old-fleet merge as clean. Unknown envelope versions and unknown inner coverage, strict, baseline, or SDK tracker versions are rejected; they are never guessed or partially imported. The compatibility rules are: - A newer merge reader keeps support for the explicitly documented older payloads within the same major line. - A format owner bumps its version for an incompatible shape change. Envelope, coverage state, strict-required state, strict-additional-properties state, and SDK exercise state versions evolve independently. - An older reader is not required to accept payloads written by a future version. Unknown versions and unrecognised shapes fail loudly instead of being guessed or partially merged. - A legacy payload may be rejected when accepting it would silently lose data. In particular, strict-required state `version: 1` is not accepted by the v1.9 reader because it cannot represent nested pointer observations from `version: 2`. For the Gesso v2 migration, the v2 merge reader will retain the v1.9 inputs listed above. Any later major-version cutoff must be called out in that version's upgrade guide; it must not appear as an unversioned shape change. These rules apply to the worker-to-merge protocol only. The coverage report written by `json_output` has its own `schema_version` contract documented in [`coverage-json-schema.md`](coverage-json-schema.md). ## What's NOT covered by SemVer - Anything marked `@internal` — including the `Internal\` and `Validation\Support\` namespaces, the per-validator helpers under `Validation\Request\` / `Validation\Response\`, `Spec\OpenApiSchemaConverter` / `Spec\OpenApiPathMatcher` / `Spec\OpenApiRefResolver` / `Spec\OpenApiPathSuggester`, the PHPUnit `CoverageReportSubscriber`, `Cli\DoctorCommand`, `Cli\CoverageGateCommand`, `Cli\StubsCommand`, the `Stubs\` namespace, `Coverage\CoverageMergeCommand` (the `gesso doctor`, `gesso coverage:gate`, `gesso coverage:merge`, and `gesso stubs` CLI surfaces remain covered — these classes are their implementation details), and test seams (`*::resetWarningStateForTesting()`, `OpenApiSpecLoader::reset()`, `OpenApiCoverageTracker::reset()` / `exportState()` / `importState()`). - Validator error message wording (we may improve them; assert on presence/category, not on exact strings). - The text-mode assertion failure layout — the header line, the error list, and the trailing `Reproduce:` curl line (including its redaction details). Machine consumers should select the json output mode, whose failure shape and document schema are covered above. - The set of `format` keywords delegated to opis — we follow opis upstream, so a new format is added when opis adds it. - Behaviour of bug-fix releases that close a documented silent-pass case. A test that passed only because of the silent pass may start failing — that's the fix doing its job, not a SemVer break. `@internal` is enforced statically. Our CI runs PHPStan (2.1.13 or newer; `composer.json` pins an exact version that Renovate bumps) with the `bleedingEdge` ruleset enabled so that `new.internalClass` / `method.internalClass` / `staticMethod.internalClass` / `return.internalClass` / `parameter.internalClass` / `classConstant.internalClass` / `catch.internalClass` violations fail the build. The boundary is the **root namespace** — any code outside `Studio\` that instantiates, calls, type-hints against, or accesses constants on an `@internal` symbol will surface as a PHPStan error. Downstream consumers who enable bleedingEdge in their own PHPStan setup get the same enforcement automatically. Inheritance (`extends`/`implements`) of `@internal` classes is **not** enforced by these rules — that ships under a separate bleedingEdge rule we have not opted into yet. The `bin/gesso` CLI script is the only place inside this repository that crosses the boundary by design (it lives in the global namespace and instantiates `Cli\DoctorCommand` and `Coverage\CoverageMergeCommand`); it is excluded from PHPStan's `paths` so it does not pollute the analysis. See [UPGRADING.md](https://github.com/studio-design/gesso/blob/main/UPGRADING.md) for migration notes between versions. ## Deprecation policy SemVer requires a minor bump when public API is deprecated and a major bump for its incompatible removal. This project adds one standing rule on top: > A surface covered by this document is removed or renamed in a major release > only if a deprecation for it shipped in a minor release of the preceding > major. The last minor of a major is therefore the last chance to deprecate > anything for the next one. The rule covers every surface listed under "What's covered by SemVer" — not just PHP symbols, but configuration keys, CLI flags, environment variables, and Artisan command names. It does not apply to `@internal` symbols or to anything in the "NOT covered by SemVer" list. Deprecations are announced at runtime, not only in a docblock. Each one emits a one-shot `E_USER_DEPRECATED` carrying the `[Gesso deprecation]` prefix, the replacement, and the removal version; the PHPUnit extension writes a single end-of-run STDERR line counting how many deprecated surfaces the suite still uses. Under paratest that line comes from `gesso coverage:merge`, summed over the worker sidecars. See [diagnostic channels](supported-features.md#diagnostic-channels-e_user_warning-and-e_user_deprecated). **If a run on the final minor of a major reports zero Gesso deprecations, upgrading to the next major requires no consumer code, configuration, or CLI changes.** That is what the rule buys: the deprecation report, not the upgrade guide, is the authoritative answer to "am I ready?". For the v2 → v3 transition, the deprecation-carrying release is the final v2 minor. Its number is not chosen by hand — release-please derives it from Conventional Commit types — so the constraint is an ordering one: every v2 release must ship before the first breaking commit reaches `main`, because that commit turns the pending release into `3.0.0` and no further v2 release can be cut from the branch. ### The rename checklist Two fixtures carry the v2 → v3 transition, and they are checked from opposite directions because each one is blind to the other's failure mode. | Fixture | Scanned from | Catches | | --- | --- | --- | | `tests/fixtures/compatibility/v2-deprecations.json` | `src/` and `bin/`, by `DeprecationRegistryTest` | a `Deprecations::notice()` call nobody registered, and a notice re-pointed, re-dated, or moved to another surface without the ledger saying so | | `tests/fixtures/compatibility/v3-renames.json` | [ADR 0005](adr/0005-v3-configuration-and-cli-naming.md), by `V3RenameRegistryTest` | a rename that ships no notice at all | The first cannot see the second. A rename that simply never calls `Deprecations::notice()` emits nothing, so the scan finds nothing and the registry stays as correct — and as empty — as it was. `Deprecations` is the only place allowed to put a notice on the `E_USER_DEPRECATED` channel, and that is enforced rather than asked for: a `trigger_error(..., E_USER_DEPRECATED)` — or `user_error()`, PHP's own alias — or a `#[\Deprecated]` attribute anywhere in `src/` or `bin/` fails the scan, because it announces a removal the registry has no row for. The severity is read as a value, in every base PHP writes one, so `0x4000` is the same thing spelled differently; only the four severities `trigger_error()` accepts can answer "not the deprecation channel", and anything else counts as the channel rather than being waved through. The one exception — the Laravel adapter's contradictory-intent warning, which rides the channel so PHPUnit counts it and announces no removal — is listed by name and by call count in `DeprecationRegistryTest`. Reaching the emitter in a way the scan cannot read fails for the same reason: call it by name. A literal `Deprecations::notice(...)` is the only form the scan can attach a registry entry to, so a computed class, a computed member (`::{'notice'}`, `::${'notice'}`, `::$method(`), a `::class` callable, and a string naming the emitter are all reported instead. A string is compared as PHP builds it rather than as it is typed — quoted, heredoc, or nowdoc, each with the escapes it applies — so `\x53tudio\Gesso\…` is that name too. Each registry entry carries the notice twice: once under `notice`, as the three prose arguments the call passes, compared to `src/` argument by argument; and once as the bare `spelling` and `v3_target` the tests compare by equality. Prose alone cannot carry the link, because one sentence can name two spellings and an id then points at whichever of them the reader assumes. **Changing the wording, the target, or the removal version of a notice is therefore a change to this fixture too** — which is the point, since the fixture is the list a major deletes from. `v3-renames.json` therefore starts from the ADR: it lists every old spelling ADR 0005's two tables name, the v3 spelling that replaces it, the channel it uses, and the deprecation id once one is staged. Each replacement is compared byte-for-byte against the right of the matching `→` in the ADR, which is why a row replacing several spellings at once writes one `→` per spelling — otherwise the fixture could name the wrong member of the right key. **A PR that renames a configuration key or a CLI flag updates this fixture in the same change.** Its `unstaged_count` is a ratchet — staging a deprecation lowers it, and the test fails in both directions, so neither an unstaged addition nor a stale number can sit unnoticed. Zero means every v3 rename has shipped its notice and the final v2 minor can be cut. Two numbers bounding the fixture live in `V3RenameRegistryTest` rather than in the fixture: how many spellings ADR 0005 names, and the highest `unstaged_count` allowed. A number a file keeps about itself bounds nothing — lowering either is progress and costs one line, raising one is a decision and shows up in the diff of a test. `owner` is derived, not declared: ADR 0005's rule 4 gives each name one issue, and the test applies its own division of labour — the environment variables and Artisan commands to #504, the CLI to #507, the keys that collapse several v2 settings to #502, the rest of the key set to #501. A row that the rule would get wrong names its issue inline instead, the way the removals carry `(#508)`. The three channels a spelling can take: - **`deprecation`** — the spelling stops working. `E_USER_DEPRECATED` through `Studio\Gesso\Internal\Deprecations`, with an entry in `v2-deprecations.json`. - **`accepted-spelling`** — the spelling keeps working. The `[Gesso]` warning channel through `Studio\Gesso\Internal\LegacyIdentity`, described below. - **`unchanged-spelling`** — nothing is removed; listed only so the gate can account for every ADR row. This is the one channel that stages nothing and counts toward nothing, so membership is a hand-written list in `V3RenameRegistryTest`, not a property of the entry. Keeping a *name* is not enough: `baseline_stale` and the two `--strict-*` flags keep theirs while replacing the value they accept, which is a removal to anyone who wrote the old value down, so they take the `deprecation` channel. ### Renamed spellings still accepted A rename that keeps the old spelling working is not a removal, so it does not go through the `E_USER_DEPRECATED` channel above: a suite running PHPUnit's `failOnDeprecation` would fail for spelling a name that still works. These names warn once per process on STDERR instead, under the `[Gesso]` prefix, and each warning names its replacement and its removal version. | Accepted spelling | Use instead | Accepted through | Removed in | | --- | --- | --- | --- | | `OPENAPI_VALIDATION_OUTPUT` | `GESSO_VALIDATION_FORMAT` | v3.x | v4.0 | | `OPENAPI_CONSOLE_OUTPUT` | `GESSO_CONSOLE_OUTPUT` | v3.x | v4.0 | | `OPENAPI_BASELINE_GENERATE` | `GESSO_BASELINE_GENERATE` | v3.x | v4.0 | | `openapi:routes` | `gesso:routes` | v3.x | v4.0 | | `openapi:stubs` | `gesso:stubs` | v3.x | v4.0 | The current spelling wins when both are set, and setting only the accepted one resolves to the same value with no behaviour difference. The map lives in `Studio\Gesso\Internal\LegacyIdentity`; its removal is tracked by [#523](https://github.com/studio-design/gesso/issues/523). ## Support policy | Component | Supported | | --- | --- | | PHP runtime | v2.x — CI: 8.3, 8.4, 8.5; Composer: `^8.3`. v1.x — CI: 8.2, 8.3, 8.4; Composer: `^8.2`, subject to the lifecycle below. | | PHPUnit | v2.x: 12.x, 13.x. v1.x: 11.x, 12.x, 13.x. Each line is covered by its branch CI matrix. | | `opis/json-schema` | `^2.6` for v1.x and v2.x. A jump to `^3` would be a SemVer-major. | | Laravel (optional adapter) | Whatever `orchestra/testbench` `^9 \|\| ^10 \|\| ^11` supports. | After v2 stable, v2 bug fixes and security updates land on its latest minor. Each line follows its maintenance lifecycle below. Neither major maintains older minor branches; upgrade to the latest minor of the selected major to receive fixes. ## v2 maintenance lifecycle v3.0 has no scheduled date: its window opens on a condition rather than a calendar deadline, recorded in [ADR 0004](adr/0004-v3-consistency-policy-and-protected-core.md#2026-08-12--the-v30-window-opens-on-a-condition-not-a-date). This line's support is therefore anchored to the v3.0.0 release rather than to fixed dates. | Period | Ends | Accepted changes | | --- | --- | --- | | Active maintenance | 12 months after v3.0.0 is released, and no earlier than 2028-07-01 | Security fixes, backward-compatible bug fixes, and critical ecosystem interoperability fixes | | Security maintenance | 6 months after active maintenance ends | Security fixes only; non-security changes require evidence that they are necessary to ship a security fix safely | | End of life | when security maintenance ends | No releases or support commitment | **Adopting v2 today does not carry an unknown support horizon.** The earliest v3.0.0 can be released is 2027-07-01, because the ADR 0004 window cannot open before v1 reaches end of life. Twelve months after that is 2028-07-01, so that is the earliest date active maintenance can end, and 2029-01-01 the earliest end of life. The 2028-07-01 floor in the table restates that derived date as a commitment of this document. It is not there to bind the relative rule — it cannot, since the two are equal by construction today — but to make the guarantee independent: shortening it requires changing this table, so amending ADR 0004 cannot quietly shorten how long v2 is supported. The window is longer than the one v1 received — v1 entered security maintenance about five months after v2.0.0 shipped — and the difference is deliberate. v1 was the pre-rename line under a package that is now [marked abandoned][composer-abandoned], and its migration path is a package swap documented in [migration/v2.md](migration/v2.md). v2 is the line adopters are asked to build on. The anchor is relative because a calendar one decouples from the release it describes: the v1 table below carries a note that its dates do not move automatically if the v2 schedule changes, which is a manual obligation this line avoids by construction. When v3.0.0 ships, the resolved dates are recorded in this section by the same PR that closes the v3 window. ## v1 maintenance lifecycle v1.10.0 is the final planned feature minor of the original `studio-design/openapi-contract-testing` package. It may add backward-compatible migration aids and deprecations for Gesso 2.0. Under the [deprecation policy](#deprecation-policy), v1.10.0 is the last release that can carry a deprecation for a surface v2 removes. After v1.10.0, the v1 line receives patches from the `1.x` maintenance branch: V1.10.0 also provides lazy `Studio\Gesso\` aliases for all non-`@internal` public PHP types. Consumers should use these aliases to migrate imports before changing Composer packages. The aliases do not cover configuration, wire formats, or literal class-name identity, and v2 does not provide a reverse legacy namespace shim. V1.10 adds `gesso doctor` and `gesso coverage:merge` so command invocations can move before the package switch; the v1 standalone binaries remain supported until v1 EOL and are removed in v2. Follow the [staged v2 migration guide](migration/v2.md) for these boundaries. | Period | Dates | Accepted changes | | --- | --- | --- | | Active maintenance | through 2026-12-31 | Security fixes, backward-compatible bug fixes, and critical ecosystem interoperability fixes | | Security maintenance | 2027-01-01 through 2027-06-30 | Security fixes only; non-security changes require evidence that they are necessary to ship a security fix safely | | End of life | from 2027-07-01 | No releases or support commitment; migrate to `studio-design/gesso` | These dates are calendar deadlines in UTC and do not move automatically if the v2 schedule changes. If v2 stable is not available before active maintenance ends, the maintainers must publish a revised calendar before 2026-12-31 rather than silently extending or shortening support. The v1 Composer PHP constraint remains `^8.2`; a patch release does not raise the declared floor. [PHP 8.2 reaches upstream security EOL][php-support] on 2026-12-31. Compatibility checks may continue during v1 security maintenance, but Gesso cannot provide fixes for vulnerabilities in an EOL PHP runtime. Consumers should run v1 on a PHP branch still supported by the PHP project. The `1.x` branch is created from the v1.10.0 release commit only after its tag and GitHub release have been produced by release-please. `main` then becomes the v2 development line. Both branches use release-please independently; v1 patch tags remain `v1.10.z` and v2 tags use `v2.y.z`. Changes that affect both majors land on `main` first, then are cherry-picked from the squashed commit into a new branch based on `1.x` and reviewed in a separate PR targeting `1.x`. A v1-only compatibility or security fix may target `1.x` directly when no equivalent v2 change is needed. Never merge `main` into `1.x`, and never combine unrelated backports. The maintenance PR must preserve the v1 public compatibility surface and pass the same required checks as a `main` PR. The old Composer package remains installable throughout this lifecycle. It is [marked abandoned][composer-abandoned] with `studio-design/gesso` as its suggested replacement only after the v2 stable package is installable and its migration path has been verified. Abandonment is a migration signal, not permission to remove existing v1 tags. The package remains supported according to the dates above even if the abandonment notice is added earlier. [composer-abandoned]: https://getcomposer.org/doc/04-schema.md#abandoned [php-support]: https://www.php.net/supported-versions.php ## Release checklist Before publishing a release: - [ ] Run the supported PHP / PHPUnit / framework matrix in CI. - [ ] Review `UPGRADING.md` and the generated release notes for SemVer accuracy. - [ ] For a v2 beta or stable promotion, complete the tag, GitHub Release, release manifest, Packagist metadata, clean-install, and published-artifact checks in the [Gesso 2.0 release procedure][v2-release-procedure]. - [ ] If the README feature comparison was last checked three or more months ago, verify every competitor version and linked capability against its tagged official README and `composer.json`, update the checked date, and keep competitor strengths as well as gaps. [v2-release-procedure]: https://github.com/studio-design/gesso/blob/main/CONTRIBUTING.md#gesso-20-beta-and-stable-promotion