Skip to content

Combining "nullable" and "allOf" does not work correctly #50

Description

@shadowhand

PHP version

8.5

duyler/openapi version

0.7.0

OpenAPI spec version

3.0

Description

In OpenAPI 3.0, a $ref cannot have siblings, so the documented way to make a referenced schema nullable is to wrap it in allOf and put nullable: true next to it:

data:
  allOf:
    - $ref: '#/components/schemas/FormIdentifier'
  nullable: true

duyler/openapi rejects null for this schema. nullable is honored on the schema being validated only when type is present (TypeValidator::validate()), but the composition validators consult nullable on the branch schemas rather than on the parent that carries the keyword, so the null is passed straight into each branch and fails there.

All three composition keywords are affected. This makes every nullable relationship in a JSON:API-style 3.0 document unvalidatable — in our spec it fails ~100 endpoint responses.

Steps to reproduce

<?php

declare(strict_types=1);

use Duyler\OpenApi\Builder\OpenApiValidatorBuilder;
use Duyler\OpenApi\Validator\Exception\ValidationException;
use Nyholm\Psr7\Response;
use Nyholm\Psr7\ServerRequest;

require __DIR__ . '/vendor/autoload.php';

$validator = OpenApiValidatorBuilder::create()
    ->fromYamlFile('nullable-allof.yaml')
    ->build();

$operation = $validator->validateRequest(new ServerRequest('GET', '/actions/1'));

$body = '{"data":{"type":"actions","id":"1","relationships":{"form":{"data":null}}}}';

try {
    $validator->validateResponse(
        new Response(200, ['Content-Type' => 'application/json'], $body),
        $operation,
    );
    echo "PASS: null relationship accepted\n";
} catch (ValidationException $e) {
    printf("FAIL: %s\n", $e->getMessage());
    printf("      getErrors() returned %d error(s)\n", count($e->getErrors()));
}

Schema, e.g. nullable-allof.yaml:

openapi: 3.0.0
info:
  title: nullable allOf repro
  version: 1.0.0
paths:
  '/actions/{actionId}':
    get:
      parameters:
        - name: actionId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: One action
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Action'
components:
  schemas:
    FormIdentifier:
      type: object
      required: [type, id]
      properties:
        type:
          type: string
          enum: [forms]
        id:
          type: string
    Action:
      type: object
      properties:
        type:
          type: string
          enum: [actions]
        id:
          type: string
        relationships:
          type: object
          properties:
            form:
              type: object
              properties:
                data:
                  # OAS 3.0: the relationship may be absent (null) or a form identifier.
                  allOf:
                    - $ref: '#/components/schemas/FormIdentifier'
                  nullable: true

Actual result

FAIL: All of the schemas must match, but 1 failed
      getErrors() returned 0 error(s)

Behaviour matrix

Same branch schema ({type: object, required: [type, id], …}) in every row, via validateSchema():

Parent schema null valid object
{allOf: [B], nullable: true} FAILAll of the schemas must match, but 1 failed PASS
{type: object, allOf: [B], nullable: true} FAIL — same PASS
{anyOf: [B], nullable: true} FAILAt least one of the schemas must match, but none did PASS
{oneOf: [B], nullable: true} FAILExactly one of schemas must match, but none did PASS
{type: object, nullable: true, required: […]} (no composition) PASS PASS
{allOf: [B + nullable: true]} (nullable on the branch) PASS PASS

Two things this pins down: it is specific to null (valid objects are unaffected), and adding type: object beside the composition keyword does not help — TypeValidator's early return only skips its own keyword, and the composition validator still runs.

Root cause

src/Validator/SchemaValidator/TypeValidator.php:44 honors nullable on the schema under validation:

if (null === $data && $schema->nullable && $nullableAsType) {
    return;
}

src/Validator/SchemaValidator/AbstractCompositionalValidator.php:112-119 checks the branch instead, and never sees the parent's nullable:

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));

    return SchemaValueNormalizer::normalize($data, $allowNull);
}

AllOfValidator::validate(), AnyOfValidator::validate() and OneOfValidator::validate() therefore dispatch branches without checking $schema->nullable first. src/Validator/Schema/OneOfValidatorWithContext.php:88 (hasNullableSchema()) makes the same branch-only assumption on the context-carrying path.

Suggested fix: in the composition validators, return early when null === $data && $schema->nullable && $nullableAsType, mirroring TypeValidator. Equivalently, have normalizeForBranch() take the parent's nullability into account.

Secondary observation

When this fires, ValidationException::getErrors() is empty and the message carries no dataPath, so a middleware cannot tell the client which member was rejected. Branch failures inside allOf appear to be dropped unless they are AbstractValidationError instances (AbstractCompositionalValidator::validateBranch()). Worth a separate issue if you'd prefer.

Spec reference

OAS 3.0.3, Schema Object: nullable — "Allows sending a null value for the defined schema." The allOf + nullable wrapper is the standard 3.0 workaround for $ref sibling keywords being ignored, and is what code generators and editors emit for a nullable reference.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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