diff --git a/src/Validator/Response/ResponseBodyValidatorWithContext.php b/src/Validator/Response/ResponseBodyValidatorWithContext.php index 4d42532..efef5dc 100644 --- a/src/Validator/Response/ResponseBodyValidatorWithContext.php +++ b/src/Validator/Response/ResponseBodyValidatorWithContext.php @@ -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); @@ -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); diff --git a/src/Validator/Schema/DiscriminatorValidator.php b/src/Validator/Schema/DiscriminatorValidator.php index c81878c..107aae6 100644 --- a/src/Validator/Schema/DiscriminatorValidator.php +++ b/src/Validator/Schema/DiscriminatorValidator.php @@ -182,6 +182,6 @@ private function validateAgainstSchema( ): void { /** @var array $data */ $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $document); - $validator->validate($data, $schema, useDiscriminator: true); + $validator->validate($data, $schema, useDiscriminator: false); } } diff --git a/src/Validator/Schema/ItemsValidatorWithContext.php b/src/Validator/Schema/ItemsValidatorWithContext.php index f8179c7..b120a56 100644 --- a/src/Validator/Schema/ItemsValidatorWithContext.php +++ b/src/Validator/Schema/ItemsValidatorWithContext.php @@ -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; @@ -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| diff --git a/src/Validator/Schema/PropertiesValidatorWithContext.php b/src/Validator/Schema/PropertiesValidatorWithContext.php index bbf7d01..8929347 100644 --- a/src/Validator/Schema/PropertiesValidatorWithContext.php +++ b/src/Validator/Schema/PropertiesValidatorWithContext.php @@ -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; @@ -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| diff --git a/src/Validator/Schema/RefResolver.php b/src/Validator/Schema/RefResolver.php index e704e2c..b41fc26 100644 --- a/src/Validator/Schema/RefResolver.php +++ b/src/Validator/Schema/RefResolver.php @@ -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 { diff --git a/src/Validator/Schema/RefResolverInterface.php b/src/Validator/Schema/RefResolverInterface.php index 2340197..d1f66a4 100644 --- a/src/Validator/Schema/RefResolverInterface.php +++ b/src/Validator/Schema/RefResolverInterface.php @@ -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 $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 * diff --git a/src/Validator/Schema/SchemaValidatorWithContext.php b/src/Validator/Schema/SchemaValidatorWithContext.php index 3236b57..16f4f7e 100644 --- a/src/Validator/Schema/SchemaValidatorWithContext.php +++ b/src/Validator/Schema/SchemaValidatorWithContext.php @@ -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); @@ -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; @@ -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 = []; diff --git a/tests/Functional/Advanced/RefItemsValidationTest.php b/tests/Functional/Advanced/RefItemsValidationTest.php new file mode 100644 index 0000000..d31be39 --- /dev/null +++ b/tests/Functional/Advanced/RefItemsValidationTest.php @@ -0,0 +1,89 @@ +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); + } +} diff --git a/tests/Functional/Advanced/ReferenceResolutionTest.php b/tests/Functional/Advanced/ReferenceResolutionTest.php index 89aa8f0..636e78c 100644 --- a/tests/Functional/Advanced/ReferenceResolutionTest.php +++ b/tests/Functional/Advanced/ReferenceResolutionTest.php @@ -8,6 +8,8 @@ use PHPUnit\Framework\Attributes\Test; use Duyler\OpenApi\Builder\OpenApiValidatorBuilder; use Duyler\OpenApi\Validator\OpenApiValidator; +use Duyler\OpenApi\Validator\Exception\ValidationException; +use Duyler\OpenApi\Validator\Schema\Exception\UnresolvableRefException; final class ReferenceResolutionTest extends AdvancedFunctionalTestCase { @@ -36,6 +38,21 @@ public function local_ref_to_schema_valid(): void $this->expectNotToPerformAssertions(); } + #[Test] + public function local_ref_to_schema_missing_required_throws(): void + { + $validator = $this->createValidator($this->specFile); + $request = $this->createRequest('GET', '/schema-ref?id=user-123&name=John+Doe'); + + $operation = $validator->validateRequest($request); + $response = $this->createResponse(200, [ + 'foo' => 'bar', + ]); + + $this->expectException(ValidationException::class); + $validator->validateResponse($response, $operation); + } + #[Test] public function local_ref_to_parameter_valid(): void { @@ -144,8 +161,8 @@ public function invalid_ref_throws_error(): void 'test' => 'data', ]); + $this->expectException(UnresolvableRefException::class); $validator->validateResponse($response, $operation); - $this->expectNotToPerformAssertions(); } #[Test] diff --git a/tests/Validator/Schema/ItemsValidatorWithContextTest.php b/tests/Validator/Schema/ItemsValidatorWithContextTest.php index 5f8f920..f2395e5 100644 --- a/tests/Validator/Schema/ItemsValidatorWithContextTest.php +++ b/tests/Validator/Schema/ItemsValidatorWithContextTest.php @@ -346,7 +346,7 @@ public function validate_items_with_discriminator_in_nested_schema(): void #[Test] public function validate_items_throws_missing_discriminator_property(): void { - $this->expectNotToPerformAssertions(); + $this->expectException(MissingDiscriminatorPropertyException::class); $petSchema = new Schema( type: 'object', @@ -381,20 +381,16 @@ public function validate_items_throws_missing_discriminator_property(): void $document, ); - try { - $data = [ - ['name' => 'Fluffy'], - ]; - $validator->validateWithContext($data, $schema, $this->context); - } catch (MissingDiscriminatorPropertyException|ValidationException) { - return; - } + $data = [ + ['name' => 'Fluffy'], + ]; + $validator->validateWithContext($data, $schema, $this->context); } #[Test] public function validate_items_throws_unknown_discriminator_value(): void { - $this->expectNotToPerformAssertions(); + $this->expectException(UnknownDiscriminatorValueException::class); $catSchema = new Schema( type: 'object', @@ -452,14 +448,10 @@ public function validate_items_throws_unknown_discriminator_value(): void $document, ); - try { - $data = [ - ['petType' => 'bird'], - ]; - $validator->validateWithContext($data, $schema, $this->context); - } catch (UnknownDiscriminatorValueException|ValidationException) { - return; - } + $data = [ + ['petType' => 'bird'], + ]; + $validator->validateWithContext($data, $schema, $this->context); } #[Test] diff --git a/tests/Validator/Schema/PropertiesValidatorWithContextTest.php b/tests/Validator/Schema/PropertiesValidatorWithContextTest.php index 75e49ff..6ecc0ff 100644 --- a/tests/Validator/Schema/PropertiesValidatorWithContextTest.php +++ b/tests/Validator/Schema/PropertiesValidatorWithContextTest.php @@ -356,7 +356,7 @@ public function validate_properties_with_discriminator_schema(): void 'pet' => ['name' => 'Fluffy'], ]; - $this->validator->validateWithContext($data, $schema, $this->context); + $validator->validateWithContext($data, $schema, $this->context); $this->assertTrue(true); } @@ -398,11 +398,22 @@ public function validate_properties_with_nested_object(): void #[Test] public function validate_properties_with_nested_discriminator_schema(): void { + $catSchema = new Schema( + title: 'cat', + type: 'object', + properties: [ + 'petType' => new Schema(type: 'string'), + ], + ); + $petSchema = new Schema( type: 'object', discriminator: new Discriminator( propertyName: 'petType', ), + oneOf: [ + new Schema(ref: '#/components/schemas/Cat'), + ], ); $schema = new Schema( @@ -420,6 +431,7 @@ public function validate_properties_with_nested_discriminator_schema(): void components: new Components( schemas: [ 'Pet' => $petSchema, + 'Cat' => $catSchema, ], ), ); diff --git a/tests/Validator/Schema/RefResolverTest.php b/tests/Validator/Schema/RefResolverTest.php index 12333bc..6ee9c51 100644 --- a/tests/Validator/Schema/RefResolverTest.php +++ b/tests/Validator/Schema/RefResolverTest.php @@ -580,4 +580,371 @@ public function resolves_relative_ref_from_nested_directory(): void $this->assertSame('https://api.example.com/api/v2/paths/users.yaml', $resolved); } + + #[Test] + public function schema_has_ref_returns_true(): void + { + $schema = new Schema(ref: '#/components/schemas/User'); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_without_ref_returns_false(): void + { + $schema = new Schema(type: 'string'); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_property_containing_ref_returns_true(): void + { + $propertySchema = new Schema(ref: '#/components/schemas/User'); + $parentSchema = new Schema(properties: ['user' => $propertySchema]); + + $this->assertTrue($this->resolver->schemaHasRef($parentSchema)); + } + + #[Test] + public function schema_with_property_without_ref_returns_false(): void + { + $propertySchema = new Schema(type: 'string'); + $parentSchema = new Schema(properties: ['name' => $propertySchema]); + + $this->assertFalse($this->resolver->schemaHasRef($parentSchema)); + } + + #[Test] + public function schema_with_items_containing_ref_returns_true(): void + { + $itemsSchema = new Schema(ref: '#/components/schemas/Item'); + $arraySchema = new Schema(items: $itemsSchema); + + $this->assertTrue($this->resolver->schemaHasRef($arraySchema)); + } + + #[Test] + public function schema_with_items_without_ref_returns_false(): void + { + $itemsSchema = new Schema(type: 'string'); + $arraySchema = new Schema(items: $itemsSchema); + + $this->assertFalse($this->resolver->schemaHasRef($arraySchema)); + } + + #[Test] + public function schema_with_prefix_items_containing_ref_returns_true(): void + { + $prefixItemSchema = new Schema(ref: '#/components/schemas/Item'); + $schema = new Schema(prefixItems: [$prefixItemSchema]); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_prefix_items_without_ref_returns_false(): void + { + $prefixItemSchema = new Schema(type: 'string'); + $schema = new Schema(prefixItems: [$prefixItemSchema]); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_allof_containing_ref_returns_true(): void + { + $subSchema = new Schema(ref: '#/components/schemas/Base'); + $schema = new Schema(allOf: [$subSchema]); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_allof_without_ref_returns_false(): void + { + $subSchema = new Schema(type: 'object'); + $schema = new Schema(allOf: [$subSchema]); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_anyof_containing_ref_returns_true(): void + { + $subSchema = new Schema(ref: '#/components/schemas/Option'); + $schema = new Schema(anyOf: [$subSchema]); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_anyof_without_ref_returns_false(): void + { + $subSchema = new Schema(type: 'string'); + $schema = new Schema(anyOf: [$subSchema]); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_oneof_containing_ref_returns_true(): void + { + $subSchema = new Schema(ref: '#/components/schemas/Variant'); + $schema = new Schema(oneOf: [$subSchema]); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_oneof_without_ref_returns_false(): void + { + $subSchema = new Schema(type: 'string'); + $schema = new Schema(oneOf: [$subSchema]); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_not_containing_ref_returns_true(): void + { + $notSchema = new Schema(ref: '#/components/schemas/Forbidden'); + $schema = new Schema(not: $notSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_not_without_ref_returns_false(): void + { + $notSchema = new Schema(type: 'null'); + $schema = new Schema(not: $notSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_if_containing_ref_returns_true(): void + { + $ifSchema = new Schema(ref: '#/components/schemas/Condition'); + $schema = new Schema(if: $ifSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_if_without_ref_returns_false(): void + { + $ifSchema = new Schema(type: 'object'); + $schema = new Schema(if: $ifSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_then_containing_ref_returns_true(): void + { + $thenSchema = new Schema(ref: '#/components/schemas/ThenSchema'); + $schema = new Schema(then: $thenSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_then_without_ref_returns_false(): void + { + $thenSchema = new Schema(type: 'object'); + $schema = new Schema(then: $thenSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_else_containing_ref_returns_true(): void + { + $elseSchema = new Schema(ref: '#/components/schemas/ElseSchema'); + $schema = new Schema(else: $elseSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_else_without_ref_returns_false(): void + { + $elseSchema = new Schema(type: 'object'); + $schema = new Schema(else: $elseSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_contains_containing_ref_returns_true(): void + { + $containsSchema = new Schema(ref: '#/components/schemas/Item'); + $schema = new Schema(contains: $containsSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_contains_without_ref_returns_false(): void + { + $containsSchema = new Schema(type: 'string'); + $schema = new Schema(contains: $containsSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_pattern_properties_containing_ref_returns_true(): void + { + $patternSchema = new Schema(ref: '#/components/schemas/Value'); + $schema = new Schema(patternProperties: ['^x-' => $patternSchema]); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_pattern_properties_without_ref_returns_false(): void + { + $patternSchema = new Schema(type: 'string'); + $schema = new Schema(patternProperties: ['^x-' => $patternSchema]); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_dependent_schemas_containing_ref_returns_true(): void + { + $dependentSchema = new Schema(ref: '#/components/schemas/Dependent'); + $schema = new Schema(dependentSchemas: ['foo' => $dependentSchema]); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_dependent_schemas_without_ref_returns_false(): void + { + $dependentSchema = new Schema(type: 'object'); + $schema = new Schema(dependentSchemas: ['foo' => $dependentSchema]); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_property_names_containing_ref_returns_true(): void + { + $propertyNamesSchema = new Schema(ref: '#/components/schemas/NamePattern'); + $schema = new Schema(propertyNames: $propertyNamesSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_property_names_without_ref_returns_false(): void + { + $propertyNamesSchema = new Schema(type: 'string'); + $schema = new Schema(propertyNames: $propertyNamesSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_unevaluated_items_containing_ref_returns_true(): void + { + $unevaluatedItemsSchema = new Schema(ref: '#/components/schemas/Item'); + $schema = new Schema(unevaluatedItems: $unevaluatedItemsSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_unevaluated_items_without_ref_returns_false(): void + { + $unevaluatedItemsSchema = new Schema(type: 'string'); + $schema = new Schema(unevaluatedItems: $unevaluatedItemsSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_additional_properties_containing_ref_returns_true(): void + { + $additionalPropertiesSchema = new Schema(ref: '#/components/schemas/Value'); + $schema = new Schema(additionalProperties: $additionalPropertiesSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_additional_properties_bool_returns_false(): void + { + $schema = new Schema(additionalProperties: true); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_with_nested_refs_returns_true(): void + { + $deepSchema = new Schema(ref: '#/components/schemas/Deep'); + $midSchema = new Schema(properties: ['deep' => $deepSchema]); + $topSchema = new Schema(properties: ['mid' => $midSchema]); + + $this->assertTrue($this->resolver->schemaHasRef($topSchema)); + } + + #[Test] + public function schema_has_ref_in_property_names(): void + { + $propertyNamesSchema = new Schema(ref: '#/components/schemas/NamePattern'); + $schema = new Schema(propertyNames: $propertyNamesSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_has_ref_in_unevaluated_items(): void + { + $unevaluatedItemsSchema = new Schema(ref: '#/components/schemas/Item'); + $schema = new Schema(unevaluatedItems: $unevaluatedItemsSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_has_ref_in_additional_properties_as_schema(): void + { + $additionalPropertiesSchema = new Schema(ref: '#/components/schemas/Value'); + $schema = new Schema(additionalProperties: $additionalPropertiesSchema); + + $this->assertTrue($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_without_ref_in_property_names(): void + { + $propertyNamesSchema = new Schema(type: 'string'); + $schema = new Schema(propertyNames: $propertyNamesSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_without_ref_in_unevaluated_items(): void + { + $unevaluatedItemsSchema = new Schema(type: 'string'); + $schema = new Schema(unevaluatedItems: $unevaluatedItemsSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } + + #[Test] + public function schema_without_ref_in_additional_properties_as_schema(): void + { + $additionalPropertiesSchema = new Schema(type: 'string'); + $schema = new Schema(additionalProperties: $additionalPropertiesSchema); + + $this->assertFalse($this->resolver->schemaHasRef($schema)); + } } diff --git a/tests/Validator/Schema/SchemaValidatorWithContextResolveRefTest.php b/tests/Validator/Schema/SchemaValidatorWithContextResolveRefTest.php new file mode 100644 index 0000000..8859dd1 --- /dev/null +++ b/tests/Validator/Schema/SchemaValidatorWithContextResolveRefTest.php @@ -0,0 +1,339 @@ +refResolver = new RefResolver(); + $this->pool = new ValidatorPool(); + + $itemSchema = new Schema( + type: 'object', + required: ['id'], + properties: [ + 'id' => new Schema(type: 'integer', minimum: 1), + 'name' => new Schema(type: 'string'), + ], + ); + + $this->document = new OpenApiDocument( + '3.1.0', + new InfoObject('Test API', '1.0.0'), + components: new Components( + schemas: [ + 'Item' => $itemSchema, + ], + ), + ); + } + + #[Test] + public function validate_resolves_ref_and_validates_required(): void + { + $schema = new Schema(ref: '#/components/schemas/Item'); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + 'id' => 1, + 'name' => 'Test', + ]; + + $validator->validate($data, $schema); + + $this->assertTrue(true); + } + + #[Test] + public function validate_resolves_ref_and_throws_for_missing_required(): void + { + $schema = new Schema(ref: '#/components/schemas/Item'); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + 'name' => 'Test', + ]; + + $this->expectException(ValidationException::class); + $validator->validate($data, $schema); + } + + #[Test] + public function validate_resolves_ref_and_throws_for_invalid_type(): void + { + $schema = new Schema(ref: '#/components/schemas/Item'); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + 'id' => 'not-an-integer', + ]; + + $this->expectException(ValidationException::class); + $validator->validate($data, $schema); + } + + #[Test] + public function validate_resolves_ref_and_throws_for_minimum_violation(): void + { + $schema = new Schema(ref: '#/components/schemas/Item'); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + 'id' => 0, + ]; + + $this->expectException(ValidationException::class); + $validator->validate($data, $schema); + } + + #[Test] + public function validate_without_ref_passes(): void + { + $schema = new Schema( + type: 'object', + required: ['id'], + properties: [ + 'id' => new Schema(type: 'integer'), + ], + ); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = ['id' => 1]; + + $validator->validate($data, $schema); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_context_resolves_ref_and_validates(): void + { + $schema = new Schema(ref: '#/components/schemas/Item'); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + $context = ValidationContext::create($this->pool); + + $data = [ + 'id' => 1, + ]; + + $validator->validateWithContext($data, $schema, $context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_context_resolves_ref_and_throws_for_missing_required(): void + { + $schema = new Schema(ref: '#/components/schemas/Item'); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + $context = ValidationContext::create($this->pool); + + $data = []; + + $this->expectException(ValidationException::class); + $validator->validateWithContext($data, $schema, $context); + } + + #[Test] + public function validate_nested_ref_in_properties(): void + { + $containerSchema = new Schema( + type: 'object', + properties: [ + 'item' => new Schema(ref: '#/components/schemas/Item'), + ], + ); + + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + 'item' => [ + 'id' => 1, + ], + ]; + + $validator->validate($data, $containerSchema); + + $this->assertTrue(true); + } + + #[Test] + public function validate_nested_ref_in_properties_throws_for_invalid_data(): void + { + $containerSchema = new Schema( + type: 'object', + properties: [ + 'item' => new Schema(ref: '#/components/schemas/Item'), + ], + ); + + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + 'item' => [ + 'name' => 'Invalid - missing id', + ], + ]; + + $this->expectException(ValidationException::class); + $validator->validate($data, $containerSchema); + } + + #[Test] + public function validate_ref_in_items(): void + { + $arraySchema = new Schema( + type: 'array', + items: new Schema(ref: '#/components/schemas/Item'), + ); + + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + ['id' => 1], + ['id' => 2, 'name' => 'Test'], + ]; + + $validator->validate($data, $arraySchema); + + $this->assertTrue(true); + } + + #[Test] + public function validate_ref_in_items_throws_for_invalid_data(): void + { + $arraySchema = new Schema( + type: 'array', + items: new Schema(ref: '#/components/schemas/Item'), + ); + + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + ['name' => 'Invalid - missing id'], + ]; + + $this->expectException(ValidationException::class); + $validator->validate($data, $arraySchema); + } + + #[Test] + public function validate_with_nested_ref_in_allof(): void + { + $extendedItemSchema = new Schema( + allOf: [ + new Schema(ref: '#/components/schemas/Item'), + new Schema( + properties: [ + 'extra' => new Schema(type: 'string'), + ], + ), + ], + ); + + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = [ + 'id' => 1, + 'extra' => 'value', + ]; + + $validator->validate($data, $extendedItemSchema); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_nested_ref_in_anyof(): void + { + $anyOfSchema = new Schema( + anyOf: [ + new Schema(ref: '#/components/schemas/Item'), + new Schema(type: 'string'), + ], + ); + + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = ['id' => 1]; + + $validator->validate($data, $anyOfSchema); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_nested_ref_in_oneof(): void + { + $oneOfSchema = new Schema( + oneOf: [ + new Schema(ref: '#/components/schemas/Item'), + new Schema(type: 'string'), + ], + ); + + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + + $data = ['id' => 1]; + + $validator->validate($data, $oneOfSchema); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_context_without_ref(): void + { + $schema = new Schema( + type: 'object', + required: ['value'], + properties: [ + 'value' => new Schema(type: 'string'), + ], + ); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + $context = ValidationContext::create($this->pool); + + $data = ['value' => 'test']; + + $validator->validateWithContext($data, $schema, $context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_context_without_ref_throws_for_invalid(): void + { + $schema = new Schema( + type: 'object', + required: ['value'], + properties: [ + 'value' => new Schema(type: 'string'), + ], + ); + $validator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document); + $context = ValidationContext::create($this->pool); + + $data = []; + + $this->expectException(ValidationException::class); + $validator->validateWithContext($data, $schema, $context); + } +} diff --git a/tests/fixtures/advanced-specs/ref-items-validation.yaml b/tests/fixtures/advanced-specs/ref-items-validation.yaml new file mode 100644 index 0000000..88851e7 --- /dev/null +++ b/tests/fixtures/advanced-specs/ref-items-validation.yaml @@ -0,0 +1,29 @@ +openapi: 3.0.0 +info: + title: Ref Items Validation API + version: 1.0.0 +paths: + /items: + get: + operationId: items.get + responses: + '200': + description: 'result' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MyItem' +components: + schemas: + MyItem: + title: MyItem + required: + - id + properties: + id: + type: integer + minimum: 1 + nullable: false + type: object