Skip to content

OpenAPI 3.0 nullable is discarded when a schema is reached through $ref (sibling merge uses AND) #64

Description

@shadowhand

PHP version

8.5

duyler/openapi version

0.7.0

OpenAPI spec version

3.0

Description

ScalarSiblingMerger::merge() combines the nullable flag of a $ref node and its resolved target with a logical AND:

// src/Schema/Model/Internal/ScalarSiblingMerger.php:41
'nullable' => $sibling->nullable && $resolved->nullable,

Schema::$nullable defaults to false (src/Schema/Model/Schema.php:75), and a bare {$ref: ...} node never sets it. So for every plain $ref, the merge evaluates false && true and the target's nullable: true is discarded. The resolved schema keeps its type: string but loses its nullability, and a legitimate null is then rejected by TypeValidator:

[type] path=/p schemaPath=/type :: Expected type "string", but got "null"

This makes a very common OpenAPI 3.0 arrangement unusable — declaring a nullable schema once under components/schemas and reusing it by reference:

NullableString:
  type: string
  nullable: true
Thing:
  type: object
  properties:
    p:
      $ref: '#/components/schemas/NullableString'   # null now rejected

The AND also looks inconsistent with how the same class merges every other scalar. format, pattern, multipleOf and friends use "sibling wins if set, otherwise the resolved value" (mergeFormat, mergeNullableIdentical), and type uses mergeType. Only nullable uses AND — and because its default is false rather than null, "not specified at the referring site" is indistinguishable from "explicitly not nullable", so the default silently overrides the target.

Per OpenAPI 3.0, a nullable sibling next to $ref can only widen the target; there is no way to spell "narrow this to non-nullable", so the merge should be ||.

Two things narrow the blast radius, and may help confirm the diagnosis:

  • Restating nullable: true at the referring site works around it, which is exactly what the AND predicts.
  • The 3.1 spelling type: [string, 'null'] is unaffected, because the type union travels through mergeType rather than the nullable flag. This is a 3.0-only problem.

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 Ref API
  version: 1.0.0
paths: {}
components:
  schemas:
    NullableString:
      type: string
      nullable: true
    UnionNullString:
      type: [string, 'null']

    InlineProp:
      type: object
      properties:
        p:
          type: string
          nullable: true
    RefProp:
      type: object
      properties:
        p:
          $ref: '#/components/schemas/NullableString'
    RefPropRestatingNullable:
      type: object
      properties:
        p:
          $ref: '#/components/schemas/NullableString'
          nullable: true
    RefPropTypeUnion:
      type: object
      properties:
        p:
          $ref: '#/components/schemas/UnionNullString'
YAML;

$validator = OpenApiValidatorBuilder::create()->fromYamlString($yaml)->build();

foreach (['InlineProp', 'RefProp', 'RefPropRestatingNullable', 'RefPropTypeUnion'] as $name) {
    try {
        $validator->validateSchema(['p' => null], "#/components/schemas/$name");
        printf("%-26s PASS\n", $name);
    } catch (Throwable $e) {
        printf("%-26s %s\n", $name, $e->getMessage());
    }
}

All four are the same assertion — "a nullable property accepts null" — so all four should pass.

Actual result

InlineProp                 PASS
RefProp                    Schema validation failed
RefPropRestatingNullable   PASS
RefPropTypeUnion           PASS

RefProp fails with ValidationException, whose single error is:

[type] dataPath=/p schemaPath=/type
Expected type "string", but got "null"
params: {"expected":"string","actual":"null","reason":null}

Trace through SchemaValidatorWithContext::validateInternal()PropertiesValidatorWithContext:65PropertiesAndItemsDispatcher:42.

Worth noting that PropertiesValidatorWithContext:56-58 already anticipates this case when deciding $allowNull:

$allowNull = $context->nullableAsType && ($propertySchema->nullable
    || SchemaValueNormalizer::doesTypeIncludeNull($propertySchema->type)
    || null !== $propertySchema->ref);

The null !== $propertySchema->ref clause lets the null past normalisation, but the schema it is then validated against has already had its nullable erased by the merge, so it is rejected one level down.

Suggested fix

'nullable' => $sibling->nullable || $resolved->nullable,

Notes

Found while migrating a JSON:API service from league/openapi-psr7-validator. Our workaround is to fully dereference the spec before handing it to the builder, which sidesteps the sibling merge entirely — but that also defeats $ref reuse and roughly 9x's the parsed document.

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