Skip to content

nullable inside allOf/anyOf/oneOf is invisible to the null pre-check, so valid nulls are rejected before any branch runs #66

Description

@shadowhand

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions