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
39 changes: 39 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ jobs:
with:
persist-credentials: false

- name: Checkout contract PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: rasuvaeff/openapi-contract
ref: master
path: contract-source
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
Expand All @@ -115,6 +123,11 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist

- name: Use contract PR source
run: |
rm -rf vendor/rasuvaeff/openapi-contract
ln -s "$GITHUB_WORKSPACE/contract-source" vendor/rasuvaeff/openapi-contract

- name: Build
run: composer build

Expand All @@ -130,6 +143,14 @@ jobs:
with:
persist-credentials: false

- name: Checkout contract PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: rasuvaeff/openapi-contract
ref: master
path: contract-source
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
Expand All @@ -148,6 +169,11 @@ jobs:
- name: Install dependencies
run: composer update --prefer-lowest --prefer-stable --no-interaction --no-progress

- name: Use contract PR source
run: |
rm -rf vendor/rasuvaeff/openapi-contract
ln -s "$GITHUB_WORKSPACE/contract-source" vendor/rasuvaeff/openapi-contract

- name: Build
run: composer build

Expand Down Expand Up @@ -208,6 +234,14 @@ jobs:
with:
persist-credentials: false

- name: Checkout contract PR
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
repository: rasuvaeff/openapi-contract
ref: master
path: contract-source
persist-credentials: false

- name: Setup PHP
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
with:
Expand All @@ -226,6 +260,11 @@ jobs:
- name: Install dependencies
run: composer install --no-interaction --no-progress --prefer-dist

- name: Use contract PR source
run: |
rm -rf vendor/rasuvaeff/openapi-contract
ln -s "$GITHUB_WORKSPACE/contract-source" vendor/rasuvaeff/openapi-contract

- name: Restore property regression corpus
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,12 @@ the same way.
| `minLength`, `maxLength` (capped at 64), `pattern` (PCRE subset) | supported |
| `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (annotation) | supported; a length window the format cannot satisfy, or `pattern` combined with an asserted format, fails closed |
| `items`, `minItems`, `maxItems` (capped at 16), `uniqueItems` | supported; `uniqueItems` over a finite item domain smaller than `minItems` fails closed |
| `properties`, `required`, `minProperties`, `maxProperties` (capped at 16), `additionalProperties` (boolean or schema) | supported; the cardinality is met by construction (an optional past the ceiling is left out, one needed for the floor brought in) |
| `properties`, `patternProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties` (capped at 16), `additionalProperties` (boolean or schema) | supported; pattern keys are generated from the declared regexes and values satisfy every matching pattern schema; the cardinality is met by construction |
| `readOnly` (requests), `writeOnly` (responses) | dropped per direction |
| `anyOf`, `oneOf` (provably disjoint branches, or one `integer` beside one `number` branch: a value is kept only when exactly one admits it, the number branch's `multipleOf` judged by the contract's own `SchemaCheck::isMultipleOf()`), `allOf` (mergeable branches; a branch bounding `additionalProperties` must declare every sibling property) | supported |
| `not` with `const`, `enum`, or `type` | supported; a `not` that excludes every declared type fails closed |
| `$defs` + local `$ref` (`#/$defs/…`) — the contract's compiled form of a schema that refers to itself, a tree whose `children` are trees | supported; the def unfolds three levels below the root and ends at a leaf that leaves the recursive member out (an optional property omitted, an array that may be empty left empty, a branch of a choice skipped); a def whose only way down is a required member that is the def itself has no finite instance and fails closed; a `$ref` to anything but the schema's own `$defs` fails closed |
| `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` |
| `if`/`then`/`else`, `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, numeric `exclusiveMinimum`/`exclusiveMaximum`, other formats | fail closed as `UnsupportedGeneration` |

Every unsatisfiable combination the compiler can recognise is refused at
compile time; what remains probabilistic (a `pattern` that rarely matches its
Expand Down
4 changes: 2 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,12 @@ request-направлению схемы — `readOnly`-свойства ухо
| `minLength`, `maxLength` (не более 64), `pattern` (подмножество PCRE) | поддержано |
| `format`: `uuid`, `email`, `ipv4`, `uri`, `uri-reference`, `url`, `date`, `date-time`, `password` (аннотация) | поддержано; окно длины, которое format не может удовлетворить, или `pattern` вместе с проверяемым format падают fail-closed |
| `items`, `minItems`, `maxItems` (не более 16), `uniqueItems` | поддержано; `uniqueItems` над конечным доменом элементов меньше `minItems` падает fail-closed |
| `properties`, `required`, `minProperties`, `maxProperties` (не более 16), `additionalProperties` (boolean или схема) | поддержано; кардинальность выполняется по построению (optional сверх потолка выпадает, нужный для пола — добавляется) |
| `properties`, `patternProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties` (не более 16), `additionalProperties` (boolean или схема) | поддержано; ключи pattern генерируются из объявленных regex, значения удовлетворяют всем совпавшим схемам, кардинальность выполняется по построению |
| `readOnly` (requests), `writeOnly` (responses) | отбрасываются по направлению |
| `anyOf`, `oneOf` (доказуемо непересекающиеся ветви, либо одна ветвь `integer` рядом с одной `number`: значение остаётся, только если его допускает ровно одна, а `multipleOf` number-ветви судит собственный `SchemaCheck::isMultipleOf()` контракта), `allOf` (сливаемые ветви; ветвь, ограничивающая `additionalProperties`, обязана объявлять все свойства соседей) | поддержано |
| `not` с `const`, `enum` или `type` | поддержано; `not`, исключающий каждый объявленный тип, падает fail-closed |
| `$defs` + локальный `$ref` (`#/$defs/…`) — скомпилированная контрактом форма схемы, ссылающейся на саму себя: дерево, чьи `children` тоже деревья | поддерживается; def разворачивается на три уровня под корнем и заканчивается листом, который опускает рекурсивный член (необязательное свойство пропущено, массив, которому можно быть пустым, пуст, ветка выбора пропущена); def, единственный путь вниз которого — обязательный член, равный самому def, не имеет конечного экземпляра и fail-closed; `$ref` куда-либо кроме собственных `$defs` схемы — fail-closed |
| `if`/`then`/`else`, `contains`, `prefixItems`, `patternProperties`, `propertyNames`, `unevaluatedProperties`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` |
| `if`/`then`/`else`, `contains`, `prefixItems`, `unevaluatedProperties`, `dependentRequired`, `dependentSchemas`, `unevaluatedItems`, числовые `exclusiveMinimum`/`exclusiveMaximum`, прочие formats | fail-closed как `UnsupportedGeneration` |

Каждая невыполнимая комбинация, которую компилятор способен распознать,
отвергается при компиляции; то, что остаётся вероятностным (`pattern`, редко
Expand Down
6 changes: 4 additions & 2 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,10 @@ form property is omitted at materialization (RFC 6570: undefined), and required
array/object parameters and form properties are generated non-empty.
Required parameters and request bodies are always generated. Optional parameters
and JSON bodies are generated as both present and absent branches.
Object generation supports bounded `minProperties`, `maxProperties`, and
boolean or schema-valued `additionalProperties`.
Object generation supports bounded `minProperties`, `maxProperties`,
`patternProperties`, `propertyNames`, and boolean or schema-valued
`additionalProperties`; generated pattern values satisfy every matching
pattern schema.
The constructive `not` subset supports `const`, `enum`, and `type` exclusions;
other negative assertions fail closed.
A schema that refers to itself arrives from the contract as `$defs` plus a
Expand Down
140 changes: 125 additions & 15 deletions src/Internal/Compile/ContainerArbitraries.php
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,35 @@ private function finiteDomain(array $items): ?int
public function object(array $schema): ArbitraryInterface
{
$properties = $this->facts->schemaObject($schema['properties'] ?? [], 'object properties must be an object');
$patterns = $this->facts->schemaObject($schema['patternProperties'] ?? [], 'patternProperties must be an object of schemas');
$propertyNames = array_key_exists('propertyNames', $schema)
? $this->facts->schemaObject($schema['propertyNames'], 'propertyNames must be a schema object')
: null;
if ($propertyNames !== null) {
foreach (array_keys($propertyNames) as $keyword) {
if ($keyword !== 'pattern') {
throw UnsupportedGeneration::forSchema(sprintf('propertyNames keyword "%s" is outside the supported generator subset', $keyword));
}
}
}
$patternArbitraries = [];
/** @var array<string, array<string, mixed>> $typedPatterns */
$typedPatterns = [];
foreach ($patterns as $pattern => $patternSchema) {
if (!is_array($patternSchema) || array_is_list($patternSchema)) {
throw UnsupportedGeneration::forSchema('patternProperties must contain named schema objects');
}
/** @var array<string, mixed> $patternSchema */
$typedPatterns[$pattern] = $patternSchema;

try {
$key = Gen::stringMatching($pattern);
} catch (\InvalidArgumentException $exception) {
throw UnsupportedGeneration::forSchema(sprintf('patternProperties key is not supported: %s', $exception->getMessage()));
}
$patternArbitraries[] = [$pattern, $key];
}
$patterns = $typedPatterns;
$required = $schema['required'] ?? [];
if (!is_array($required) || !array_is_list($required)) {
throw UnsupportedGeneration::forSchema('required must be a list of property names');
Expand All @@ -120,6 +149,9 @@ public function object(array $schema): ArbitraryInterface
$reserved = [];
/** @var array<string, true> $requiredNames */
foreach ($properties as $name => $property) {
if ($propertyNames !== null && !$this->matchesSchema($name, $propertyNames)) {
throw UnsupportedGeneration::forSchema('propertyNames excludes a declared property');
}
if (!is_array($property) || array_is_list($property)) {
throw UnsupportedGeneration::forSchema('object properties must contain named schema objects');
}
Expand All @@ -132,7 +164,14 @@ public function object(array $schema): ArbitraryInterface
continue;
}

$compiled = $this->producible($property);
$applicable = [$property];
foreach ($patterns as $pattern => $patternSchema) {
if (preg_match('~' . $pattern . '~', $name) === 1) {
/** @var array<string, mixed> $patternSchema */
$applicable[] = $patternSchema;
}
}
$compiled = $this->producible(count($applicable) === 1 ? $property : ['allOf' => $applicable]);
if (!$compiled instanceof \Rasuvaeff\PropertyTesting\ArbitraryInterface) {
// The leaf of a recursive def: an optional member that would
// hold more of it is left out, a required one gives it up.
Expand All @@ -156,7 +195,7 @@ public function object(array $schema): ArbitraryInterface
}

$additional = $this->facts->additionalPropertiesSchema($schema);
if ($additional === false && $minProperties > count($shape)) {
if ($additional === false && $patternArbitraries === [] && $minProperties > count($shape)) {
throw UnsupportedGeneration::forSchema('minProperties requires additional properties, but additionalProperties is false');
}
// The declared optionals meet the cardinality by construction: an
Expand All @@ -182,23 +221,74 @@ public function object(array $schema): ArbitraryInterface
return self::objectValues($typed, $requiredNames, $declaredFloor, $maxProperties);
});

if ($additional === false || $minProperties <= 0 && $shape !== []) {
if (($additional === false && $patternArbitraries === []) || ($minProperties <= 0 && $shape !== [] && $patternArbitraries === [])) {
return $base;
}

$keyAlphabet = 'abcdefghijklmnopqrstuvwxyz';
/** @var ArbitraryInterface<array-key> $key */
$key = Gen::map(
Gen::filter(
Gen::stringFrom($keyAlphabet, minLength: 1, maxLength: 8),
static fn(string $name): bool => !array_key_exists($name, $shape) && !isset($reserved[$name]),
),
static fn(string $name): string => $name,
);
/** @var ArbitraryInterface<string> $key */
if ($patternArbitraries !== []) {
$key = Gen::map(Gen::frequency(array_map(
function (array $entry) use ($shape, $reserved, $propertyNames, $patterns): array {
/** @var string $pattern */
[$pattern, $source] = $entry;
$filtered = Gen::filter($source, function (mixed $name) use ($pattern, $shape, $reserved, $propertyNames, $patterns): bool {
if (!is_string($name) || array_key_exists($name, $shape) || isset($reserved[$name])) {
return false;
}
if ($propertyNames !== null && !$this->matchesSchema($name, $propertyNames)) {
return false;
}
$matches = [];
foreach (array_keys($patterns) as $candidate) {
if (preg_match('~' . $candidate . '~', $name) === 1) {
$matches[] = $candidate;
}
}

return in_array($pattern, $matches, strict: true);
});

return [1, $filtered];
},
$patternArbitraries,
)), static fn(string $name): string => $name);
} else {
$key = Gen::map(
Gen::filter(
Gen::stringFrom($keyAlphabet, minLength: 1, maxLength: 8),
fn(string $name): bool => !array_key_exists($name, $shape) && !isset($reserved[$name])
&& ($propertyNames === null || $this->matchesSchema($name, $propertyNames)),
),
static fn(string $name): string => $name,
);
}
/** @var ArbitraryInterface<mixed> $value */
$value = is_array($additional) && $additional !== []
? $this->compiler->compile($additional)
: $this->additionalValue();
if ($patternArbitraries !== []) {
$value = Gen::flatMap($key, function (mixed $name) use ($patterns, $additional): ArbitraryInterface {
if (is_string($name)) {
$matching = [];
/** @var list<array<string, mixed>> $matching */
foreach ($patterns as $pattern => $patternSchema) {
if (preg_match('~' . $pattern . '~', $name) === 1) {
/** @var array<string, mixed> $patternSchema */
$matching[] = $patternSchema;
}
}
if ($matching !== []) {
return $this->compiler->compile(['allOf' => $matching]);
}
}

return is_array($additional) && $additional !== []
? $this->compiler->compile($additional)
: $this->additionalValue();
});
} else {
$value = is_array($additional) && $additional !== []
? $this->compiler->compile($additional)
: $this->additionalValue();
}

return Gen::flatMap($base, fn(array $values): ArbitraryInterface => $this->additionalProperties(
$values,
Expand All @@ -209,6 +299,26 @@ public function object(array $schema): ArbitraryInterface
));
}

/** @param array<string, mixed> $schema */
private function matchesSchema(string $value, array $schema): bool
{
$pattern = $schema['pattern'] ?? null;

if ($pattern === null) {
return true;
}
if (!is_string($pattern)) {
throw UnsupportedGeneration::forSchema('propertyNames pattern must be a string');
}

$result = preg_match('~' . $pattern . '~', $value);
if ($result === false) {
throw UnsupportedGeneration::forSchema('propertyNames pattern is not supported');
}

return $result === 1;
}

/** @return ArbitraryInterface<mixed> */
public function additionalValue(): ArbitraryInterface
{
Expand All @@ -231,7 +341,7 @@ public function additionalValue(): ArbitraryInterface

/**
* @param array<array-key, mixed> $values
* @param ArbitraryInterface<array-key> $key
* @param ArbitraryInterface<string> $key
* @param ArbitraryInterface<mixed> $value
* @return ArbitraryInterface<array<array-key, mixed>>
*/
Expand Down
Loading
Loading