Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- `nullable: true` is now honoured when it sits beside a composition keyword
instead of only on the branch schemas, so the standard OAS 3.0 workaround
for a nullable `$ref` — `{allOf: [$ref], nullable: true}` — accepts `null`.
`AllOfValidator`, `AnyOfValidator`, `OneOfValidator`,
`OneOfValidatorWithContext` and `IfThenElseValidator` short-circuit when the
schema carrying the keyword is nullable, rather than dispatching `null` into
branches that reject it. `anyOf`/`oneOf` treat this as the keyword being
satisfied, not as a matching branch, so `oneOf` still enforces exactly-one
for non-null data. A `null` member of an OAS 3.1 `type` union is ordinary
JSON Schema and keeps composing — only `nullable: true` waives branches, and
only while `nullableAsType` is enabled (#50).

## [0.7.0]

Preparation for the 1.0.0 stable release. This section tracks work that
Expand Down
4 changes: 4 additions & 0 deletions src/Validator/Schema/OneOfValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ private function validate(mixed $data, Schema $schema, ValidationContext $contex
return;
}

if (null === $data && SchemaValueNormalizer::isNullableSchema($schema, $context->nullableAsType)) {
return;
}

if ($useDiscriminator && null !== $schema->discriminator) {
$this->validateWithDiscriminator($data, $schema, $context);
return;
Expand Down
6 changes: 6 additions & 0 deletions src/Validator/Schema/SchemaValueNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Duyler\OpenApi\Validator\Schema;

use Duyler\OpenApi\Schema\Model\Schema;
use Duyler\OpenApi\Validator\Exception\InvalidDataTypeException;
use stdClass;

Expand Down Expand Up @@ -50,6 +51,11 @@ public static function normalize(mixed $value, bool $allowNull = false): array|i
));
}

public static function isNullableSchema(Schema $schema, bool $nullableAsType): bool
{
return $nullableAsType && $schema->nullable;
}

/**
* @param string|array<int, string|null>|null $type
*/
Expand Down
8 changes: 8 additions & 0 deletions src/Validator/SchemaValidator/AbstractSchemaValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@

namespace Duyler\OpenApi\Validator\SchemaValidator;

use Duyler\OpenApi\Schema\Model\Schema;
use Duyler\OpenApi\Validator\Error\ValidationContext;
use Duyler\OpenApi\Validator\PregExecutor;
use Duyler\OpenApi\Validator\Schema\RegexValidator;
use Duyler\OpenApi\Validator\Schema\SchemaValueNormalizer;
use Duyler\OpenApi\Validator\ValidatorPool;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Log\LoggerInterface;
Expand All @@ -20,6 +22,12 @@ public function __construct(
protected readonly ValidatorDependencies $dependencies,
) {}

protected function acceptsNullAsNullable(mixed $data, Schema $schema, ?ValidationContext $context): bool
{
return null === $data
&& SchemaValueNormalizer::isNullableSchema($schema, $context?->nullableAsType ?? true);
}

protected function getDataPath(?ValidationContext $context): string
{
if (null === $context) {
Expand Down
4 changes: 4 additions & 0 deletions src/Validator/SchemaValidator/AllOfValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex
return;
}

if ($this->acceptsNullAsNullable($data, $schema, $context)) {
return;
}

$result = $this->validateSchemas($schema->allOf, $data, $context, 'allOf');

if ([] !== $result->errors || [] !== $result->abstractErrors) {
Expand Down
4 changes: 4 additions & 0 deletions src/Validator/SchemaValidator/AnyOfValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex
return;
}

if ($this->acceptsNullAsNullable($data, $schema, $context)) {
return;
}

$result = $this->validateSchemas($schema->anyOf, $data, $context, 'anyOf');

if (0 === $result->validCount) {
Expand Down
4 changes: 4 additions & 0 deletions src/Validator/SchemaValidator/IfThenElseValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex
return;
}

if ($this->acceptsNullAsNullable($data, $schema, $context)) {
return;
}

if (is_bool($schema->if)) {
$this->routeThenOrElse(schema: $schema, data: $data, context: $context, ifValid: $schema->if);

Expand Down
4 changes: 4 additions & 0 deletions src/Validator/SchemaValidator/OneOfValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex
return;
}

if ($this->acceptsNullAsNullable($data, $schema, $context)) {
return;
}

$result = $this->validateSchemas($schema->oneOf, $data, $context, 'oneOf');

if (0 === $result->validCount) {
Expand Down
3 changes: 2 additions & 1 deletion src/Validator/SchemaValidator/TypeValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Duyler\OpenApi\Validator\EmptyArrayStrategy;
use Duyler\OpenApi\Validator\Error\ValidationContext;
use Duyler\OpenApi\Validator\Exception\TypeMismatchError;
use Duyler\OpenApi\Validator\Schema\SchemaValueNormalizer;
use Duyler\OpenApi\Validator\TypeFormatter;
use Override;

Expand Down Expand Up @@ -41,7 +42,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex

$nullableAsType = $context?->nullableAsType ?? true;

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

Expand Down
131 changes: 131 additions & 0 deletions tests/Functional/Response/NullableCompositionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

declare(strict_types=1);

namespace Duyler\OpenApi\Test\Functional\Response;

use Duyler\OpenApi\Builder\OpenApiValidatorBuilder;
use Duyler\OpenApi\Validator\Exception\ValidationException;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

/**
* Regression coverage for a JSON:API-shaped OAS 3.0 document where the nullable relationship is
* expressed as `allOf: [$ref]` plus `nullable: true`, reached through a nested `properties` chain.
*
* @see https://github.com/duyler/openapi/issues/50
*
* @internal
*/
final class NullableCompositionTest extends TestCase
{
private const string SPEC = <<<'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:
allOf:
- $ref: '#/components/schemas/FormIdentifier'
nullable: true
YAML;

private Psr17Factory $psrFactory;

protected function setUp(): void
{
$this->psrFactory = new Psr17Factory();
}

#[Test]
public function null_relationship_is_accepted_through_nested_properties(): void
{
$this->validateResponseBody(
'{"data":{"type":"actions","id":"1","relationships":{"form":{"data":null}}}}',
);

$this->expectNotToPerformAssertions();
}

#[Test]
public function populated_relationship_is_accepted_through_nested_properties(): void
{
$this->validateResponseBody(
'{"data":{"type":"actions","id":"1","relationships":{"form":{"data":{"type":"forms","id":"7"}}}}}',
);

$this->expectNotToPerformAssertions();
}

#[Test]
public function invalid_relationship_is_still_rejected_through_nested_properties(): void
{
$this->expectException(ValidationException::class);

$this->validateResponseBody(
'{"data":{"type":"actions","id":"1","relationships":{"form":{"data":{"type":"widgets"}}}}}',
);
}

private function validateResponseBody(string $body): void
{
$validator = OpenApiValidatorBuilder::create()
->fromYamlString(self::SPEC)
->build();

$operation = $validator->validateRequest(
$this->psrFactory->createServerRequest('GET', '/actions/1'),
);

$response = $this->psrFactory->createResponse(200)
->withHeader('Content-Type', 'application/json')
->withBody($this->psrFactory->createStream($body));

$validator->validateResponse($response, $operation);
}
}
29 changes: 29 additions & 0 deletions tests/Unit/Validator/SchemaValidator/AllOfValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies;

use Duyler\OpenApi\Schema\Model\Schema;
use Duyler\OpenApi\Validator\Error\ValidationContext;
use Duyler\OpenApi\Validator\Exception\ValidationException;
use Duyler\OpenApi\Validator\ValidatorPool;
use Duyler\OpenApi\Validator\Format\BuiltinFormats;
Expand Down Expand Up @@ -237,4 +238,32 @@ public function allOf_propagates_invalid_data_type_errors(): void
self::assertGreaterThan(0, count($errors));
}
}

#[Test]
public function allOf_passes_when_parent_schema_is_nullable_and_data_is_null(): void
{
$schema = new Schema(
nullable: true,
allOf: [new Schema(type: 'object', required: ['id'])],
);

$this->validator->validate(null, $schema);

$this->expectNotToPerformAssertions();
}

#[Test]
public function allOf_rejects_null_when_parent_nullable_is_not_honored(): void
{
$schema = new Schema(
nullable: true,
allOf: [new Schema(type: 'object', required: ['id'])],
);

$context = ValidationContext::create($this->pool, nullableAsType: false);

$this->expectException(ValidationException::class);

$this->validator->validate(null, $schema, $context);
}
}
28 changes: 28 additions & 0 deletions tests/Unit/Validator/SchemaValidator/AnyOfValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -239,4 +239,32 @@ public function anyOf_passes_when_multiple_nullable_schemas_match_null(): void

$this->expectNotToPerformAssertions();
}

#[Test]
public function anyOf_passes_when_parent_schema_is_nullable_and_data_is_null(): void
{
$schema = new Schema(
nullable: true,
anyOf: [new Schema(type: 'object', required: ['id'])],
);

$this->validator->validate(null, $schema);

$this->expectNotToPerformAssertions();
}

#[Test]
public function anyOf_rejects_null_when_parent_nullable_is_not_honored(): void
{
$schema = new Schema(
nullable: true,
anyOf: [new Schema(type: 'object', required: ['id'])],
);

$context = ValidationContext::create($this->pool, nullableAsType: false);

$this->expectException(ValidationException::class);

$this->validator->validate(null, $schema, $context);
}
}
32 changes: 32 additions & 0 deletions tests/Unit/Validator/SchemaValidator/IfThenElseValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
use Duyler\OpenApi\Validator\SchemaValidator\ValidatorDependencies;

use Duyler\OpenApi\Schema\Model\Schema;
use Duyler\OpenApi\Validator\Error\ValidationContext;
use Duyler\OpenApi\Validator\Exception\InvalidDataTypeException;
use Duyler\OpenApi\Validator\Exception\MaximumError;
use Duyler\OpenApi\Validator\ValidatorPool;
use Duyler\OpenApi\Validator\Format\BuiltinFormats;
Expand Down Expand Up @@ -156,4 +158,34 @@ public function not_apply_else_when_if_valid(): void

$this->expectNotToPerformAssertions();
}

#[Test]
public function skip_branches_when_parent_schema_is_nullable_and_data_is_null(): void
{
$schema = new Schema(
nullable: true,
if: new Schema(type: 'string'),
else: new Schema(type: 'object', required: ['id']),
);

$this->validator->validate(null, $schema);

$this->expectNotToPerformAssertions();
}

#[Test]
public function apply_branches_to_null_when_parent_nullable_is_not_honored(): void
{
$schema = new Schema(
nullable: true,
if: new Schema(type: 'string'),
else: new Schema(type: 'object', required: ['id']),
);

$context = ValidationContext::create($this->pool, nullableAsType: false);

$this->expectException(InvalidDataTypeException::class);

$this->validator->validate(null, $schema, $context);
}
}
Loading