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:65 → PropertiesAndItemsDispatcher: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.
PHP version
8.5
duyler/openapi version
0.7.0
OpenAPI spec version
3.0
Description
ScalarSiblingMerger::merge()combines thenullableflag of a$refnode and its resolved target with a logical AND:Schema::$nullabledefaults tofalse(src/Schema/Model/Schema.php:75), and a bare{$ref: ...}node never sets it. So for every plain$ref, the merge evaluatesfalse && trueand the target'snullable: trueis discarded. The resolved schema keeps itstype: stringbut loses its nullability, and a legitimatenullis then rejected byTypeValidator:This makes a very common OpenAPI 3.0 arrangement unusable — declaring a nullable schema once under
components/schemasand reusing it by reference:The AND also looks inconsistent with how the same class merges every other scalar.
format,pattern,multipleOfand friends use "sibling wins if set, otherwise the resolved value" (mergeFormat,mergeNullableIdentical), andtypeusesmergeType. Onlynullableuses AND — and because its default isfalserather thannull, "not specified at the referring site" is indistinguishable from "explicitly not nullable", so the default silently overrides the target.Per OpenAPI 3.0, a
nullablesibling next to$refcan 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:
nullable: trueat the referring site works around it, which is exactly what the AND predicts.type: [string, 'null']is unaffected, because the type union travels throughmergeTyperather than thenullableflag. This is a 3.0-only problem.Steps to reproduce
All four are the same assertion — "a nullable property accepts
null" — so all four should pass.Actual result
RefPropfails withValidationException, whose single error is:Trace through
SchemaValidatorWithContext::validateInternal()→PropertiesValidatorWithContext:65→PropertiesAndItemsDispatcher:42.Worth noting that
PropertiesValidatorWithContext:56-58already anticipates this case when deciding$allowNull:The
null !== $propertySchema->refclause lets thenullpast normalisation, but the schema it is then validated against has already had itsnullableerased by the merge, so it is rejected one level down.Suggested fix
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$refreuse and roughly 9x's the parsed document.