<?php
declare(strict_types=1);
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder;
use Duyler\OpenApi\Validator\Exception\ValidationException;
require __DIR__ . '/vendor/autoload.php';
/** Shaped like a JSON:API resource: an identifier schema composed with a closed attribute schema. */
function resource(string $type, array $attributes): array
{
return [
'allOf' => [
[
'type' => 'object',
'required' => ['type'],
'properties' => ['type' => ['type' => 'string', 'enum' => [$type]]],
],
[
'type' => 'object',
'properties' => [
'attributes' => [
'type' => 'object',
'additionalProperties' => false,
'properties' => array_fill_keys($attributes, ['type' => 'string']),
],
],
],
],
];
}
$noisy = resource('patients', ['first_name', 'last_name']);
$matching = resource('flows', ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k']);
// Matches the `flows` schema, and nothing else.
$flow = [
'type' => 'flows',
'attributes' => array_fill_keys(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'], 'x'),
];
$spec = [
'openapi' => '3.0.0',
'info' => ['title' => 'anyOf branch cap', 'version' => '1.0.0'],
'paths' => [],
'components' => [
'schemas' => [
'Noisy' => $noisy,
'Matching' => $matching,
// Identical branch sets, different order.
'MatchingLast' => ['anyOf' => [
['$ref' => '#/components/schemas/Noisy'],
['$ref' => '#/components/schemas/Noisy'],
['$ref' => '#/components/schemas/Matching'],
]],
'MatchingFirst' => ['anyOf' => [
['$ref' => '#/components/schemas/Matching'],
['$ref' => '#/components/schemas/Noisy'],
['$ref' => '#/components/schemas/Noisy'],
]],
],
],
];
$validator = OpenApiValidatorBuilder::create()->fromJsonString(json_encode($spec))->build();
foreach (['MatchingFirst', 'MatchingLast'] as $schema) {
try {
$validator->validateSchema($flow, "#/components/schemas/$schema");
printf("%-14s => PASS\n", $schema);
} catch (ValidationException $e) {
printf("%-14s => FAIL %s (%d errors)\n", $schema, $e->getMessage(), count($e->getErrors()));
}
}
MatchingFirst => PASS
MatchingLast => FAIL At least one of the schemas must match, but none did (21 errors)
foreach ($schemas as $subSchema) {
$outcome = $this->validateBranch($data, $subSchema, $context, $schemaType);
if ($outcome->matched) {
++$validCount;
continue;
}
foreach ($outcome->errors as $error) {
$errors[] = $error;
}
foreach ($outcome->abstractErrors as $error) {
$abstractErrors[] = $error;
if (self::MAX_COMPOSITION_ERRORS <= count($abstractErrors)) {
$abstractErrors[] = new TooManyErrorsError(
max: self::MAX_COMPOSITION_ERRORS,
dataPath: $dataPath,
);
return new ValidationResult($validCount, $errors, $abstractErrors); // ← leaves the outer foreach
}
}
}
PHP version
8.5
duyler/openapi version
0.7.0
OpenAPI spec version
3.0
Description
AbstractCompositionalValidator::validateSchemas()stops collecting errors afterMAX_COMPOSITION_ERRORS(20) — but it does so with areturn, which abandons the remaining branches instead of just the remaining errors. If earlier branches produce 20+ errors, a later branch that would match is never evaluated,$validCountstays0, andanyOfreports "At least one of the schemas must match, but none did".The result is a validation outcome that depends on the declaration order of the branches, which is not something
anyOfsemantics allow.Steps to reproduce
Actual result
Root Cause
src/Validator/SchemaValidator/AbstractCompositionalValidator.php:37-63:The
returnexitsvalidateSchemas()entirely. An error cap silently becomes a branch cap.AnyOfValidatorthen sees0 === $result->validCountand throws;OneOfValidatoris affected the same way, and can also under-count matches (turning a legitimate single match into "none did").AllOfValidatoris unaffected in outcome — it already fails — but it under-reports which branches failed.