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} |
FAIL — All of the schemas must match, but 1 failed |
PASS |
{type: object, allOf: [B], nullable: true} |
FAIL — same |
PASS |
{anyOf: [B], nullable: true} |
FAIL — At least one of the schemas must match, but none did |
PASS |
{oneOf: [B], nullable: true} |
FAIL — Exactly 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.
PHP version
8.5
duyler/openapi version
0.7.0
OpenAPI spec version
3.0
Description
In OpenAPI 3.0, a
$refcannot have siblings, so the documented way to make a referenced schema nullable is to wrap it inallOfand putnullable: truenext to it:duyler/openapirejectsnullfor this schema.nullableis honored on the schema being validated only whentypeis present (TypeValidator::validate()), but the composition validators consultnullableon the branch schemas rather than on the parent that carries the keyword, so thenullis 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
Schema, e.g.
nullable-allof.yaml:Actual result
Behaviour matrix
Same branch schema (
{type: object, required: [type, id], …}) in every row, viavalidateSchema():null{allOf: [B], nullable: true}All of the schemas must match, but 1 failed{type: object, allOf: [B], nullable: true}{anyOf: [B], nullable: true}At least one of the schemas must match, but none did{oneOf: [B], nullable: true}Exactly one of schemas must match, but none did{type: object, nullable: true, required: […]}(no composition){allOf: [B + nullable: true]}(nullable on the branch)Two things this pins down: it is specific to
null(valid objects are unaffected), and addingtype: objectbeside 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:44honorsnullableon the schema under validation:src/Validator/SchemaValidator/AbstractCompositionalValidator.php:112-119checks the branch instead, and never sees the parent'snullable:AllOfValidator::validate(),AnyOfValidator::validate()andOneOfValidator::validate()therefore dispatch branches without checking$schema->nullablefirst.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, mirroringTypeValidator. Equivalently, havenormalizeForBranch()take the parent's nullability into account.Secondary observation
When this fires,
ValidationException::getErrors()is empty and the message carries nodataPath, so a middleware cannot tell the client which member was rejected. Branch failures insideallOfappear to be dropped unless they areAbstractValidationErrorinstances (AbstractCompositionalValidator::validateBranch()). Worth a separate issue if you'd prefer.Spec reference
OAS 3.0.3, Schema Object:
nullable— "Allows sending anullvalue for the defined schema." TheallOf+nullablewrapper is the standard 3.0 workaround for$refsibling keywords being ignored, and is what code generators and editors emit for a nullable reference.