Skip to content
Open
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,17 @@ internal-only unless explicitly marked as public API.
empty-body path.
- `NotValidator` — removed redundant `$schema->not` truthy check after
`is_bool($schema->not)` narrowing (Psalm `RedundantCondition`).
- Composition branch errors are no longer lost, duplicated, or
inconsistent between keywords (#52). A `ValidationException` thrown by
`allOf`/`anyOf`/`oneOf` now always carries a structured error list:
a value rejected during branch normalization (typically `null` against
a non-nullable branch) synthesises a `TypeMismatchError` with the
branch `dataPath`/`schemaPath` instead of an empty list; `allOf` no
longer reports each branch error twice; and the `allOf` message counts
the branches that did not match rather than one of two error buckets.
`BranchOutcome` and `ValidationResult` carry a single canonical error
list (`ValidationResult::$abstractErrors` merged into `$errors`, new
`$failedCount`), so no caller can double-count.
- Infection CI job no longer OOMs; MSI thresholds realigned with the
current mutation score so the gate is neither green-by-default nor
unreachable.
Expand Down
53 changes: 38 additions & 15 deletions src/Validator/Schema/OneOfValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,15 @@
use Duyler\OpenApi\Validator\Exception\AbstractValidationError;
use Duyler\OpenApi\Validator\Exception\DiscriminatorDataError;
use Duyler\OpenApi\Validator\Exception\InvalidDataTypeException;
use Duyler\OpenApi\Validator\Exception\NestedValidationError;
use Duyler\OpenApi\Validator\Exception\OneOfError;
use Duyler\OpenApi\Validator\Exception\TypeMismatchError;
use Duyler\OpenApi\Validator\Exception\ValidationException;
use Duyler\OpenApi\Validator\TypeFormatter;

use function implode;
use function is_array;
use function sprintf;

final readonly class OneOfValidatorWithContext
{
Expand Down Expand Up @@ -83,6 +88,18 @@ private function validateWithDiscriminator(mixed $data, Schema $schema, Validati
$this->discriminatorValidator->validate($data, $schema, $this->document, $dataPath, $context);
}

/**
* @param string|array<int, string|null>|null $type
*/
private function formatSchemaType(string|array|null $type): string
{
return match (true) {
null === $type => 'object',
is_array($type) => implode('|', $type),
default => $type,
};
}

private function hasNullableSchema(array $oneOf): bool
{
return array_any($oneOf, fn(Schema $subSchema): bool => $subSchema->nullable
Expand All @@ -93,16 +110,16 @@ private function validateWithoutDiscriminator(mixed $data, array $oneOf, Validat
{
$validCount = 0;
$errors = [];
$abstractErrors = [];

$rootValidator = $this->dependencies->rootSchemaValidator($this->document, $this->configuration);

foreach ($oneOf as $subSchema) {
foreach ($oneOf as $index => $subSchema) {
if (false === $subSchema instanceof Schema) {
continue;
}

$childContext = $context->forkForBranch();
$schemaPath = sprintf('/oneOf/%d', $index);

try {
$allowNull = $context->nullableAsType && ($subSchema->nullable
Expand All @@ -112,27 +129,33 @@ private function validateWithoutDiscriminator(mixed $data, array $oneOf, Validat
++$validCount;
$context->mergeChildAnnotations($childContext);
} catch (AbstractValidationError $e) {
$abstractErrors[] = $e;
$errors[] = $e;
} catch (InvalidDataTypeException) {
continue;
} catch (ValidationException $e) {
$errors[] = new ValidationException(
message: 'Invalid data for oneOf schema: ' . $e->getMessage(),
previous: $e,
errors: $e->getErrors(),
$errors[] = new TypeMismatchError(
expected: $this->formatSchemaType($subSchema->type),
actual: TypeFormatter::format($data),
dataPath: $context->breadcrumbs->currentPath(),
schemaPath: $schemaPath,
);
} catch (ValidationException $e) {
$branchErrors = $e->getErrors();

if ([] === $branchErrors) {
$branchErrors = [new NestedValidationError(
dataPath: $context->breadcrumbs->currentPath(),
schemaPath: $schemaPath,
message: $e->getMessage(),
)];
}

$errors = [...$errors, ...$branchErrors];
}
}

if (0 === $validCount) {
$allErrors = $abstractErrors;
foreach ($errors as $error) {
$allErrors = [...$allErrors, ...$error->getErrors()];
}

throw new ValidationException(
'Exactly one of schemas must match, but none did',
errors: $allErrors,
errors: $errors,
);
}

Expand Down
70 changes: 39 additions & 31 deletions src/Validator/SchemaValidator/AbstractCompositionalValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@
use Duyler\OpenApi\Schema\Model\Schema;
use Duyler\OpenApi\Validator\Exception\AbstractValidationError;
use Duyler\OpenApi\Validator\Exception\InvalidDataTypeException;
use Duyler\OpenApi\Validator\Exception\NestedValidationError;
use Duyler\OpenApi\Validator\Exception\TooManyErrorsError;
use Duyler\OpenApi\Validator\Exception\TypeMismatchError;
use Duyler\OpenApi\Validator\Exception\ValidationException;
use Duyler\OpenApi\Validator\Schema\SchemaValueNormalizer;
use Duyler\OpenApi\Validator\Error\ValidationContext;
use Duyler\OpenApi\Validator\SchemaValidator\Internal\BranchOutcome;
use Duyler\OpenApi\Validator\TypeFormatter;

use function array_values;
use function count;
use function sprintf;

Expand All @@ -30,41 +34,46 @@ protected function validateSchemas(
string $schemaType,
): ValidationResult {
$validCount = 0;
$failedCount = 0;
$errors = [];
$abstractErrors = [];
$dataPath = $this->getDataPath($context);

foreach ($schemas as $subSchema) {
$outcome = $this->validateBranch($data, $subSchema, $context, $schemaType);
foreach ($schemas as $index => $subSchema) {
$outcome = $this->validateBranch($data, $subSchema, $context, $schemaType, $index);

if ($outcome->matched) {
++$validCount;
continue;
}

++$failedCount;

foreach ($outcome->errors as $error) {
$errors[] = $error;
}

foreach ($outcome->abstractErrors as $error) {
$abstractErrors[] = $error;

if (self::MAX_COMPOSITION_ERRORS <= count($abstractErrors)) {
$abstractErrors[] = new TooManyErrorsError(
if (self::MAX_COMPOSITION_ERRORS <= count($errors)) {
$errors[] = new TooManyErrorsError(
max: self::MAX_COMPOSITION_ERRORS,
dataPath: $dataPath,
);

return new ValidationResult($validCount, $errors, $abstractErrors);
return new ValidationResult($validCount, $errors, $failedCount);
}
}
}

return new ValidationResult($validCount, $errors, $abstractErrors);
return new ValidationResult($validCount, $errors, $failedCount);
}

private function validateBranch(mixed $data, Schema $subSchema, ?ValidationContext $context, string $schemaType): BranchOutcome
{
private function validateBranch(
mixed $data,
Schema $subSchema,
?ValidationContext $context,
string $schemaType,
int $index,
): BranchOutcome {
$schemaPath = sprintf('/%s/%d', $schemaType, $index);

try {
$normalizedData = $this->normalizeForBranch($data, $subSchema, $context);
$validator = $this->createSchemaValidator();
Expand All @@ -77,32 +86,31 @@ private function validateBranch(mixed $data, Schema $subSchema, ?ValidationConte
$validator->validate($normalizedData, $subSchema, null);
}

return new BranchOutcome(matched: true, errors: [], abstractErrors: []);
} catch (InvalidDataTypeException $e) {
return new BranchOutcome(matched: true, errors: []);
} catch (InvalidDataTypeException) {
return new BranchOutcome(
matched: false,
errors: [new ValidationException(
sprintf('Invalid data type for %s schema: %s', $schemaType, $e->getMessage()),
previous: $e,
errors: [new TypeMismatchError(
expected: $this->formatSchemaType($subSchema->type, 'object'),
actual: TypeFormatter::format($data),
dataPath: $this->getDataPath($context),
schemaPath: $schemaPath,
)],
abstractErrors: [],
);
} catch (ValidationException $e) {
/** @var list<AbstractValidationError> $abstractErrors */
$abstractErrors = [];
foreach ($e->getErrors() as $err) {
if ($err instanceof AbstractValidationError) {
$abstractErrors[] = $err;
}
$errors = array_values($e->getErrors());

if ([] === $errors) {
$errors = [new NestedValidationError(
dataPath: $this->getDataPath($context),
schemaPath: $schemaPath,
message: $e->getMessage(),
)];
}

return new BranchOutcome(
matched: false,
errors: [$e],
abstractErrors: $abstractErrors,
);
return new BranchOutcome(matched: false, errors: $errors);
} catch (AbstractValidationError $e) {
return new BranchOutcome(matched: false, errors: [], abstractErrors: [$e]);
return new BranchOutcome(matched: false, errors: [$e]);
}
}

Expand Down
15 changes: 3 additions & 12 deletions src/Validator/SchemaValidator/AllOfValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
use Duyler\OpenApi\Validator\Exception\ValidationException;
use Override;

use function count;
use function sprintf;

final readonly class AllOfValidator extends AbstractCompositionalValidator implements KeywordApplicable
Expand All @@ -29,18 +28,10 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex

$result = $this->validateSchemas($schema->allOf, $data, $context, 'allOf');

if ([] !== $result->errors || [] !== $result->abstractErrors) {
$allErrors = $result->abstractErrors;

foreach ($result->errors as $exception) {
foreach ($exception->getErrors() as $error) {
$allErrors[] = $error;
}
}

if (0 !== $result->failedCount) {
throw new ValidationException(
sprintf('All of the schemas must match, but %d failed', count($result->errors)),
errors: $allErrors,
sprintf('All of the schemas must match, but %d failed', $result->failedCount),
errors: $result->errors,
);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/Validator/SchemaValidator/AnyOfValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex
if (0 === $result->validCount) {
throw new ValidationException(
'At least one of the schemas must match, but none did',
errors: $result->abstractErrors,
errors: $result->errors,
);
}
}
Expand Down
8 changes: 3 additions & 5 deletions src/Validator/SchemaValidator/Internal/BranchOutcome.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,17 @@

namespace Duyler\OpenApi\Validator\SchemaValidator\Internal;

use Duyler\OpenApi\Validator\Exception\AbstractValidationError;
use Duyler\OpenApi\Validator\Exception\ValidationException;
use Duyler\OpenApi\Validator\Exception\ValidationErrorInterface;

/** @internal */
final readonly class BranchOutcome
{
/**
* @param list<ValidationException> $errors
* @param list<AbstractValidationError> $abstractErrors
* @param list<ValidationErrorInterface> $errors Canonical error list for the branch,
* empty only when the branch matched.
*/
public function __construct(
public bool $matched,
public array $errors,
public array $abstractErrors,
) {}
}
2 changes: 1 addition & 1 deletion src/Validator/SchemaValidator/OneOfValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex
if (0 === $result->validCount) {
throw new ValidationException(
'Exactly one of the schemas must match, but none did',
errors: $result->abstractErrors,
errors: $result->errors,
);
}

Expand Down
6 changes: 2 additions & 4 deletions src/Validator/SchemaValidator/ValidationResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,13 @@
namespace Duyler\OpenApi\Validator\SchemaValidator;

use Duyler\OpenApi\Validator\Exception\ValidationErrorInterface;
use Duyler\OpenApi\Validator\Exception\ValidationException;

final readonly class ValidationResult
{
public function __construct(
public readonly int $validCount,
/** @var array<int, ValidationException> */
public readonly array $errors,
/** @var array<int, ValidationErrorInterface> */
public readonly array $abstractErrors,
public readonly array $errors,
public readonly int $failedCount = 0,
) {}
}
Loading