PHP version
8.5
duyler/openapi version
0.7.0
OpenAPI spec version
3.0
Description
Before validating a property or array item, the validators run a SchemaValueNormalizer::normalize($value, $allowNull) pre-check, and compute $allowNull by looking only at the immediate schema node:
// src/Validator/Schema/PropertiesValidatorWithContext.php:56-58
$allowNull = $context->nullableAsType && ($propertySchema->nullable
|| SchemaValueNormalizer::doesTypeIncludeNull($propertySchema->type)
|| null !== $propertySchema->ref);
When that node is a composition — allOf, anyOf, or oneOf — the node itself carries no type and no nullable; both live in the branches. So $allowNull is false, and a perfectly valid null is rejected by InvalidDataTypeException before a single branch is ever evaluated:
Data must be array, int, string, float or bool, null given
This needs no $ref to reproduce. A one-branch allOf wrapping an inline nullable: true is enough, which makes it independent of #64 (that one is about the $ref sibling merge; this one fires before any resolution happens).
The practical impact is large for JSON:API-style documents, where resources are routinely modelled as allOf compositions. Any nullable attribute inside such a resource becomes unrepresentable: sending the null the schema explicitly permits produces a validation failure.
Two further notes that may help when fixing:
The error carries no location. InvalidDataTypeException is raised with no dataPath and no schemaPath, so when it surfaces through a composition it arrives as a single error with empty paths. There is no way to tell which property was at fault:
[invalid] dataPath='' schemaPath='' :: Data must be array, int, string, float or bool, null given params: []
The same rule is implemented four times and the copies disagree. Only the first has the $ref clause:
| site |
nullable |
type includes null |
$ref |
Schema/PropertiesValidatorWithContext.php:56-58 |
yes |
yes |
yes |
SchemaValidator/PropertiesValidator.php:54-55 |
yes |
yes |
no |
SchemaValidator/AbstractCompositionalValidator.php:115-116 (normalizeForBranch) |
yes |
yes |
no |
SchemaValidator/ItemsValidator.php:90 / Schema/ItemsValidatorWithContext.php:74 |
via caller |
via caller |
no |
So a $ref property that accepts null in one code path is rejected in another. Consolidating these into one helper would fix the divergence along with the composition case.
Steps to reproduce
<?php
declare(strict_types=1);
use Duyler\OpenApi\Builder\OpenApiValidatorBuilder;
require __DIR__ . '/vendor/autoload.php';
$yaml = <<<'YAML'
openapi: 3.0.0
info:
title: Nullable Composition API
version: 1.0.0
paths: {}
components:
schemas:
NullableString:
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/NullableString'}]}}}
# Restating nullable on the property node is the workaround.
P_allOf_nullable_sibling:
{type: object, properties: {p: {nullable: true, allOf: [{type: string, nullable: true}]}}}
YAML;
$validator = OpenApiValidatorBuilder::create()->fromYamlString($yaml)->build();
$schemas = [
'P_inline', 'P_allOf_inline', 'P_anyOf_inline',
'P_oneOf_inline', 'P_allOf_ref', 'P_allOf_nullable_sibling',
];
foreach ($schemas as $name) {
try {
$validator->validateSchema(['p' => null], "#/components/schemas/$name");
printf("%-26s PASS\n", $name);
} catch (Throwable $e) {
printf("%-26s %s\n", $name, (new ReflectionClass($e))->getShortName());
}
}
Every one of these says "property p accepts null", so all six should pass.
Array items behave the same way:
$yaml = <<<'YAML'
openapi: 3.0.0
info: {title: T, version: 1.0.0}
paths: {}
components:
schemas:
A_inline: {type: array, items: {type: string, nullable: true}}
A_allOf: {type: array, items: {allOf: [{type: string, nullable: true}]}}
YAML;
$validator->validateSchema([null], '#/components/schemas/A_inline'); // passes
$validator->validateSchema([null], '#/components/schemas/A_allOf'); // InvalidDataTypeException
Actual result
P_inline PASS
P_allOf_inline InvalidDataTypeException
P_anyOf_inline InvalidDataTypeException
P_oneOf_inline InvalidDataTypeException
P_allOf_ref InvalidDataTypeException
P_allOf_nullable_sibling PASS
and for arrays:
A_inline PASS
A_allOf InvalidDataTypeException
Message in every failing case: Data must be array, int, string, float or bool, null given.
Suggested fix
The pre-check is deciding a question it does not yet have the information to answer. Either look through composition (and $ref) when computing $allowNull — e.g. allow null when the node has any of allOf/anyOf/oneOf/$ref and let the branch validators decide — or drop the eager rejection and let TypeValidator handle it, since it already evaluates nullable correctly per branch.
Either way it seems worth extracting the rule into a single helper so the four copies cannot drift again.
Notes
Found while migrating a JSON:API service from league/openapi-psr7-validator; our resource schemas are allOf compositions, so every nullable attribute in them hits this. Verified against a clean 0.7.0 install, and separately against a build that already carries the #64 fix — the behaviour is identical, confirming the two are independent.
PHP version
8.5
duyler/openapi version
0.7.0
OpenAPI spec version
3.0
Description
Before validating a property or array item, the validators run a
SchemaValueNormalizer::normalize($value, $allowNull)pre-check, and compute$allowNullby looking only at the immediate schema node:When that node is a composition —
allOf,anyOf, oroneOf— the node itself carries notypeand nonullable; both live in the branches. So$allowNullisfalse, and a perfectly validnullis rejected byInvalidDataTypeExceptionbefore a single branch is ever evaluated:This needs no
$refto reproduce. A one-branchallOfwrapping an inlinenullable: trueis enough, which makes it independent of #64 (that one is about the$refsibling merge; this one fires before any resolution happens).The practical impact is large for JSON:API-style documents, where resources are routinely modelled as
allOfcompositions. Any nullable attribute inside such a resource becomes unrepresentable: sending thenullthe schema explicitly permits produces a validation failure.Two further notes that may help when fixing:
The error carries no location.
InvalidDataTypeExceptionis raised with nodataPathand noschemaPath, so when it surfaces through a composition it arrives as a single error with empty paths. There is no way to tell which property was at fault:The same rule is implemented four times and the copies disagree. Only the first has the
$refclause:nullabletypeincludes null$refSchema/PropertiesValidatorWithContext.php:56-58SchemaValidator/PropertiesValidator.php:54-55SchemaValidator/AbstractCompositionalValidator.php:115-116(normalizeForBranch)SchemaValidator/ItemsValidator.php:90/Schema/ItemsValidatorWithContext.php:74So a
$refproperty that acceptsnullin one code path is rejected in another. Consolidating these into one helper would fix the divergence along with the composition case.Steps to reproduce
Every one of these says "property
pacceptsnull", so all six should pass.Array items behave the same way:
Actual result
and for arrays:
Message in every failing case:
Data must be array, int, string, float or bool, null given.Suggested fix
The pre-check is deciding a question it does not yet have the information to answer. Either look through composition (and
$ref) when computing$allowNull— e.g. allow null when the node has any ofallOf/anyOf/oneOf/$refand let the branch validators decide — or drop the eager rejection and letTypeValidatorhandle it, since it already evaluatesnullablecorrectly per branch.Either way it seems worth extracting the rule into a single helper so the four copies cannot drift again.
Notes
Found while migrating a JSON:API service from
league/openapi-psr7-validator; our resource schemas areallOfcompositions, so every nullable attribute in them hits this. Verified against a clean0.7.0install, and separately against a build that already carries the #64 fix — the behaviour is identical, confirming the two are independent.