Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,51 @@ jobs:
- name: Build
run: composer build

upstream:
name: Contract dev-master
needs: changes
if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.run == 'true') }}
runs-on: ubuntu-latest
# This package and rasuvaeff/openapi-contract are one oracle in two
# repositories: generated traffic is recorded into the contract's corpus,
# so the contract notices a change here. Nothing noticed a change THERE —
# a tightened compiler broke ten tests of this suite and stayed invisible
# until someone ran it by hand. This job is that run, and it may not gate
# the build: the contract's master is allowed to be ahead of what this
# package supports, and a red result is a heads-up, not a verdict.
continue-on-error: true

steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
php-version: '8.4'
coverage: none
extensions: json, mbstring
tools: composer:v2

- name: Cache Composer dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.composer/cache
key: composer-${{ runner.os }}-${{ hashFiles('**/composer.json') }}
restore-keys: composer-${{ runner.os }}-

- name: Install dependencies with the contract's development branch
run: |
composer config minimum-stability dev
composer config prefer-stable true
composer require rasuvaeff/openapi-contract:dev-master --no-update --no-interaction
composer update --no-interaction --no-progress

- name: Test
run: composer test

coverage:
name: Coverage & Mutation
needs: changes
Expand Down
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,31 @@ every mutation of it is masked. `RequestReproducer::redactCase()` had four such
guards over `$body['encoding']` and became one `match`; the escapes went with
them. Masked mutants are a shape, not a fact of life.

## The contract package is the other half of the oracle

`rasuvaeff/openapi-contract` depends on nothing here, and this package depends
on it — but the two are one oracle: generated traffic is recorded into the
contract's corpus, so a change *here* shows up *there*. The reverse does not
hold, and it has already cost a day: the contract tightened its compiler and
ten tests of this suite went red without anything noticing until someone ran
them by hand.

Two habits close it:

- the `Contract dev-master` job in `build.yml` runs this suite against the
contract's development branch on every relevant PR. It is `continue-on-error`
on purpose — the contract's master may legitimately be ahead of what this
package supports — so read it, do not ignore it;
- before releasing either package, run the suite of the other. From the
monorepo root:
`docker run --rm -v "$PWD/property-testing-openapi":/app -v "$PWD":/repo -v "$PWD/docs/reviews/probe-openapi-contract-2026-09-06":/s -w /app composer:2 sh /s/pto-against-master.sh`

A document this package builds only to prove a refusal must not go through
`Contract::fromArray()` when the contract already refuses it: hand-build the
`Operation`. `forOperation()` and `partContentTypeMismatchForOperation()` take
one by signature, and that is the only path such a shape can still travel in
production.

## When you finish

Run `composer build`, `composer rector`, and `git diff --check`. Run mutation
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

- **Internal.** Two tests built documents `rasuvaeff/openapi-contract` no
longer accepts, so this suite went red against its development branch while
staying green against the release. The shared multipart fixture is a document
that compiles again, and the malformed header declarations it carried — a
value that is not an object, a name that is not a string — moved to a test
that hands the generator an `Operation` built by hand, which is the only way
such a shape can still reach it. `rejectsAPartContentTypeMismatchItCannotConstruct`
builds its operation the same way, so every dataset reaches the refusal it
is named for.

- **Internal.** A `Contract dev-master` job runs this suite against the
contract's development branch on every relevant pull request. It does not
gate the build — that master may legitimately be ahead of what this package
supports — but the day it goes red is the day to look, instead of finding out
at release time.

## 0.10.0 — 2026-09-05

- **Added.** `NegativeRequestCaseArbitrary::partContentTypeMismatchForOperation()`
Expand Down
63 changes: 62 additions & 1 deletion tests/RequestCaseArbitraryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,46 @@ public function avoidsASubstituteTheDeclarationAllows(): void
Assert::false($contract->validateRequest($request)->isValid());
}

/**
* A part header declaration this generator cannot read — a value that is
* not an object, a name that is not a string — is skipped rather than
* generated from.
*
* The shape used to live in the shared multipart fixture. It cannot: since
* openapi-contract 0.8 such a document does not compile at all, and the
* only way the shape still reaches a generator is an `Operation` built by
* hand, which `forOperation()` accepts by signature.
*/
public function skipsPartHeaderDeclarationsItCannotRead(): void
{
$operation = new Operation(
key: 'upload.create',
operationId: 'upload.create',
method: 'POST',
path: '/upload',
requestBody: ['required' => true, 'content' => ['multipart/form-data' => [
'schema' => [
'type' => 'object',
'required' => ['title'],
'additionalProperties' => false,
'properties' => ['title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 12]],
],
'encoding' => ['title' => ['contentType' => 'text/plain', 'headers' => [
'X-Malformed' => 'ignored',
7 => ['required' => true, 'example' => 'seven'],
'X-Kept' => ['required' => true, 'example' => 'yes'],
]]],
]]],
responses: ['201' => []],
);

$case = (new RequestCaseArbitrary())->forOperation($operation)->generate(new Random(11))->value;
$parts = $case['body']['parts'] ?? [];

Assert::same(count($parts), 1);
Assert::same($parts[0]['headers'], ['X-Kept' => 'yes']);
}

/**
* Every document the category cannot be built from, each named for its
* reason. The misuse rewrites a part a valid case already carries, under a
Expand All @@ -816,7 +856,7 @@ public function rejectsAPartContentTypeMismatchItCannotConstruct(array $requestB
Expect::exception(UnsupportedGeneration::class)->withMessage('Operation "uploads.create" has no required multipart body declaring a part content type');

(new NegativeRequestCaseArbitrary())->partContentTypeMismatchForOperation(
$this->partContract($requestBody)->operation('uploads.create'),
$this->partOperation($requestBody),
);
}

Expand Down Expand Up @@ -886,6 +926,27 @@ private function encodedPartContract(string $contentType = 'text/plain'): Contra
}

/** @param array<string, mixed> $requestBody */
/**
* Built by hand rather than compiled: several of the bodies above are
* shapes `Contract::fromArray()` refuses outright since openapi-contract
* 0.8, and the refusal under test is this package's. A hand-built
* `Operation` is also the only way such a shape can still reach the
* generator at all, so it is the path worth pinning.
*
* @param array<string, mixed> $requestBody
*/
private function partOperation(array $requestBody): Operation
{
return new Operation(
key: 'uploads.create',
operationId: 'uploads.create',
method: 'POST',
path: '/uploads',
requestBody: $requestBody,
responses: ['204' => []],
);
}

private function partContract(array $requestBody): Contract
{
return Contract::fromArray(['openapi' => '3.1.0', 'paths' => ['/uploads' => ['post' => [
Expand Down
7 changes: 5 additions & 2 deletions tests/Support/BodyContracts.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,12 @@ public static function multipart(): Contract
'flag' => ['type' => 'boolean'],
]],
'encoding' => [
// Only declarations a document may legally carry: the
// shapes `Contract::fromArray()` refuses live in
// RequestCaseArbitraryTest, on an Operation built by
// hand, which is where they can still reach the
// generator.
'title' => ['contentType' => 'text/markdown', 'headers' => [
'X-Malformed' => 'ignored',
7 => ['required' => true, 'example' => 'seven'],
'X-Unspecified' => ['example' => 'u'],
'X-Optional' => ['required' => false, 'example' => 'no'],
'X-Example' => ['required' => true, 'example' => 'yes'],
Expand Down
Loading