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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ 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]

### Fixed

- A `null` permitted by a branch of an `allOf`/`anyOf`/`oneOf` is no longer
rejected before any branch runs. The null pre-check that guards property
and item validation computed `$allowNull` from the immediate schema node,
but a composition node carries no `type` and no `nullable` — both live in
its branches — so `InvalidDataTypeException` fired before a single branch
was evaluated. This made every nullable attribute unrepresentable in
JSON:API-style documents, where resources are modelled as `allOf`
compositions. Composition and `$ref` nodes now defer the decision to the
branch or resolved target, which already evaluates `nullable` correctly.
Nulls no branch permits are still rejected, and now carry the failing
branch's data path instead of a pathless error (#66).
- The same rule was implemented nine times and the copies disagreed — only
two consulted `$ref`, so a `$ref` property that accepted `null` in one code
path was rejected in another. All nine now delegate to a single
`SchemaValueNormalizer::allowsNull()` helper, closing the divergence across
`properties`, `items`, `prefixItems`, `dependentSchemas`, `if`/`then`/
`else`, `not`, and composition branches (#66).

## [0.7.0]

Preparation for the 1.0.0 stable release. This section tracks work that
Expand Down
4 changes: 1 addition & 3 deletions src/Validator/Schema/ItemsValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,7 @@ private function validate(array $data, Schema $schema, ValidationContext $contex
$errors = [];
$itemSchema = $schema->items;
$prefixCount = null !== $schema->prefixItems ? count($schema->prefixItems) : 0;
$allowNull = $context->nullableAsType && ($itemSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($itemSchema->type)
|| null !== $itemSchema->ref);
$allowNull = SchemaValueNormalizer::allowsNull($itemSchema, $context->nullableAsType);
$rootValidator = $this->dependencies->rootSchemaValidator($this->document, $this->configuration);

foreach ($data as $index => $arrayItem) {
Expand Down
3 changes: 1 addition & 2 deletions src/Validator/Schema/OneOfValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,7 @@ private function validateWithoutDiscriminator(mixed $data, array $oneOf, Validat
$childContext = $context->forkForBranch();

try {
$allowNull = $context->nullableAsType && ($subSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($subSchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($subSchema, $context->nullableAsType);
$normalizedData = SchemaValueNormalizer::normalize($data, $allowNull);
$rootValidator->validateWithContext($normalizedData, $subSchema, $childContext);
++$validCount;
Expand Down
4 changes: 1 addition & 3 deletions src/Validator/Schema/PropertiesValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,7 @@ private function validate(array $data, Schema $schema, ValidationContext $contex
}

try {
$allowNull = $context->nullableAsType && ($propertySchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($propertySchema->type)
|| null !== $propertySchema->ref);
$allowNull = SchemaValueNormalizer::allowsNull($propertySchema, $context->nullableAsType);
$value = SchemaValueNormalizer::normalize($data[$name], $allowNull);

$context->enterBreadcrumb($name);
Expand Down
22 changes: 22 additions & 0 deletions src/Validator/Schema/SchemaValueNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Duyler\OpenApi\Validator\Schema;

use Duyler\OpenApi\Schema\Model\Schema;
use Duyler\OpenApi\Validator\Exception\InvalidDataTypeException;
use stdClass;

Expand Down Expand Up @@ -50,6 +51,27 @@ public static function normalize(mixed $value, bool $allowNull = false): array|i
));
}

/**
* Decides whether the pre-check may hand a null to $schema.
*
* A composition or $ref node carries neither type nor nullable of its
* own — both live in the branches or in the resolved target — so null
* is deferred to them instead of being rejected here.
*/
public static function allowsNull(Schema $schema, bool $nullableAsType = true): bool
{
if (false === $nullableAsType) {
return false;
}

return $schema->nullable
|| self::doesTypeIncludeNull($schema->type)
|| null !== $schema->ref
|| null !== $schema->allOf
|| null !== $schema->anyOf
|| null !== $schema->oneOf;
}

/**
* @param string|array<int, string|null>|null $type
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,7 @@ private function validateBranch(mixed $data, Schema $subSchema, ?ValidationConte
*/
private function normalizeForBranch(mixed $data, Schema $subSchema, ?ValidationContext $context): array|int|string|float|bool|null
{
$nullableAsType = $context?->nullableAsType ?? true;
$allowNull = $nullableAsType && ($subSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($subSchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($subSchema, $context?->nullableAsType ?? true);

return SchemaValueNormalizer::normalize($data, $allowNull);
}
Expand Down
3 changes: 1 addition & 2 deletions src/Validator/SchemaValidator/DependentSchemasValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ private function validateDependent(array $data, string $propertyName, Schema $de
$validator = $this->createSchemaValidator();

try {
$allowNull = $nullableAsType && ($dependentSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($dependentSchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($dependentSchema, $nullableAsType);
$normalizedData = SchemaValueNormalizer::normalize($data, $allowNull);
$validator->validate($normalizedData, $dependentSchema, $context);
} catch (InvalidDataTypeException $e) {
Expand Down
3 changes: 1 addition & 2 deletions src/Validator/SchemaValidator/IfThenElseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -157,8 +157,7 @@ private function validateThenOrElse(
*/
private function normalizeFor(mixed $data, Schema $subSchema, bool $nullableAsType): array|int|string|float|bool|null
{
$allowNull = $nullableAsType && ($subSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($subSchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($subSchema, $nullableAsType);

return SchemaValueNormalizer::normalize($data, $allowNull);
}
Expand Down
3 changes: 1 addition & 2 deletions src/Validator/SchemaValidator/ItemsValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ private function validateSchemaItems(array $data, Schema $itemsSchema, ?array $p
{
$prefixCount = null !== $prefixItems ? count($prefixItems) : 0;
$nullableAsType = $context?->nullableAsType ?? true;
$allowNull = $nullableAsType && ($itemsSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($itemsSchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($itemsSchema, $nullableAsType);

$state = new ItemValidationState(
itemsSchema: $itemsSchema,
Expand Down
3 changes: 1 addition & 2 deletions src/Validator/SchemaValidator/NotValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ private function matchesNotSchema(mixed $data, Schema $notSchema, ?ValidationCon
$childContext = null !== $context ? $context->forkForBranch() : null;

try {
$allowNull = $nullableAsType && ($notSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($notSchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($notSchema, $nullableAsType);
$normalizedData = SchemaValueNormalizer::normalize($data, $allowNull);
$validator->validate($normalizedData, $notSchema, $childContext);
} catch (InvalidDataTypeException|ValidationException|AbstractValidationError) {
Expand Down
3 changes: 1 addition & 2 deletions src/Validator/SchemaValidator/PrefixItemsValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ private function validatePrefixItemAt(mixed $item, int $index, ItemValidationSta
}

try {
$allowNull = $state->nullableAsType && ($subSchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($subSchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($subSchema, $state->nullableAsType);
$value = SchemaValueNormalizer::normalize($item, $allowNull);

if (null === $state->context) {
Expand Down
3 changes: 1 addition & 2 deletions src/Validator/SchemaValidator/PropertiesValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex
private function validateProperty(mixed $value, string $name, Schema $propertySchema, SchemaValidatorInterface $validator, bool $nullableAsType, ?ValidationContext &$context): void
{
try {
$allowNull = $nullableAsType && ($propertySchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($propertySchema->type));
$allowNull = SchemaValueNormalizer::allowsNull($propertySchema, $nullableAsType);
$normalized = SchemaValueNormalizer::normalize($value, $allowNull);

if (null === $context) {
Expand Down
136 changes: 136 additions & 0 deletions tests/Unit/Regression/NullableInCompositionRegressionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

namespace Duyler\OpenApi\Test\Unit\Regression;

use Duyler\OpenApi\Builder\OpenApiValidatorBuilder;
use Duyler\OpenApi\Validator\Exception\ValidationException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
* Regression suite for issue #66: the `null` pre-check that runs before a
* property or array item is validated inspected only the immediate schema
* node. A composition node (`allOf`/`anyOf`/`oneOf`) carries neither `type`
* nor `nullable` — both live in its branches — so a permitted `null` was
* rejected by InvalidDataTypeException before a single branch ran.
*
* Anti-test: restoring the node-local `$allowNull` computation makes every
* composition case below throw instead of pass.
*
* A 3.0 `allOf: [{$ref: <nullable target>}]` is deliberately absent: it also
* trips the `$ref` sibling merge of issue #64, which discards the target's
* `nullable` before this pre-check ever runs. The `$ref` branch covered here
* reaches its nullability through a composition instead, so it exercises the
* deferral without depending on that separate fix.
*
* @internal
*/
final class NullableInCompositionRegressionTest extends TestCase
{
private const string SPEC_YAML = <<<'YAML'
openapi: 3.0.0
info: { title: Nullable Composition API, version: 1.0.0 }
paths: {}
components:
schemas:
NullableStringComposition:
allOf: [{type: string, nullable: true}]

P_inline: {type: object, properties: {p: {type: string, nullable: true}}}
P_allOf_inline: {type: object, properties: {p: {allOf: [{type: string, nullable: true}]}}}
P_anyOf_inline: {type: object, properties: {p: {anyOf: [{type: string, nullable: true}]}}}
P_oneOf_inline: {type: object, properties: {p: {oneOf: [{type: string, nullable: true}]}}}
P_allOf_ref: {type: object, properties: {p: {allOf: [{$ref: '#/components/schemas/NullableStringComposition'}]}}}
P_allOf_nullable_sibling:
{type: object, properties: {p: {nullable: true, allOf: [{type: string, nullable: true}]}}}

P_allOf_non_nullable: {type: object, properties: {p: {allOf: [{type: string}]}}}

A_inline: {type: array, items: {type: string, nullable: true}}
A_allOf: {type: array, items: {allOf: [{type: string, nullable: true}]}}
A_anyOf: {type: array, items: {anyOf: [{type: string, nullable: true}]}}
A_oneOf: {type: array, items: {oneOf: [{type: string, nullable: true}]}}

A_allOf_non_nullable: {type: array, items: {allOf: [{type: string}]}}

# The same rule lives behind six further keywords; each reaches the
# pre-check through a different validator.
Nested_properties: {allOf: [{type: object, properties: {p: {allOf: [{type: string, nullable: true}]}}}]}
Nested_items: {allOf: [{type: array, items: {allOf: [{type: string, nullable: true}]}}]}
Dependent_schemas:
type: object
properties: {a: {type: string}}
dependentSchemas:
a: {type: object, properties: {p: {allOf: [{type: string, nullable: true}]}}}
If_then:
if: {type: object}
then: {type: object, properties: {p: {allOf: [{type: string, nullable: true}]}}}
Prefix_items: {type: array, prefixItems: [{allOf: [{type: string, nullable: true}]}]}
Discriminated_oneOf: {type: object, properties: {p: {oneOf: [{allOf: [{type: string, nullable: true}]}]}}}

# `not` is reached only once the node beside it defers, so the outer
# allOf is what carries null this far. The inner composition matches
# null, which means `not` must reject it.
Not_matching_composition:
allOf: [{type: string, nullable: true}]
not: {allOf: [{type: string, nullable: true}]}
YAML;

/**
* @return iterable<string, array{string, array<array-key, mixed>}>
*/
public static function nullAcceptingSchemaProvider(): iterable
{
yield 'inline nullable property' => ['P_inline', ['p' => null]];
yield 'allOf-wrapped nullable property' => ['P_allOf_inline', ['p' => null]];
yield 'anyOf-wrapped nullable property' => ['P_anyOf_inline', ['p' => null]];
yield 'oneOf-wrapped nullable property' => ['P_oneOf_inline', ['p' => null]];
yield 'allOf-wrapped $ref to a nullable composition' => ['P_allOf_ref', ['p' => null]];
yield 'allOf with nullable sibling' => ['P_allOf_nullable_sibling', ['p' => null]];
yield 'inline nullable item' => ['A_inline', [null]];
yield 'allOf-wrapped nullable item' => ['A_allOf', [null]];
yield 'anyOf-wrapped nullable item' => ['A_anyOf', [null]];
yield 'oneOf-wrapped nullable item' => ['A_oneOf', [null]];
yield 'properties behind an allOf branch' => ['Nested_properties', ['p' => null]];
yield 'items behind an allOf branch' => ['Nested_items', [null]];
yield 'property of a dependent schema' => ['Dependent_schemas', ['a' => 'x', 'p' => null]];
yield 'property of a then branch' => ['If_then', ['p' => null]];
yield 'prefixItems entry' => ['Prefix_items', [null]];
yield 'oneOf branch that is itself a composition' => ['Discriminated_oneOf', ['p' => null]];
}

/**
* @return iterable<string, array{string, mixed}>
*/
public static function nullRejectingSchemaProvider(): iterable
{
yield 'allOf-wrapped non-nullable property' => ['P_allOf_non_nullable', ['p' => null]];
yield 'allOf-wrapped non-nullable item' => ['A_allOf_non_nullable', [null]];
yield 'null matched by a composition under not' => ['Not_matching_composition', null];
}

#[Test]
#[DataProvider('nullAcceptingSchemaProvider')]
public function null_is_accepted_when_a_composition_branch_permits_it(string $schemaName, array $data): void
{
$validator = OpenApiValidatorBuilder::create()->fromYamlString(self::SPEC_YAML)->build();

$validator->validateSchema($data, '#/components/schemas/' . $schemaName);

$this->expectNotToPerformAssertions();
}

#[Test]
#[DataProvider('nullRejectingSchemaProvider')]
public function null_is_still_rejected_when_no_composition_branch_permits_it(string $schemaName, mixed $data): void
{
$validator = OpenApiValidatorBuilder::create()->fromYamlString(self::SPEC_YAML)->build();

$this->expectException(ValidationException::class);

$validator->validateSchema($data, '#/components/schemas/' . $schemaName);
}
}
36 changes: 36 additions & 0 deletions tests/Unit/Validator/Schema/SchemaHelperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
namespace Duyler\OpenApi\Test\Unit\Validator\Schema;

use DateTime;
use Duyler\OpenApi\Schema\Model\Schema;
use Duyler\OpenApi\Validator\Exception\InvalidDataTypeException;
use Duyler\OpenApi\Validator\Schema\SchemaValueNormalizer;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use stdClass;
Expand Down Expand Up @@ -251,4 +253,38 @@ public function type_includes_null_returns_true_for_array_with_only_null(): void

self::assertTrue($result);
}

#[Test]
#[DataProvider('allowsNullProvider')]
public function allows_null_reads_the_whole_node(Schema $schema, bool $expected): void
{
$result = SchemaValueNormalizer::allowsNull($schema);

self::assertSame($expected, $result);
}

/**
* @return iterable<string, array{Schema, bool}>
*/
public static function allowsNullProvider(): iterable
{
yield 'bare string' => [new Schema(type: 'string'), false];
yield 'nullable string' => [new Schema(type: 'string', nullable: true), true];
yield 'type array including null' => [new Schema(type: ['string', 'null']), true];
yield 'ref' => [new Schema(ref: '#/components/schemas/Anything'), true];
yield 'allOf' => [new Schema(allOf: [new Schema(type: 'string')]), true];
yield 'anyOf' => [new Schema(anyOf: [new Schema(type: 'string')]), true];
yield 'oneOf' => [new Schema(oneOf: [new Schema(type: 'string')]), true];
yield 'not is not a deferral' => [new Schema(not: new Schema(type: 'string')), false];
}

#[Test]
public function allows_null_defers_nothing_when_nullable_is_not_a_type(): void
{
$schema = new Schema(allOf: [new Schema(type: 'string', nullable: true)]);

$result = SchemaValueNormalizer::allowsNull($schema, nullableAsType: false);

self::assertFalse($result);
}
}