Skip to content
Merged
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
6 changes: 4 additions & 2 deletions src/Validator/Response/ResponseBodyValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,11 @@ private function validateRegularContent(
if (null !== $mediaTypeSchema->schema) {
$schema = $mediaTypeSchema->schema;
$hasDiscriminator = null !== $schema->discriminator || $this->refResolver->schemaHasDiscriminator($schema, $this->document);
$hasRef = $this->refResolver->schemaHasRef($schema);

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

if ($hasDiscriminator) {
if ($hasDiscriminator || $hasRef) {
$this->contextSchemaValidator->validate($parsedBody, $schema);
} else {
$this->regularSchemaValidator->validate($parsedBody, $schema, $context);
Expand All @@ -107,10 +108,11 @@ private function validateStreamingContent(

$context = ValidationContext::create($this->pool, $this->nullableAsType, $this->emptyArrayStrategy);
$hasDiscriminator = null !== $schema->discriminator || $this->refResolver->schemaHasDiscriminator($schema, $this->document);
$hasRef = $this->refResolver->schemaHasRef($schema);

foreach ($items as $item) {
if (null !== $item) {
if ($hasDiscriminator) {
if ($hasDiscriminator || $hasRef) {
$this->contextSchemaValidator->validate($item, $schema);
} else {
$this->regularSchemaValidator->validate($item, $schema, $context);
Expand Down
2 changes: 1 addition & 1 deletion src/Validator/Schema/DiscriminatorValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,6 @@ private function validateAgainstSchema(
): void {
/** @var array<array-key, mixed> $data */
$validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $document);
$validator->validate($data, $schema, useDiscriminator: true);
$validator->validate($data, $schema, useDiscriminator: false);
}
}
4 changes: 2 additions & 2 deletions src/Validator/Schema/ItemsValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public function __construct(
private readonly OpenApiDocument $document,
) {}

public function validateWithContext(array $data, Schema $schema, ValidationContext $context): void
public function validateWithContext(array $data, Schema $schema, ValidationContext $context, bool $useDiscriminator = true): void
{
if (null === $schema->items) {
return;
Expand All @@ -43,7 +43,7 @@ public function validateWithContext(array $data, Schema $schema, ValidationConte
$allowNull = $itemSchema->nullable && $context->nullableAsType;
$normalizedItem = SchemaValueNormalizer::normalize($item, $allowNull);
$validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document);
$validator->validateWithContext($normalizedItem, $itemSchema, $itemContext);
$validator->validateWithContext($normalizedItem, $itemSchema, $itemContext, $useDiscriminator);
} catch (DiscriminatorMismatchException|
InvalidDiscriminatorValueException|
MissingDiscriminatorPropertyException|
Expand Down
4 changes: 2 additions & 2 deletions src/Validator/Schema/PropertiesValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public function __construct(
private readonly OpenApiDocument $document,
) {}

public function validateWithContext(array $data, Schema $schema, ValidationContext $context): void
public function validateWithContext(array $data, Schema $schema, ValidationContext $context, bool $useDiscriminator = true): void
{
if (null === $schema->properties || [] === $schema->properties) {
return;
Expand All @@ -47,7 +47,7 @@ public function validateWithContext(array $data, Schema $schema, ValidationConte
$propertyContext = $context->withBreadcrumb($name);

$validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document);
$validator->validateWithContext($value, $propertySchema, $propertyContext);
$validator->validateWithContext($value, $propertySchema, $propertyContext, $useDiscriminator);
} catch (DiscriminatorMismatchException|
InvalidDiscriminatorValueException|
MissingDiscriminatorPropertyException|
Expand Down
128 changes: 128 additions & 0 deletions src/Validator/Schema/RefResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,134 @@ public function schemaHasDiscriminator(Schema $schema, OpenApiDocument $document
return false;
}

#[Override]
public function schemaHasRef(Schema $schema, array &$visited = []): bool
{
$schemaId = spl_object_id($schema);

if (isset($visited[$schemaId])) {
return false;
}

$visited[$schemaId] = true;

if (null !== $schema->ref) {
return true;
}

if (null !== $schema->properties) {
foreach ($schema->properties as $property) {
if ($this->schemaHasRef($property, $visited)) {
return true;
}
}
}

if (null !== $schema->items) {
if ($this->schemaHasRef($schema->items, $visited)) {
return true;
}
}

if (null !== $schema->prefixItems) {
foreach ($schema->prefixItems as $prefixItem) {
if ($this->schemaHasRef($prefixItem, $visited)) {
return true;
}
}
}

if (null !== $schema->allOf) {
foreach ($schema->allOf as $subSchema) {
if ($this->schemaHasRef($subSchema, $visited)) {
return true;
}
}
}

if (null !== $schema->anyOf) {
foreach ($schema->anyOf as $subSchema) {
if ($this->schemaHasRef($subSchema, $visited)) {
return true;
}
}
}

if (null !== $schema->oneOf) {
foreach ($schema->oneOf as $subSchema) {
if ($this->schemaHasRef($subSchema, $visited)) {
return true;
}
}
}

if (null !== $schema->not) {
if ($this->schemaHasRef($schema->not, $visited)) {
return true;
}
}

if (null !== $schema->if) {
if ($this->schemaHasRef($schema->if, $visited)) {
return true;
}
}

if (null !== $schema->then) {
if ($this->schemaHasRef($schema->then, $visited)) {
return true;
}
}

if (null !== $schema->else) {
if ($this->schemaHasRef($schema->else, $visited)) {
return true;
}
}

if (null !== $schema->contains) {
if ($this->schemaHasRef($schema->contains, $visited)) {
return true;
}
}

if (null !== $schema->patternProperties) {
foreach ($schema->patternProperties as $subSchema) {
if ($this->schemaHasRef($subSchema, $visited)) {
return true;
}
}
}

if (null !== $schema->dependentSchemas) {
foreach ($schema->dependentSchemas as $subSchema) {
if ($this->schemaHasRef($subSchema, $visited)) {
return true;
}
}
}

if (null !== $schema->propertyNames) {
if ($this->schemaHasRef($schema->propertyNames, $visited)) {
return true;
}
}

if (null !== $schema->unevaluatedItems) {
if ($this->schemaHasRef($schema->unevaluatedItems, $visited)) {
return true;
}
}

if (null !== $schema->additionalProperties && $schema->additionalProperties instanceof Schema) {
if ($this->schemaHasRef($schema->additionalProperties, $visited)) {
return true;
}
}

return false;
}

#[Override]
public function resolveSchemaWithOverride(Schema $schema, OpenApiDocument $document): Schema
{
Expand Down
9 changes: 9 additions & 0 deletions src/Validator/Schema/RefResolverInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ public function resolveResponse(string $ref, OpenApiDocument $document): Respons
*/
public function schemaHasDiscriminator(Schema $schema, OpenApiDocument $document, array &$visited = []): bool;

/**
* Check if schema or any of its nested schemas contains $ref
*
* @param Schema $schema Schema to check
* @param array<int, bool> $visited Internal tracking to prevent infinite recursion
* @return bool True if $ref found, false otherwise
*/
public function schemaHasRef(Schema $schema, array &$visited = []): bool;

/**
* Get base URI from document's $self field
*
Expand Down
29 changes: 21 additions & 8 deletions src/Validator/Schema/SchemaValidatorWithContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public function validate(array|int|string|float|bool|null $data, Schema $schema,
{
$context = ValidationContext::create($this->pool, $this->nullableAsType, $this->emptyArrayStrategy);

$schema = $this->resolveRef($schema);

if ($useDiscriminator && null !== $schema->discriminator && null !== $schema->oneOf) {
$oneOfValidator = new OneOfValidatorWithContext($this->pool, $this->refResolver, $this->document);
$oneOfValidator->validateWithContext($data, $schema, $context, $useDiscriminator);
Expand All @@ -73,27 +75,29 @@ public function validate(array|int|string|float|bool|null $data, Schema $schema,

if (null !== $schema->properties && [] !== $schema->properties && is_array($data)) {
$propertiesValidator = new PropertiesValidatorWithContext($this->pool, $this->refResolver, $this->document);
$propertiesValidator->validateWithContext($data, $schema, $context);
$propertiesValidator->validateWithContext($data, $schema, $context, $useDiscriminator);
}

if (null !== $schema->items && is_array($data)) {
$itemsValidator = new ItemsValidatorWithContext($this->pool, $this->refResolver, $this->document);
$itemsValidator->validateWithContext($data, $schema, $context);
$itemsValidator->validateWithContext($data, $schema, $context, $useDiscriminator);
}
}

/**
* Validate data with existing ValidationContext for breadcrumb tracking
*/
public function validateWithContext(array|int|string|float|bool|null $data, Schema $schema, ValidationContext $context): void
public function validateWithContext(array|int|string|float|bool|null $data, Schema $schema, ValidationContext $context, bool $useDiscriminator = true): void
{
if (null !== $schema->discriminator && null !== $schema->oneOf) {
$schema = $this->resolveRef($schema);

if ($useDiscriminator && null !== $schema->discriminator && null !== $schema->oneOf) {
$oneOfValidator = new OneOfValidatorWithContext($this->pool, $this->refResolver, $this->document);
$oneOfValidator->validateWithContext($data, $schema, $context, useDiscriminator: true);
$oneOfValidator->validateWithContext($data, $schema, $context, $useDiscriminator);
return;
}

if (null !== $schema->discriminator && null !== $data) {
if ($useDiscriminator && null !== $schema->discriminator && null !== $data) {
$discriminatorValidator = new DiscriminatorValidator($this->refResolver, $this->pool);
$discriminatorValidator->validate($data, $schema, $this->document);
return;
Expand All @@ -103,15 +107,24 @@ public function validateWithContext(array|int|string|float|bool|null $data, Sche

if (null !== $schema->properties && [] !== $schema->properties && is_array($data)) {
$propertiesValidator = new PropertiesValidatorWithContext($this->pool, $this->refResolver, $this->document);
$propertiesValidator->validateWithContext($data, $schema, $context);
$propertiesValidator->validateWithContext($data, $schema, $context, $useDiscriminator);
}

if (null !== $schema->items && is_array($data)) {
$itemsValidator = new ItemsValidatorWithContext($this->pool, $this->refResolver, $this->document);
$itemsValidator->validateWithContext($data, $schema, $context);
$itemsValidator->validateWithContext($data, $schema, $context, $useDiscriminator);
}
}

private function resolveRef(Schema $schema): Schema
{
if (null === $schema->ref) {
return $schema;
}

return $this->refResolver->resolveSchemaWithOverride($schema, $this->document);
}

private function validateInternal(array|int|string|float|bool|null $data, Schema $schema, ?ValidationContext $context = null): void
{
$errors = [];
Expand Down
89 changes: 89 additions & 0 deletions tests/Functional/Advanced/RefItemsValidationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace Duyler\OpenApi\Test\Functional\Advanced;

use Duyler\OpenApi\Validator\Exception\ValidationException;
use Override;
use PHPUnit\Framework\Attributes\Test;

final class RefItemsValidationTest extends AdvancedFunctionalTestCase
{
private string $specFile;

#[Override]
protected function setUp(): void
{
parent::setUp();
$this->specFile = __DIR__ . '/../../fixtures/advanced-specs/ref-items-validation.yaml';
}

#[Test]
public function items_with_ref_valid(): void
{
$validator = $this->createValidator($this->specFile);
$request = $this->createRequest('GET', '/items');

$operation = $validator->validateRequest($request);
$response = $this->createResponse(200, [
[
'id' => 1,
],
]);

$validator->validateResponse($response, $operation);
$this->expectNotToPerformAssertions();
}

#[Test]
public function items_with_ref_missing_required_field_throws(): void
{
$validator = $this->createValidator($this->specFile);
$request = $this->createRequest('GET', '/items');

$operation = $validator->validateRequest($request);
$response = $this->createResponse(200, [
[
'foo' => 'bar',
],
]);

$this->expectException(ValidationException::class);
$validator->validateResponse($response, $operation);
}

#[Test]
public function items_with_ref_invalid_type_throws(): void
{
$validator = $this->createValidator($this->specFile);
$request = $this->createRequest('GET', '/items');

$operation = $validator->validateRequest($request);
$response = $this->createResponse(200, [
[
'id' => 'invalid-string',
],
]);

$this->expectException(ValidationException::class);
$validator->validateResponse($response, $operation);
}

#[Test]
public function items_with_ref_below_minimum_throws(): void
{
$validator = $this->createValidator($this->specFile);
$request = $this->createRequest('GET', '/items');

$operation = $validator->validateRequest($request);
$response = $this->createResponse(200, [
[
'id' => 0,
],
]);

$this->expectException(ValidationException::class);
$validator->validateResponse($response, $operation);
}
}
Loading