Skip to content

anyOf/oneOf false negative: the 20-error cap aborts branch iteration #54

Description

@shadowhand

PHP version

8.5

duyler/openapi version

0.7.0

OpenAPI spec version

3.0

Description

AbstractCompositionalValidator::validateSchemas() stops collecting errors after MAX_COMPOSITION_ERRORS (20) — but it does so with a return, 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, $validCount stays 0, and anyOf reports "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 anyOf semantics allow.

Steps to reproduce

<?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()));
    }
}

Actual result

MatchingFirst  => PASS
MatchingLast   => FAIL  At least one of the schemas must match, but none did (21 errors)

Root Cause

src/Validator/SchemaValidator/AbstractCompositionalValidator.php:37-63:

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
        }
    }
}

The return exits validateSchemas() entirely. An error cap silently becomes a branch cap.

AnyOfValidator then sees 0 === $result->validCount and throws; OneOfValidator is affected the same way, and can also under-count matches (turning a legitimate single match into "none did"). AllOfValidator is unaffected in outcome — it already fails — but it under-reports which branches failed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions