From 5ee24dbaa291337733444eaec318db129bf06030 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Sun, 15 Feb 2026 13:07:16 +1000 Subject: [PATCH] ref: refactoring and bug fixes --- CHANGELOG.md | 21 + README.md | 67 ++ infection.log | 111 ++ psalm-baseline.xml | 803 +++++++++++++++ psalm.xml | 5 +- src/Builder/Exception/BuilderException.php | 2 +- src/Builder/OpenApiValidatorBuilder.php | 149 +-- src/Cache/TypedCacheDecorator.php | 2 +- src/Registry/SchemaRegistry.php | 2 +- .../Exception/InvalidSchemaException.php | 2 +- src/Schema/Model/Callbacks.php | 2 +- src/Schema/Model/Components.php | 2 +- src/Schema/Model/Contact.php | 2 +- src/Schema/Model/Content.php | 2 +- src/Schema/Model/Discriminator.php | 2 +- src/Schema/Model/Example.php | 2 +- src/Schema/Model/ExternalDocs.php | 2 +- src/Schema/Model/Header.php | 2 +- src/Schema/Model/Headers.php | 2 +- src/Schema/Model/InfoObject.php | 2 +- src/Schema/Model/License.php | 2 +- src/Schema/Model/Link.php | 2 +- src/Schema/Model/Links.php | 2 +- src/Schema/Model/MediaType.php | 2 +- src/Schema/Model/Operation.php | 2 +- src/Schema/Model/Parameter.php | 2 +- src/Schema/Model/Parameters.php | 2 +- src/Schema/Model/PathItem.php | 2 +- src/Schema/Model/Paths.php | 2 +- src/Schema/Model/RequestBody.php | 2 +- src/Schema/Model/Response.php | 2 +- src/Schema/Model/Responses.php | 2 +- src/Schema/Model/Schema.php | 2 +- src/Schema/Model/SecurityRequirement.php | 2 +- src/Schema/Model/SecurityScheme.php | 2 +- src/Schema/Model/Server.php | 2 +- src/Schema/Model/Servers.php | 2 +- src/Schema/Model/Tag.php | 2 +- src/Schema/Model/Tags.php | 2 +- src/Schema/Model/Webhooks.php | 2 +- src/Schema/OpenApiDocument.php | 2 +- src/Schema/Parser/JsonParser.php | 782 +------------- src/Schema/Parser/OpenApiBuilder.php | 775 ++++++++++++++ src/Schema/Parser/TypeHelper.php | 2 +- src/Schema/Parser/YamlParser.php | 799 +-------------- src/Validator/EmptyArrayStrategy.php | 13 + src/Validator/Error/BreadcrumbManager.php | 2 +- src/Validator/Error/ValidationContext.php | 19 +- src/Validator/Exception/AnyOfError.php | 2 +- src/Validator/Exception/ConstError.php | 2 +- .../DiscriminatorMismatchException.php | 2 +- src/Validator/Exception/EnumError.php | 2 +- .../InvalidDiscriminatorValueException.php | 2 +- .../Exception/InvalidFormatException.php | 2 +- src/Validator/Exception/MaxContainsError.php | 2 +- src/Validator/Exception/MaxItemsError.php | 2 +- src/Validator/Exception/MaxLengthError.php | 2 +- .../Exception/MaxPropertiesError.php | 2 +- src/Validator/Exception/MaximumError.php | 2 +- src/Validator/Exception/MinContainsError.php | 2 +- src/Validator/Exception/MinItemsError.php | 2 +- src/Validator/Exception/MinLengthError.php | 2 +- .../Exception/MinPropertiesError.php | 2 +- src/Validator/Exception/MinimumError.php | 2 +- .../MissingDiscriminatorPropertyException.php | 2 +- .../Exception/MissingParameterException.php | 2 +- src/Validator/Exception/MultipleOfError.php | 2 +- .../Exception/MultipleOfKeywordError.php | 2 +- src/Validator/Exception/OneOfError.php | 2 +- .../Exception/PathMismatchException.php | 2 +- .../Exception/PatternMismatchError.php | 2 +- src/Validator/Exception/RequiredError.php | 2 +- src/Validator/Exception/TypeMismatchError.php | 2 +- .../Exception/UndefinedResponseException.php | 2 +- .../UnknownDiscriminatorValueException.php | 2 +- .../UnsupportedMediaTypeException.php | 2 +- .../Exception/ValidationException.php | 2 +- src/Validator/Format/BuiltinFormats.php | 2 +- src/Validator/Format/FormatRegistry.php | 2 +- .../Format/Numeric/FloatDoubleValidator.php | 2 +- src/Validator/Format/String/ByteValidator.php | 2 +- .../Format/String/DateTimeValidator.php | 2 +- src/Validator/Format/String/DateValidator.php | 2 +- .../Format/String/DurationValidator.php | 2 +- .../Format/String/EmailValidator.php | 2 +- .../Format/String/HostnameValidator.php | 2 +- src/Validator/Format/String/Ipv4Validator.php | 2 +- src/Validator/Format/String/Ipv6Validator.php | 2 +- .../Format/String/JsonPointerValidator.php | 2 +- .../String/RelativeJsonPointerValidator.php | 2 +- src/Validator/Format/String/TimeValidator.php | 2 +- src/Validator/Format/String/UriValidator.php | 2 +- src/Validator/Format/String/UuidValidator.php | 2 +- src/Validator/OpenApiValidator.php | 11 +- src/Validator/Operation.php | 2 +- src/Validator/PathFinder.php | 2 +- .../Registry/DefaultValidatorRegistry.php | 2 +- .../Request/BodyParser/BodyParser.php | 2 +- .../Request/BodyParser/FormBodyParser.php | 2 +- .../Request/BodyParser/JsonBodyParser.php | 2 +- .../BodyParser/MultipartBodyParser.php | 2 +- .../Request/BodyParser/TextBodyParser.php | 2 +- .../Request/BodyParser/XmlBodyParser.php | 12 +- .../Request/ContentTypeNegotiator.php | 2 +- src/Validator/Request/CookieValidator.php | 2 +- src/Validator/Request/HeaderFinder.php | 2 +- src/Validator/Request/HeadersValidator.php | 2 +- .../Request/ParameterDeserializer.php | 2 +- .../Request/PathParametersValidator.php | 2 +- src/Validator/Request/PathParser.php | 2 +- .../Request/QueryParametersValidator.php | 2 +- src/Validator/Request/QueryParser.php | 2 +- src/Validator/Request/RequestBodyCoercer.php | 2 +- .../Request/RequestBodyValidator.php | 2 +- .../RequestBodyValidatorWithContext.php | 8 +- src/Validator/Request/RequestValidator.php | 2 +- src/Validator/Request/TypeCoercer.php | 2 +- .../Response/ResponseBodyValidator.php | 2 +- .../ResponseBodyValidatorWithContext.php | 8 +- .../Response/ResponseHeadersValidator.php | 2 +- .../Response/ResponseTypeCoercer.php | 2 +- src/Validator/Response/ResponseValidator.php | 2 +- .../Response/ResponseValidatorWithContext.php | 6 +- .../Response/StatusCodeValidator.php | 2 +- .../Schema/DiscriminatorValidator.php | 2 +- .../Exception/UnresolvableRefException.php | 2 +- .../Schema/ItemsValidatorWithContext.php | 2 +- .../Schema/OneOfValidatorWithContext.php | 2 +- .../Schema/PropertiesValidatorWithContext.php | 2 +- src/Validator/Schema/RefResolver.php | 42 +- .../Schema/SchemaValidatorWithContext.php | 6 +- .../Schema/SchemaValueNormalizer.php | 2 +- .../AdditionalPropertiesValidator.php | 2 +- .../SchemaValidator/AllOfValidator.php | 2 +- .../SchemaValidator/AnyOfValidator.php | 2 +- .../SchemaValidator/ArrayLengthValidator.php | 2 +- .../SchemaValidator/ConstValidator.php | 2 +- .../ContainsRangeValidator.php | 2 +- .../SchemaValidator/ContainsValidator.php | 2 +- .../DependentSchemasValidator.php | 2 +- .../SchemaValidator/EnumValidator.php | 2 +- .../SchemaValidator/FormatValidator.php | 2 +- .../SchemaValidator/IfThenElseValidator.php | 2 +- .../SchemaValidator/ItemsValidator.php | 2 +- .../SchemaValidator/NotValidator.php | 2 +- .../SchemaValidator/NumericRangeValidator.php | 2 +- .../SchemaValidator/ObjectLengthValidator.php | 2 +- .../SchemaValidator/OneOfValidator.php | 2 +- .../PatternPropertiesValidator.php | 2 +- .../SchemaValidator/PatternValidator.php | 2 +- .../SchemaValidator/PrefixItemsValidator.php | 2 +- .../SchemaValidator/PropertiesValidator.php | 2 +- .../PropertyNamesValidator.php | 2 +- .../SchemaValidator/RequiredValidator.php | 2 +- .../SchemaValidator/SchemaValidator.php | 2 +- .../SchemaValidator/StringLengthValidator.php | 2 +- .../SchemaValidator/TypeValidator.php | 55 +- .../UnevaluatedItemsValidator.php | 2 +- .../UnevaluatedPropertiesValidator.php | 2 +- src/Validator/TypeGuarantor.php | 2 +- src/Validator/ValidatorPool.php | 2 +- tests/Schema/Parser/JsonParserRefTest.php | 228 +++++ tests/Schema/Parser/OpenApiBuilderTest.php | 956 ++++++++++++++++++ tests/Schema/Parser/TypeHelperTest.php | 188 ++++ tests/Security/XmlSecurityTest.php | 220 ++++ .../Request/BodyParser/XmlBodyParserTest.php | 394 ++++++++ .../Schema/RefResolverCircularTest.php | 226 +++++ .../EmptyArrayStrategyTest.php | 224 ++++ .../fixtures/security-specs/xml-endpoint.yaml | 24 + 169 files changed, 4579 insertions(+), 1857 deletions(-) create mode 100644 infection.log create mode 100644 psalm-baseline.xml create mode 100644 src/Schema/Parser/OpenApiBuilder.php create mode 100644 src/Validator/EmptyArrayStrategy.php create mode 100644 tests/Schema/Parser/JsonParserRefTest.php create mode 100644 tests/Schema/Parser/OpenApiBuilderTest.php create mode 100644 tests/Security/XmlSecurityTest.php create mode 100644 tests/Validator/Request/BodyParser/XmlBodyParserTest.php create mode 100644 tests/Validator/Schema/RefResolverCircularTest.php create mode 100644 tests/Validator/SchemaValidator/EmptyArrayStrategyTest.php create mode 100644 tests/fixtures/security-specs/xml-endpoint.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index f32f20d..e630496 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. +## [Unreleased] + +### Security +- Fixed XXE vulnerability in XmlBodyParser +- Added circular reference protection in RefResolver + +### Fixed +- Fixed $ref handling in JsonParser for Parameter and Response +- Fixed empty array type ambiguity with configurable strategy + +### Changed +- Refactored JsonParser and YamlParser to use shared OpenApiBuilder +- Removed Psalm global suppressions, improved type safety +- Added `final` modifier to all non-readonly classes +- Created psalm-baseline.xml for legitimate mixed type suppressions + +### Added +- EmptyArrayStrategy enum for configurable empty array validation +- Security tests for XXE protection +- Comprehensive test coverage for ref resolution + ## [Unreleased] - Breaking Changes ### Added diff --git a/README.md b/README.md index 4ed0c6b..57e9cf1 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,7 @@ $validator->validate(['name' => 'John', 'age' => 30]); | `withFormat(string $type, string $format, FormatValidatorInterface $validator)` | Register custom format | - | | `withValidatorPool(ValidatorPool $pool)` | Set custom validator pool | `new ValidatorPool()` | | `withLogger(object $logger)` | Set PSR-3 logger | `null` | +| `withEmptyArrayStrategy(EmptyArrayStrategy $strategy)` | Set empty array validation strategy | `AllowBoth` | | `enableCoercion()` | Enable type coercion | `false` | | `enableNullableAsType()` | Enable nullable validation (default: true) | `true` | | `disableNullableAsType()` | Disable nullable validation | `false` | @@ -737,6 +738,72 @@ make psalm make cs-fix ``` +## Empty Array Strategy + +By default, empty arrays `[]` are valid for both `array` and `object` types. You can configure this behavior: + +```php +use Duyler\OpenApi\Validator\EmptyArrayStrategy; + +$validator = OpenApiValidatorBuilder::create() + ->fromYamlFile('openapi.yaml') + ->withEmptyArrayStrategy(EmptyArrayStrategy::PreferObject) + ->build(); +``` + +Available strategies: + +| Strategy | Empty array valid for array | Empty array valid for object | +|----------|----------------------------|------------------------------| +| `AllowBoth` (default) | Yes | Yes | +| `PreferArray` | Yes | No | +| `PreferObject` | No | Yes | +| `Reject` | No | No | + +## Security Considerations + +### XML External Entity (XXE) Protection + +This library includes built-in protection against XML External Entity (XXE) attacks when parsing XML request bodies. The `XmlBodyParser` automatically disables external entity loading to prevent: + +- **File disclosure attacks** - Prevents reading local files via `SYSTEM "file:///etc/passwd"` +- **SSRF attacks** - Blocks Server-Side Request Forgery via external entity references +- **Billion laughs attacks** - Mitigates denial of service through entity expansion + +The protection is implemented by: + +1. Disabling external entity loader via `libxml_set_external_entity_loader(null)` +2. Using internal error handling with `libxml_use_internal_errors(true)` +3. Clearing libxml errors after parsing + +### Circular Reference Protection + +The `RefResolver` detects and prevents circular references in OpenAPI specifications to avoid stack overflow attacks. + +### PHP Configuration Recommendations + +For enhanced security, ensure the following PHP settings are configured: + +```ini +; Disable allow_url_fopen to prevent SSRF via XXE +allow_url_fopen = Off + +; Disable allow_url_include for additional protection +allow_url_include = Off +``` + +### Content-Type Validation + +The validator strictly validates Content-Type headers to ensure request bodies match the expected format. Unexpected content types are rejected with `UnsupportedMediaTypeException`. + +### Input Validation + +All input validation follows the OpenAPI 3.1 specification constraints. Schema validation prevents: + +- Type confusion attacks +- Buffer overflow via length constraints +- Injection attacks via pattern validation + ## License MIT diff --git a/infection.log b/infection.log new file mode 100644 index 0000000..e8de8fe --- /dev/null +++ b/infection.log @@ -0,0 +1,111 @@ +Note: Pass `--log-verbosity=all` to log information about killed and errored mutants. +Note: Pass `--debug` to log test-framework output. + +Escaped mutants: +================ + +1) /app/src/Validator/Request/BodyParser/XmlBodyParser.php:22 [M] FunctionCallRemoval [ID] 4496607e11f60767e5da8d170dc17d8d + +@@ @@ + return ''; + } + +- libxml_set_external_entity_loader(null); ++ + libxml_use_internal_errors(true); + + try { + + +2) /app/src/Validator/Request/BodyParser/XmlBodyParser.php:23 [M] FunctionCallRemoval [ID] 9a161dc7643e664ae4cc051c8d80fd3d + +@@ @@ + } + + libxml_set_external_entity_loader(null); +- libxml_use_internal_errors(true); ++ + + try { + $xml = simplexml_load_string($body); + + +3) /app/src/Validator/Request/BodyParser/XmlBodyParser.php:25 [M] UnwrapFinally [ID] 1b6b30c40d097a34decbe297c4f22e59 + +@@ @@ + + libxml_set_external_entity_loader(null); + libxml_use_internal_errors(true); +- + try { + $xml = simplexml_load_string($body); +- + if (false === $xml) { + return $body; + } +- + $encoded = json_encode($xml); + if (false === $encoded) { + return $body; + } +- + $decoded = json_decode($encoded, true); + if (false === is_array($decoded)) { + return $body; + } +- + return $decoded; + } catch (ValueError) { + return $body; +- } finally { +- libxml_clear_errors(); + } ++ libxml_clear_errors(); + } + } + + +4) /app/src/Validator/Request/BodyParser/XmlBodyParser.php:29 [M] ReturnRemoval [ID] 040d60e037e033a9b595f9ed9c7efbf0 + +@@ @@ + $xml = simplexml_load_string($body); + + if (false === $xml) { +- return $body; ++ + } + + $encoded = json_encode($xml); + + +5) /app/src/Validator/Request/BodyParser/XmlBodyParser.php:45 [M] Finally_ [ID] d4933893a94af46f25a423d898882a1c + +@@ @@ + return $decoded; + } catch (ValueError) { + return $body; +- } finally { +- libxml_clear_errors(); + } + } + } + + +6) /app/src/Validator/Request/BodyParser/XmlBodyParser.php:46 [M] FunctionCallRemoval [ID] 2ee18910bd1cb84b30c81cc02c9306ce + +@@ @@ + } catch (ValueError) { + return $body; + } finally { +- libxml_clear_errors(); ++ + } + } + } + + +Timed Out mutants: +================== + +Skipped mutants: +================ diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..637a3f9 --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,803 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/psalm.xml b/psalm.xml index 0962da2..58594fe 100644 --- a/psalm.xml +++ b/psalm.xml @@ -7,6 +7,7 @@ xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" findUnusedBaselineEntry="true" findUnusedCode="false" + errorBaseline="psalm-baseline.xml" > @@ -14,8 +15,4 @@ - - - - diff --git a/src/Builder/Exception/BuilderException.php b/src/Builder/Exception/BuilderException.php index 0f0a77e..1fe92a4 100644 --- a/src/Builder/Exception/BuilderException.php +++ b/src/Builder/Exception/BuilderException.php @@ -7,7 +7,7 @@ use Exception; use Throwable; -class BuilderException extends Exception +final class BuilderException extends Exception { public function __construct( string $message = '', diff --git a/src/Builder/OpenApiValidatorBuilder.php b/src/Builder/OpenApiValidatorBuilder.php index 6f1011a..84719e9 100644 --- a/src/Builder/OpenApiValidatorBuilder.php +++ b/src/Builder/OpenApiValidatorBuilder.php @@ -9,6 +9,7 @@ use Duyler\OpenApi\Schema\OpenApiDocument; use Duyler\OpenApi\Schema\Parser\JsonParser; use Duyler\OpenApi\Schema\Parser\YamlParser; +use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Error\Formatter\ErrorFormatterInterface; use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; use Duyler\OpenApi\Validator\Format\BuiltinFormats; @@ -22,49 +23,28 @@ use function sprintf; -/** - * Fluent builder for creating OpenApiValidator instances. - * - * Provides a convenient interface for configuring and building validators - * with support for caching, custom formats, error formatting, and event dispatching. - */ -class OpenApiValidatorBuilder +final readonly class OpenApiValidatorBuilder { protected function __construct( - protected readonly ?string $specPath = null, - protected readonly ?string $specContent = null, - protected readonly ?string $specType = null, - protected readonly ?ValidatorPool $pool = null, - protected readonly ?SchemaCache $cache = null, - protected readonly ?object $logger = null, - protected readonly ?FormatRegistry $formatRegistry = null, - protected readonly bool $coercion = false, - protected readonly bool $nullableAsType = true, - protected readonly ?ErrorFormatterInterface $errorFormatter = null, - protected readonly ?EventDispatcherInterface $eventDispatcher = null, + protected ?string $specPath = null, + protected ?string $specContent = null, + protected ?string $specType = null, + protected ?ValidatorPool $pool = null, + protected ?SchemaCache $cache = null, + protected ?object $logger = null, + protected ?FormatRegistry $formatRegistry = null, + protected bool $coercion = false, + protected bool $nullableAsType = true, + protected EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, + protected ?ErrorFormatterInterface $errorFormatter = null, + protected ?EventDispatcherInterface $eventDispatcher = null, ) {} - /** - * Create a new builder instance. - * - * @return self - */ public static function create(): self { return new self(); } - /** - * Load OpenAPI spec from YAML file. - * - * @param string $path Path to the YAML file - * @return self - * - * @example - * $validator = OpenApiValidatorBuilder::create() - * ->fromYamlFile('openapi.yaml') - * ->build(); - */ public function fromYamlFile(string $path): self { return new self( @@ -76,14 +56,12 @@ public function fromYamlFile(string $path): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Load OpenAPI spec from JSON file - */ public function fromJsonFile(string $path): self { return new self( @@ -95,14 +73,12 @@ public function fromJsonFile(string $path): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Load OpenAPI spec from YAML string - */ public function fromYamlString(string $content): self { return new self( @@ -114,14 +90,12 @@ public function fromYamlString(string $content): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Load OpenAPI spec from JSON string - */ public function fromJsonString(string $content): self { return new self( @@ -133,14 +107,12 @@ public function fromJsonString(string $content): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Set custom validator pool - */ public function withValidatorPool(ValidatorPool $pool): self { return new self( @@ -153,24 +125,12 @@ public function withValidatorPool(ValidatorPool $pool): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Enable PSR-6 caching for OpenAPI documents. - * - * @param SchemaCache $cache PSR-6 cache implementation - * @return self - * - * @example - * $cache = new SchemaCache($symfonyCacheAdapter); - * $validator = OpenApiValidatorBuilder::create() - * ->fromYamlFile('openapi.yaml') - * ->withCache($cache) - * ->build(); - */ public function withCache(SchemaCache $cache): self { return new self( @@ -183,14 +143,12 @@ public function withCache(SchemaCache $cache): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Set PSR-3 logger - */ public function withLogger(object $logger): self { return new self( @@ -203,14 +161,12 @@ public function withLogger(object $logger): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Set error formatter - */ public function withErrorFormatter(ErrorFormatterInterface $formatter): self { return new self( @@ -223,14 +179,12 @@ public function withErrorFormatter(ErrorFormatterInterface $formatter): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $formatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Register custom format validator - */ public function withFormat( string $type, string $format, @@ -249,14 +203,12 @@ public function withFormat( formatRegistry: $registry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Enable type coercion - */ public function enableCoercion(): self { return new self( @@ -269,14 +221,12 @@ public function enableCoercion(): self formatRegistry: $this->formatRegistry, coercion: true, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Enable nullable validation - */ public function enableNullableAsType(): self { return new self( @@ -289,14 +239,12 @@ public function enableNullableAsType(): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: true, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Disable nullable validation - */ public function disableNullableAsType(): self { return new self( @@ -309,23 +257,30 @@ public function disableNullableAsType(): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: false, + emptyArrayStrategy: $this->emptyArrayStrategy, + errorFormatter: $this->errorFormatter, + eventDispatcher: $this->eventDispatcher, + ); + } + + public function withEmptyArrayStrategy(EmptyArrayStrategy $strategy): self + { + return new self( + specPath: $this->specPath, + specContent: $this->specContent, + specType: $this->specType, + pool: $this->pool, + cache: $this->cache, + logger: $this->logger, + formatRegistry: $this->formatRegistry, + coercion: $this->coercion, + nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $strategy, errorFormatter: $this->errorFormatter, eventDispatcher: $this->eventDispatcher, ); } - /** - * Set PSR-14 event dispatcher. - * - * @param EventDispatcherInterface $dispatcher PSR-14 event dispatcher - * @return self - * - * @example - * $validator = OpenApiValidatorBuilder::create() - * ->fromYamlFile('openapi.yaml') - * ->withEventDispatcher($symfonyEventDispatcher) - * ->build(); - */ public function withEventDispatcher(EventDispatcherInterface $dispatcher): self { return new self( @@ -338,25 +293,12 @@ public function withEventDispatcher(EventDispatcherInterface $dispatcher): self formatRegistry: $this->formatRegistry, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, errorFormatter: $this->errorFormatter, eventDispatcher: $dispatcher, ); } - /** - * Build the validator instance. - * - * @return OpenApiValidator Configured validator instance - * @throws BuilderException If spec is not loaded - * - * @example - * $validator = OpenApiValidatorBuilder::create() - * ->fromYamlFile('openapi.yaml') - * ->withCache($cache) - * ->withEventDispatcher($dispatcher) - * ->enableCoercion() - * ->build(); - */ public function build(): OpenApiValidator { $document = $this->loadSpec(); @@ -375,6 +317,7 @@ public function build(): OpenApiValidator logger: $this->logger, coercion: $this->coercion, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, eventDispatcher: $this->eventDispatcher, pathFinder: $pathFinder, ); diff --git a/src/Cache/TypedCacheDecorator.php b/src/Cache/TypedCacheDecorator.php index 49177ac..dfdf59e 100644 --- a/src/Cache/TypedCacheDecorator.php +++ b/src/Cache/TypedCacheDecorator.php @@ -7,7 +7,7 @@ use Psr\Cache\CacheItemPoolInterface; use RuntimeException; -final readonly class TypedCacheDecorator +readonly class TypedCacheDecorator { public function __construct( private readonly CacheItemPoolInterface $pool, diff --git a/src/Registry/SchemaRegistry.php b/src/Registry/SchemaRegistry.php index 52f8807..639ee2a 100644 --- a/src/Registry/SchemaRegistry.php +++ b/src/Registry/SchemaRegistry.php @@ -8,7 +8,7 @@ use function count; -final readonly class SchemaRegistry +readonly class SchemaRegistry { /** * @param array> $schemas diff --git a/src/Schema/Exception/InvalidSchemaException.php b/src/Schema/Exception/InvalidSchemaException.php index b060233..c889b32 100644 --- a/src/Schema/Exception/InvalidSchemaException.php +++ b/src/Schema/Exception/InvalidSchemaException.php @@ -6,4 +6,4 @@ use RuntimeException; -class InvalidSchemaException extends RuntimeException {} +final class InvalidSchemaException extends RuntimeException {} diff --git a/src/Schema/Model/Callbacks.php b/src/Schema/Model/Callbacks.php index aea22c3..976bbb6 100644 --- a/src/Schema/Model/Callbacks.php +++ b/src/Schema/Model/Callbacks.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Callbacks implements JsonSerializable +readonly class Callbacks implements JsonSerializable { /** * @param array> $callbacks diff --git a/src/Schema/Model/Components.php b/src/Schema/Model/Components.php index 96125be..da0c02a 100644 --- a/src/Schema/Model/Components.php +++ b/src/Schema/Model/Components.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Components implements JsonSerializable +readonly class Components implements JsonSerializable { /** * @param array|null $schemas diff --git a/src/Schema/Model/Contact.php b/src/Schema/Model/Contact.php index 8a51e57..8cf014f 100644 --- a/src/Schema/Model/Contact.php +++ b/src/Schema/Model/Contact.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Contact implements JsonSerializable +readonly class Contact implements JsonSerializable { public function __construct( public ?string $name = null, diff --git a/src/Schema/Model/Content.php b/src/Schema/Model/Content.php index 1a90008..db340ca 100644 --- a/src/Schema/Model/Content.php +++ b/src/Schema/Model/Content.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Content implements JsonSerializable +readonly class Content implements JsonSerializable { /** * @param array $mediaTypes diff --git a/src/Schema/Model/Discriminator.php b/src/Schema/Model/Discriminator.php index 11dd97b..ab3a67d 100644 --- a/src/Schema/Model/Discriminator.php +++ b/src/Schema/Model/Discriminator.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Discriminator implements JsonSerializable +readonly class Discriminator implements JsonSerializable { /** * @param array $mapping diff --git a/src/Schema/Model/Example.php b/src/Schema/Model/Example.php index b74d4a6..9617c0d 100644 --- a/src/Schema/Model/Example.php +++ b/src/Schema/Model/Example.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Example implements JsonSerializable +readonly class Example implements JsonSerializable { public function __construct( public ?string $summary = null, diff --git a/src/Schema/Model/ExternalDocs.php b/src/Schema/Model/ExternalDocs.php index a0fdc0d..5718f97 100644 --- a/src/Schema/Model/ExternalDocs.php +++ b/src/Schema/Model/ExternalDocs.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class ExternalDocs implements JsonSerializable +readonly class ExternalDocs implements JsonSerializable { public function __construct( public string $url, diff --git a/src/Schema/Model/Header.php b/src/Schema/Model/Header.php index 6bd4c69..4d79d4e 100644 --- a/src/Schema/Model/Header.php +++ b/src/Schema/Model/Header.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Header implements JsonSerializable +readonly class Header implements JsonSerializable { /** * @param array $examples diff --git a/src/Schema/Model/Headers.php b/src/Schema/Model/Headers.php index 987925f..4d44df6 100644 --- a/src/Schema/Model/Headers.php +++ b/src/Schema/Model/Headers.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Headers implements JsonSerializable +readonly class Headers implements JsonSerializable { /** * @param array $headers diff --git a/src/Schema/Model/InfoObject.php b/src/Schema/Model/InfoObject.php index d91996e..aa5f1d7 100644 --- a/src/Schema/Model/InfoObject.php +++ b/src/Schema/Model/InfoObject.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class InfoObject implements JsonSerializable +readonly class InfoObject implements JsonSerializable { public function __construct( public string $title, diff --git a/src/Schema/Model/License.php b/src/Schema/Model/License.php index c21e83e..832a2ec 100644 --- a/src/Schema/Model/License.php +++ b/src/Schema/Model/License.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class License implements JsonSerializable +readonly class License implements JsonSerializable { public function __construct( public string $name, diff --git a/src/Schema/Model/Link.php b/src/Schema/Model/Link.php index 29a3a81..d2184c2 100644 --- a/src/Schema/Model/Link.php +++ b/src/Schema/Model/Link.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Link implements JsonSerializable +readonly class Link implements JsonSerializable { /** * @param array $parameters diff --git a/src/Schema/Model/Links.php b/src/Schema/Model/Links.php index f42a285..d19e47c 100644 --- a/src/Schema/Model/Links.php +++ b/src/Schema/Model/Links.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Links implements JsonSerializable +readonly class Links implements JsonSerializable { /** * @param array $links diff --git a/src/Schema/Model/MediaType.php b/src/Schema/Model/MediaType.php index 53a49d4..4861144 100644 --- a/src/Schema/Model/MediaType.php +++ b/src/Schema/Model/MediaType.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class MediaType implements JsonSerializable +readonly class MediaType implements JsonSerializable { /** * @param array $examples diff --git a/src/Schema/Model/Operation.php b/src/Schema/Model/Operation.php index b2a91c0..8c10748 100644 --- a/src/Schema/Model/Operation.php +++ b/src/Schema/Model/Operation.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Operation implements JsonSerializable +readonly class Operation implements JsonSerializable { /** * @param list|null $tags diff --git a/src/Schema/Model/Parameter.php b/src/Schema/Model/Parameter.php index c25098b..ffbac26 100644 --- a/src/Schema/Model/Parameter.php +++ b/src/Schema/Model/Parameter.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Parameter implements JsonSerializable +readonly class Parameter implements JsonSerializable { /** * @param array $examples diff --git a/src/Schema/Model/Parameters.php b/src/Schema/Model/Parameters.php index e8546c8..0aaa218 100644 --- a/src/Schema/Model/Parameters.php +++ b/src/Schema/Model/Parameters.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Parameters implements JsonSerializable +readonly class Parameters implements JsonSerializable { /** * @param list $parameters diff --git a/src/Schema/Model/PathItem.php b/src/Schema/Model/PathItem.php index 49d4bf8..98d4f59 100644 --- a/src/Schema/Model/PathItem.php +++ b/src/Schema/Model/PathItem.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class PathItem implements JsonSerializable +readonly class PathItem implements JsonSerializable { public function __construct( public ?string $ref = null, diff --git a/src/Schema/Model/Paths.php b/src/Schema/Model/Paths.php index cb637e9..ffc9541 100644 --- a/src/Schema/Model/Paths.php +++ b/src/Schema/Model/Paths.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Paths implements JsonSerializable +readonly class Paths implements JsonSerializable { /** * @param array $paths diff --git a/src/Schema/Model/RequestBody.php b/src/Schema/Model/RequestBody.php index e8dfb7d..2eeae60 100644 --- a/src/Schema/Model/RequestBody.php +++ b/src/Schema/Model/RequestBody.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class RequestBody implements JsonSerializable +readonly class RequestBody implements JsonSerializable { public function __construct( public ?string $description = null, diff --git a/src/Schema/Model/Response.php b/src/Schema/Model/Response.php index fd8f55e..98702b1 100644 --- a/src/Schema/Model/Response.php +++ b/src/Schema/Model/Response.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Response implements JsonSerializable +readonly class Response implements JsonSerializable { public function __construct( public ?string $ref = null, diff --git a/src/Schema/Model/Responses.php b/src/Schema/Model/Responses.php index f7843f3..6a6b9e7 100644 --- a/src/Schema/Model/Responses.php +++ b/src/Schema/Model/Responses.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Responses implements JsonSerializable +readonly class Responses implements JsonSerializable { /** * @param array $responses diff --git a/src/Schema/Model/Schema.php b/src/Schema/Model/Schema.php index 7249cc9..4f1c196 100644 --- a/src/Schema/Model/Schema.php +++ b/src/Schema/Model/Schema.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Schema implements JsonSerializable +readonly class Schema implements JsonSerializable { /** * @param string|list|null $type diff --git a/src/Schema/Model/SecurityRequirement.php b/src/Schema/Model/SecurityRequirement.php index db3b3c5..d6e8811 100644 --- a/src/Schema/Model/SecurityRequirement.php +++ b/src/Schema/Model/SecurityRequirement.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class SecurityRequirement implements JsonSerializable +readonly class SecurityRequirement implements JsonSerializable { /** * @param list>> $requirements diff --git a/src/Schema/Model/SecurityScheme.php b/src/Schema/Model/SecurityScheme.php index 783da56..a490b9d 100644 --- a/src/Schema/Model/SecurityScheme.php +++ b/src/Schema/Model/SecurityScheme.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class SecurityScheme implements JsonSerializable +readonly class SecurityScheme implements JsonSerializable { public function __construct( public string $type, diff --git a/src/Schema/Model/Server.php b/src/Schema/Model/Server.php index 78fbc37..4d2ba3b 100644 --- a/src/Schema/Model/Server.php +++ b/src/Schema/Model/Server.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Server implements JsonSerializable +readonly class Server implements JsonSerializable { /** * @param array $variables diff --git a/src/Schema/Model/Servers.php b/src/Schema/Model/Servers.php index 554b3fd..4da02c5 100644 --- a/src/Schema/Model/Servers.php +++ b/src/Schema/Model/Servers.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Servers implements JsonSerializable +readonly class Servers implements JsonSerializable { /** * @param list $servers diff --git a/src/Schema/Model/Tag.php b/src/Schema/Model/Tag.php index 676647a..506572b 100644 --- a/src/Schema/Model/Tag.php +++ b/src/Schema/Model/Tag.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Tag implements JsonSerializable +readonly class Tag implements JsonSerializable { public function __construct( public string $name, diff --git a/src/Schema/Model/Tags.php b/src/Schema/Model/Tags.php index 31de7ad..f095d76 100644 --- a/src/Schema/Model/Tags.php +++ b/src/Schema/Model/Tags.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Tags implements JsonSerializable +readonly class Tags implements JsonSerializable { /** * @param list $tags diff --git a/src/Schema/Model/Webhooks.php b/src/Schema/Model/Webhooks.php index 4d6d0fc..889397b 100644 --- a/src/Schema/Model/Webhooks.php +++ b/src/Schema/Model/Webhooks.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class Webhooks implements JsonSerializable +readonly class Webhooks implements JsonSerializable { /** * @param array $webhooks diff --git a/src/Schema/OpenApiDocument.php b/src/Schema/OpenApiDocument.php index 479f498..110b817 100644 --- a/src/Schema/OpenApiDocument.php +++ b/src/Schema/OpenApiDocument.php @@ -7,7 +7,7 @@ use JsonSerializable; use Override; -final readonly class OpenApiDocument implements JsonSerializable +readonly class OpenApiDocument implements JsonSerializable { public function __construct( public string $openapi, diff --git a/src/Schema/Parser/JsonParser.php b/src/Schema/Parser/JsonParser.php index e936ee7..324fd24 100644 --- a/src/Schema/Parser/JsonParser.php +++ b/src/Schema/Parser/JsonParser.php @@ -4,791 +4,21 @@ namespace Duyler\OpenApi\Schema\Parser; -use Duyler\OpenApi\Schema\Exception\InvalidSchemaException; -use Duyler\OpenApi\Schema\Model\Callbacks; -use Duyler\OpenApi\Schema\Model\Components; -use Duyler\OpenApi\Schema\Model\Contact; -use Duyler\OpenApi\Schema\Model\Content; -use Duyler\OpenApi\Schema\Model\Discriminator; -use Duyler\OpenApi\Schema\Model\Example; -use Duyler\OpenApi\Schema\Model\ExternalDocs; -use Duyler\OpenApi\Schema\Model\Header; -use Duyler\OpenApi\Schema\Model\Headers; -use Duyler\OpenApi\Schema\Model\InfoObject; -use Duyler\OpenApi\Schema\Model\License; -use Duyler\OpenApi\Schema\Model\Link; -use Duyler\OpenApi\Schema\Model\Links; -use Duyler\OpenApi\Schema\Model\MediaType; -use Duyler\OpenApi\Schema\Model\Operation; -use Duyler\OpenApi\Schema\Model\Parameter; -use Duyler\OpenApi\Schema\Model\Parameters; -use Duyler\OpenApi\Schema\Model\PathItem; -use Duyler\OpenApi\Schema\Model\Paths; -use Duyler\OpenApi\Schema\Model\RequestBody; -use Duyler\OpenApi\Schema\Model\Response; -use Duyler\OpenApi\Schema\Model\Responses; -use Duyler\OpenApi\Schema\Model\Schema; -use Duyler\OpenApi\Schema\Model\SecurityRequirement; -use Duyler\OpenApi\Schema\Model\SecurityScheme; -use Duyler\OpenApi\Schema\Model\Server; -use Duyler\OpenApi\Schema\Model\Servers; -use Duyler\OpenApi\Schema\Model\Tag; -use Duyler\OpenApi\Schema\Model\Tags; -use Duyler\OpenApi\Schema\Model\Webhooks; -use Duyler\OpenApi\Schema\OpenApiDocument; -use Duyler\OpenApi\Schema\SchemaParserInterface; use Override; -use Throwable; - -use function is_array; -use function is_string; use const JSON_THROW_ON_ERROR; -final readonly class JsonParser implements SchemaParserInterface +readonly class JsonParser extends OpenApiBuilder { #[Override] - public function parse(string $content): OpenApiDocument - { - try { - $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); - - if (false === is_array($data)) { - throw new InvalidSchemaException('Invalid JSON: expected object at root, got ' . get_debug_type($data)); - } - - return $this->buildDocument($data); - } catch (InvalidSchemaException $e) { - throw $e; - } catch (Throwable $e) { - throw new InvalidSchemaException('Failed to parse JSON: ' . $e->getMessage(), 0, $e); - } - } - - private function buildDocument(array $data): OpenApiDocument - { - $this->validateVersion($data); - - return new OpenApiDocument( - openapi: (string) $data['openapi'], - info: $this->buildInfo(TypeHelper::asArray($data['info'])), - jsonSchemaDialect: TypeHelper::asStringOrNull($data['jsonSchemaDialect'] ?? null), - servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, - paths: isset($data['paths']) ? $this->buildPaths(TypeHelper::asArray($data['paths'])) : null, - webhooks: isset($data['webhooks']) ? $this->buildWebhooks(TypeHelper::asArray($data['webhooks'])) : null, - components: isset($data['components']) ? $this->buildComponents(TypeHelper::asArray($data['components'])) : null, - security: isset($data['security']) ? new SecurityRequirement(TypeHelper::asSecurityListMapOrNull($data['security']) ?? []) : null, - tags: isset($data['tags']) ? new Tags($this->buildTags(TypeHelper::asList($data['tags']))) : null, - externalDocs: isset($data['externalDocs']) && is_array($data['externalDocs']) ? $this->buildExternalDocs(TypeHelper::asArray($data['externalDocs'])) : null, - ); - } - - private function validateVersion(array $data): void - { - if (false === isset($data['openapi'])) { - throw new InvalidSchemaException('OpenAPI version is required'); - } - - $version = $data['openapi']; - if (false === is_string($version) || 1 !== preg_match('/^3\.[01]\.[0-9]+$/', $version)) { - throw new InvalidSchemaException('Unsupported OpenAPI version: ' . (string) $version . '. Only 3.0.x and 3.1.x are supported.'); - } - } - - private function buildInfo(array $data): InfoObject - { - if (false === isset($data['title']) || false === isset($data['version'])) { - throw new InvalidSchemaException('Info object must have title and version'); - } - - return new InfoObject( - title: TypeHelper::asString($data['title']), - version: TypeHelper::asString($data['version']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - termsOfService: TypeHelper::asStringOrNull($data['termsOfService'] ?? null), - contact: isset($data['contact']) ? $this->buildContact(TypeHelper::asArray($data['contact'])) : null, - license: isset($data['license']) ? $this->buildLicense(TypeHelper::asArray($data['license'])) : null, - ); - } - - private function buildContact(array $data): Contact - { - return new Contact( - name: TypeHelper::asStringOrNull($data['name'] ?? null), - url: TypeHelper::asStringOrNull($data['url'] ?? null), - email: TypeHelper::asStringOrNull($data['email'] ?? null), - ); - } - - private function buildLicense(array $data): License - { - return new License( - name: TypeHelper::asString($data['name']), - identifier: TypeHelper::asStringOrNull($data['identifier'] ?? null), - url: TypeHelper::asStringOrNull($data['url'] ?? null), - ); - } - - /** - * @return list - */ - private function buildServers(array $data): array - { - return array_map(fn(array $server) => new Server( - url: TypeHelper::asString($server['url']), - description: TypeHelper::asStringOrNull($server['description'] ?? null), - variables: isset($server['variables']) && is_array($server['variables']) - ? TypeHelper::asStringMixedMapOrNull(TypeHelper::asArray($server['variables'])) - : null, - ), array_values($data)); - } - - /** - * @return list - */ - private function buildTags(array $data): array - { - return array_map(fn(array $tag) => new Tag( - name: TypeHelper::asString($tag['name']), - description: TypeHelper::asStringOrNull($tag['description'] ?? null), - externalDocs: isset($tag['externalDocs']) && is_array($tag['externalDocs']) - ? $this->buildExternalDocs(TypeHelper::asArray($tag['externalDocs'])) - : null, - ), array_values($data)); - } - - /** - * @return Paths - */ - private function buildPaths(array $data): Paths - { - $paths = []; - - foreach ($data as $path => $pathItem) { - $paths[$path] = $this->buildPathItem(TypeHelper::asArray($pathItem)); - } - - /** @var array $paths */ - return new Paths($paths); - } - - /** - */ - private function buildPathItem(array $data): PathItem - { - return new PathItem( - ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), - summary: TypeHelper::asStringOrNull($data['summary'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - get: isset($data['get']) ? $this->buildOperation(TypeHelper::asArray($data['get'])) : null, - put: isset($data['put']) ? $this->buildOperation(TypeHelper::asArray($data['put'])) : null, - post: isset($data['post']) ? $this->buildOperation(TypeHelper::asArray($data['post'])) : null, - delete: isset($data['delete']) ? $this->buildOperation(TypeHelper::asArray($data['delete'])) : null, - options: isset($data['options']) ? $this->buildOperation(TypeHelper::asArray($data['options'])) : null, - head: isset($data['head']) ? $this->buildOperation(TypeHelper::asArray($data['head'])) : null, - patch: isset($data['patch']) ? $this->buildOperation(TypeHelper::asArray($data['patch'])) : null, - trace: isset($data['trace']) ? $this->buildOperation(TypeHelper::asArray($data['trace'])) : null, - servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, - parameters: isset($data['parameters']) ? new Parameters($this->buildParameters(TypeHelper::asList($data['parameters']))) : null, - ); - } - - /** - * @return list - */ - private function buildParameters(array $data): array - { - return array_map($this->buildParameter(...), array_values($data)); - } - - /** - */ - private function buildParameter(array $data): Parameter - { - if (false === isset($data['name']) || false === isset($data['in'])) { - throw new InvalidSchemaException('Parameter must have name and in fields'); - } - - return new Parameter( - name: TypeHelper::asString($data['name']), - in: TypeHelper::asString($data['in']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - required: (bool) ($data['required'] ?? false), - deprecated: (bool) ($data['deprecated'] ?? false), - allowEmptyValue: (bool) ($data['allowEmptyValue'] ?? false), - style: TypeHelper::asStringOrNull($data['style'] ?? null), - explode: (bool) ($data['explode'] ?? false), - allowReserved: (bool) ($data['allowReserved'] ?? false), - schema: isset($data['schema']) ? $this->buildSchema(TypeHelper::asArray($data['schema'])) : null, - examples: isset($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - example: isset($data['example']) && false === is_array($data['example']) - ? (is_string($data['example']) ? $this->buildExample(['value' => $data['example']]) : null) - : null, - content: isset($data['content']) ? $this->buildContent(TypeHelper::asArray($data['content'])) : null, - ); - } - - /** - */ - private function buildSchema(array $data): Schema - { - return new Schema( - ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), - format: TypeHelper::asStringOrNull($data['format'] ?? null), - title: TypeHelper::asStringOrNull($data['title'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - default: $data['default'] ?? null, - deprecated: (bool) ($data['deprecated'] ?? false), - type: TypeHelper::asStringOrNull($data['type'] ?? null), - nullable: (bool) ($data['nullable'] ?? false), - const: $data['const'] ?? null, - multipleOf: TypeHelper::asFloatOrNull($data['multipleOf'] ?? null), - maximum: TypeHelper::asFloatOrNull($data['maximum'] ?? null), - exclusiveMaximum: TypeHelper::asFloatOrNull($data['exclusiveMaximum'] ?? null), - minimum: TypeHelper::asFloatOrNull($data['minimum'] ?? null), - exclusiveMinimum: TypeHelper::asFloatOrNull($data['exclusiveMinimum'] ?? null), - maxLength: TypeHelper::asIntOrNull($data['maxLength'] ?? null), - minLength: TypeHelper::asIntOrNull($data['minLength'] ?? null), - pattern: TypeHelper::asStringOrNull($data['pattern'] ?? null), - maxItems: TypeHelper::asIntOrNull($data['maxItems'] ?? null), - minItems: TypeHelper::asIntOrNull($data['minItems'] ?? null), - uniqueItems: TypeHelper::asBoolOrNull($data['uniqueItems'] ?? null), - maxProperties: TypeHelper::asIntOrNull($data['maxProperties'] ?? null), - minProperties: TypeHelper::asIntOrNull($data['minProperties'] ?? null), - required: TypeHelper::asStringListOrNull($data['required'] ?? null), - allOf: isset($data['allOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['allOf']))) : null, - anyOf: isset($data['anyOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['anyOf']))) : null, - oneOf: isset($data['oneOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['oneOf']))) : null, - not: isset($data['not']) ? $this->buildSchema(TypeHelper::asArray($data['not'])) : null, - discriminator: isset($data['discriminator']) ? $this->buildDiscriminator(TypeHelper::asArray($data['discriminator'])) : null, - properties: isset($data['properties']) && is_array($data['properties']) - ? $this->buildProperties(TypeHelper::asArray($data['properties'])) - : null, - additionalProperties: isset($data['additionalProperties']) && is_array($data['additionalProperties']) - ? $this->buildSchema(TypeHelper::asArray($data['additionalProperties'])) - : (isset($data['additionalProperties']) ? (bool) $data['additionalProperties'] : null), - unevaluatedProperties: TypeHelper::asBoolOrNull($data['unevaluatedProperties'] ?? null), - items: isset($data['items']) && is_array($data['items']) ? $this->buildSchema(TypeHelper::asArray($data['items'])) : null, - prefixItems: isset($data['prefixItems']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['prefixItems']))) : null, - contains: isset($data['contains']) ? $this->buildSchema(TypeHelper::asArray($data['contains'])) : null, - minContains: TypeHelper::asIntOrNull($data['minContains'] ?? null), - maxContains: TypeHelper::asIntOrNull($data['maxContains'] ?? null), - patternProperties: isset($data['patternProperties']) && is_array($data['patternProperties']) - ? $this->buildProperties(TypeHelper::asArray($data['patternProperties'])) - : null, - propertyNames: isset($data['propertyNames']) ? $this->buildSchema(TypeHelper::asArray($data['propertyNames'])) : null, - dependentSchemas: isset($data['dependentSchemas']) && is_array($data['dependentSchemas']) - ? $this->buildProperties(TypeHelper::asArray($data['dependentSchemas'])) - : null, - if: isset($data['if']) ? $this->buildSchema(TypeHelper::asArray($data['if'])) : null, - then: isset($data['then']) ? $this->buildSchema(TypeHelper::asArray($data['then'])) : null, - else: isset($data['else']) ? $this->buildSchema(TypeHelper::asArray($data['else'])) : null, - unevaluatedItems: isset($data['unevaluatedItems']) ? $this->buildSchema(TypeHelper::asArray($data['unevaluatedItems'])) : null, - example: $data['example'] ?? null, - examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - enum: TypeHelper::asEnumListOrNull($data['enum'] ?? null), - contentEncoding: TypeHelper::asStringOrNull($data['contentEncoding'] ?? null), - contentMediaType: TypeHelper::asStringOrNull($data['contentMediaType'] ?? null), - contentSchema: TypeHelper::asStringOrNull($data['contentSchema'] ?? null), - jsonSchemaDialect: TypeHelper::asStringOrNull($data['$schema'] ?? null), - ); - } - - /** - * @return array - */ - private function buildProperties(array $data): array - { - $properties = []; - - foreach ($data as $name => $schema) { - if (is_string($name) && is_array($schema)) { - $properties[$name] = $this->buildSchema(TypeHelper::asArray($schema)); - } - } - - return $properties; - } - - /** - */ - private function buildDiscriminator(array $data): Discriminator - { - if (false === isset($data['propertyName'])) { - throw new InvalidSchemaException('Discriminator must have propertyName'); - } - - return new Discriminator( - propertyName: TypeHelper::asString($data['propertyName']), - mapping: TypeHelper::asStringMapOrNull($data['mapping'] ?? null), - ); - } - - /** - */ - private function buildOperation(array $data): Operation - { - return new Operation( - tags: TypeHelper::asStringListOrNull($data['tags'] ?? null), - summary: TypeHelper::asStringOrNull($data['summary'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - externalDocs: isset($data['externalDocs']) ? $this->buildExternalDocs(TypeHelper::asArray($data['externalDocs'])) : null, - operationId: TypeHelper::asStringOrNull($data['operationId'] ?? null), - parameters: isset($data['parameters']) ? new Parameters($this->buildParameters(TypeHelper::asList($data['parameters']))) : null, - requestBody: isset($data['requestBody']) ? $this->buildRequestBody(TypeHelper::asArray($data['requestBody'])) : null, - responses: isset($data['responses']) ? $this->buildResponses(TypeHelper::asArray($data['responses'])) : null, - callbacks: isset($data['callbacks']) ? $this->buildCallbacks(TypeHelper::asArray($data['callbacks'])) : null, - deprecated: (bool) ($data['deprecated'] ?? false), - security: isset($data['security']) ? new SecurityRequirement(TypeHelper::asSecurityListMapOrNull($data['security']) ?? []) : null, - servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, - ); - } - - /** - */ - private function buildRequestBody(array $data): RequestBody - { - return new RequestBody( - description: TypeHelper::asStringOrNull($data['description'] ?? null), - content: isset($data['content']) && is_array($data['content']) - ? $this->buildContent(TypeHelper::asArray($data['content'])) - : null, - required: (bool) ($data['required'] ?? false), - ); - } - - /** - * @return Content - */ - private function buildContent(array $data): Content - { - $mediaTypes = []; - - foreach ($data as $mediaType => $content) { - if (is_array($content)) { - $mediaTypes[$mediaType] = $this->buildMediaType(TypeHelper::asArray($content)); - } - } - - /** @var array $mediaTypes */ - return new Content($mediaTypes); - } - - /** - */ - private function buildMediaType(array $data): MediaType - { - return new MediaType( - schema: isset($data['schema']) && is_array($data['schema']) - ? $this->buildSchema(TypeHelper::asArray($data['schema'])) - : null, - encoding: TypeHelper::asStringOrNull($data['encoding'] ?? null), - example: isset($data['example']) && false === is_array($data['example']) - ? $this->buildExample(['value' => $data['example']]) - : null, - examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - ); - } - - /** - * @return Responses - */ - private function buildResponses(array $data): Responses - { - $responses = []; - - foreach ($data as $statusCode => $response) { - if (is_array($response)) { - $responses[$statusCode] = $this->buildResponse(TypeHelper::asArray($response)); - } - } - - /** @var array $responses */ - return new Responses($responses); - } - - /** - */ - private function buildResponse(array $data): Response - { - return new Response( - description: TypeHelper::asStringOrNull($data['description'] ?? null), - headers: isset($data['headers']) && is_array($data['headers']) - ? $this->buildHeaders(TypeHelper::asArray($data['headers'])) - : null, - content: isset($data['content']) && is_array($data['content']) - ? $this->buildContent(TypeHelper::asArray($data['content'])) - : null, - links: isset($data['links']) && is_array($data['links']) - ? $this->buildLinks(TypeHelper::asArray($data['links'])) - : null, - ); - } - - /** - * @return Headers - */ - private function buildHeaders(array $data): Headers - { - $headers = []; - - foreach ($data as $headerName => $header) { - if (is_array($header)) { - $headers[$headerName] = $this->buildHeader(TypeHelper::asArray($header)); - } - } - - /** @var array $headers */ - return new Headers($headers); - } - - /** - */ - private function buildHeader(array $data): Header + protected function parseContent(string $content): mixed { - return new Header( - description: TypeHelper::asStringOrNull($data['description'] ?? null), - required: (bool) ($data['required'] ?? false), - deprecated: (bool) ($data['deprecated'] ?? false), - allowEmptyValue: (bool) ($data['allowEmptyValue'] ?? false), - schema: isset($data['schema']) && is_array($data['schema']) - ? $this->buildSchema(TypeHelper::asArray($data['schema'])) - : null, - example: $data['example'] ?? null, - examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - content: isset($data['content']) && is_array($data['content']) - ? $this->buildContent(TypeHelper::asArray($data['content'])) - : null, - ); + return json_decode($content, true, 512, JSON_THROW_ON_ERROR); } - /** - * @return Links - */ - private function buildLinks(array $data): Links - { - $links = []; - - foreach ($data as $linkName => $link) { - if (is_array($link)) { - $links[$linkName] = $this->buildLink(TypeHelper::asArray($link)); - } - } - - /** @var array $links */ - return new Links($links); - } - - /** - */ - private function buildLink(array $data): Link - { - return new Link( - operationRef: TypeHelper::asStringOrNull($data['operationRef'] ?? null), - ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - operationId: TypeHelper::asStringOrNull($data['operationId'] ?? null), - parameters: isset($data['parameters']) && is_array($data['parameters']) ? TypeHelper::asStringMixedMapOrNull($data['parameters']) : null, - requestBody: isset($data['requestBody']) && is_array($data['requestBody']) - ? $this->buildRequestBody(TypeHelper::asArray($data['requestBody'])) - : null, - server: isset($data['server']) && is_array($data['server']) - ? $this->buildServer(TypeHelper::asArray($data['server'])) - : null, - ); - } - - /** - */ - private function buildExternalDocs(array $data): ExternalDocs - { - if (false === isset($data['url'])) { - throw new InvalidSchemaException('External documentation must have url'); - } - - return new ExternalDocs( - url: TypeHelper::asString($data['url']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - ); - } - - /** - * @return Webhooks - */ - private function buildWebhooks(array $data): Webhooks - { - $webhooks = []; - - foreach ($data as $webhookName => $webhook) { - if (is_array($webhook)) { - $webhooks[$webhookName] = $this->buildPathItem(TypeHelper::asArray($webhook)); - } - } - - /** @var array $webhooks */ - return new Webhooks($webhooks); - } - - /** - * @return Callbacks - */ - private function buildCallbacks(array $data): Callbacks - { - $callbacks = []; - - foreach ($data as $callbackName => $callback) { - if (is_array($callback)) { - foreach ($callback as $expression => $pathItem) { - if (is_string($expression) && is_array($pathItem)) { - $callbacks[$callbackName][$expression] = $this->buildPathItem(TypeHelper::asArray($pathItem)); - } - } - } - } - - /** @var array> $callbacks */ - return new Callbacks($callbacks); - } - - private function buildComponents(array $data): Components - { - return new Components( - schemas: isset($data['schemas']) && is_array($data['schemas']) - ? $this->buildSchemas(TypeHelper::asArray($data['schemas'])) - : null, - responses: isset($data['responses']) && is_array($data['responses']) - ? $this->buildResponsesComponents(TypeHelper::asArray($data['responses'])) - : null, - parameters: isset($data['parameters']) && is_array($data['parameters']) - ? $this->buildParametersComponents(TypeHelper::asArray($data['parameters'])) - : null, - examples: isset($data['examples']) && is_array($data['examples']) - ? $this->buildExamplesComponents(TypeHelper::asArray($data['examples'])) - : null, - requestBodies: isset($data['requestBodies']) && is_array($data['requestBodies']) - ? $this->buildRequestBodiesComponents(TypeHelper::asArray($data['requestBodies'])) - : null, - headers: isset($data['headers']) && is_array($data['headers']) - ? $this->buildHeadersComponents(TypeHelper::asArray($data['headers'])) - : null, - securitySchemes: isset($data['securitySchemes']) && is_array($data['securitySchemes']) - ? $this->buildSecuritySchemesComponents(TypeHelper::asArray($data['securitySchemes'])) - : null, - links: isset($data['links']) && is_array($data['links']) - ? $this->buildLinksComponents(TypeHelper::asArray($data['links'])) - : null, - callbacks: isset($data['callbacks']) && is_array($data['callbacks']) - ? $this->buildCallbacksComponents(TypeHelper::asArray($data['callbacks'])) - : null, - pathItems: isset($data['pathItems']) && is_array($data['pathItems']) - ? $this->buildPathItemsComponents(TypeHelper::asArray($data['pathItems'])) - : null, - ); - } - - /** - * @return array - */ - private function buildSchemas(array $data): array - { - $schemas = []; - - foreach ($data as $name => $schema) { - if (is_string($name) && is_array($schema)) { - $schemas[$name] = $this->buildSchema(TypeHelper::asArray($schema)); - } - } - - return $schemas; - } - - /** - * @return array - */ - private function buildResponsesComponents(array $data): array - { - $responses = []; - - foreach ($data as $name => $response) { - if (is_string($name) && is_array($response)) { - $responses[$name] = $this->buildResponse(TypeHelper::asArray($response)); - } - } - - return $responses; - } - - /** - * @return array - */ - private function buildParametersComponents(array $data): array - { - $parameters = []; - - foreach ($data as $name => $parameter) { - if (is_string($name) && is_array($parameter)) { - $parameters[$name] = $this->buildParameter(TypeHelper::asArray($parameter)); - } - } - - return $parameters; - } - - /** - * @return array - */ - private function buildExamplesComponents(array $data): array - { - $examples = []; - - foreach ($data as $name => $example) { - if (is_string($name) && is_array($example)) { - $examples[$name] = $this->buildExample(TypeHelper::asArray($example)); - } - } - - return $examples; - } - - /** - */ - private function buildExample(array $data): Example - { - return new Example( - summary: TypeHelper::asStringOrNull($data['summary'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - value: $data['value'] ?? null, - externalValue: TypeHelper::asStringOrNull($data['externalValue'] ?? null), - ); - } - - /** - * @return array - */ - private function buildRequestBodiesComponents(array $data): array - { - $requestBodies = []; - - foreach ($data as $name => $requestBody) { - if (is_string($name) && is_array($requestBody)) { - $requestBodies[$name] = $this->buildRequestBody(TypeHelper::asArray($requestBody)); - } - } - - return $requestBodies; - } - - /** - * @return array - */ - private function buildHeadersComponents(array $data): array - { - $headers = []; - - foreach ($data as $name => $header) { - if (is_string($name) && is_array($header)) { - $headers[$name] = $this->buildHeader(TypeHelper::asArray($header)); - } - } - - return $headers; - } - - /** - * @return array - */ - private function buildSecuritySchemesComponents(array $data): array - { - $securitySchemes = []; - - foreach ($data as $name => $scheme) { - if (is_string($name) && is_array($scheme)) { - $securitySchemes[$name] = $this->buildSecurityScheme(TypeHelper::asArray($scheme)); - } - } - - return $securitySchemes; - } - - /** - */ - private function buildSecurityScheme(array $data): SecurityScheme - { - if (false === isset($data['type'])) { - throw new InvalidSchemaException('Security scheme must have type'); - } - - return new SecurityScheme( - type: TypeHelper::asString($data['type']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - name: TypeHelper::asStringOrNull($data['name'] ?? null), - in: TypeHelper::asStringOrNull($data['in'] ?? null), - scheme: TypeHelper::asStringOrNull($data['scheme'] ?? null), - bearerFormat: TypeHelper::asStringOrNull($data['bearerFormat'] ?? null), - flows: TypeHelper::asStringOrNull($data['flows'] ?? null), - authorizationUrl: TypeHelper::asStringOrNull($data['authorizationUrl'] ?? null), - tokenUrl: TypeHelper::asStringOrNull($data['tokenUrl'] ?? null), - refreshUrl: TypeHelper::asStringOrNull($data['refreshUrl'] ?? null), - scopes: isset($data['scopes']) ? TypeHelper::asArray($data['scopes']) : null, - ); - } - - /** - * @return array - */ - private function buildLinksComponents(array $data): array - { - $links = []; - - foreach ($data as $name => $link) { - if (is_string($name) && is_array($link)) { - $links[$name] = $this->buildLink(TypeHelper::asArray($link)); - } - } - - return $links; - } - - /** - * @return array - */ - private function buildCallbacksComponents(array $data): array - { - $callbacks = []; - - foreach ($data as $name => $callback) { - if (is_string($name) && is_array($callback)) { - $callbacks[$name] = $this->buildCallbacks(TypeHelper::asArray($callback)); - } - } - - return $callbacks; - } - - /** - * @return array - */ - private function buildPathItemsComponents(array $data): array - { - $pathItems = []; - - foreach ($data as $name => $pathItem) { - if (is_string($name) && is_array($pathItem)) { - $pathItems[$name] = $this->buildPathItem(TypeHelper::asArray($pathItem)); - } - } - - return $pathItems; - } - - private function buildServer(array $data): Server + #[Override] + protected function getFormatName(): string { - return new Server( - url: TypeHelper::asString($data['url']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - variables: isset($data['variables']) && is_array($data['variables']) - ? TypeHelper::asStringMixedMapOrNull($data['variables']) - : null, - ); + return 'JSON'; } } diff --git a/src/Schema/Parser/OpenApiBuilder.php b/src/Schema/Parser/OpenApiBuilder.php new file mode 100644 index 0000000..12d6046 --- /dev/null +++ b/src/Schema/Parser/OpenApiBuilder.php @@ -0,0 +1,775 @@ +parseContent($content); + + if (false === is_array($data)) { + throw new InvalidSchemaException( + 'Invalid ' . $this->getFormatName() . ': expected object at root, got ' . get_debug_type($data), + ); + } + + return $this->buildDocument($data); + } catch (InvalidSchemaException $e) { + throw $e; + } catch (Throwable $e) { + throw new InvalidSchemaException( + 'Failed to parse ' . $this->getFormatName() . ': ' . $e->getMessage(), + 0, + $e, + ); + } + } + + /** + * Parse raw content into array. + * + * @return mixed + */ + abstract protected function parseContent(string $content): mixed; + + /** + * Get the format name for error messages. + */ + abstract protected function getFormatName(): string; + + protected function buildDocument(array $data): OpenApiDocument + { + $this->validateVersion($data); + + return new OpenApiDocument( + openapi: (string) $data['openapi'], + info: $this->buildInfo(TypeHelper::asArray($data['info'])), + jsonSchemaDialect: TypeHelper::asStringOrNull($data['jsonSchemaDialect'] ?? null), + servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, + paths: isset($data['paths']) ? $this->buildPaths(TypeHelper::asArray($data['paths'])) : null, + webhooks: isset($data['webhooks']) ? $this->buildWebhooks(TypeHelper::asArray($data['webhooks'])) : null, + components: isset($data['components']) ? $this->buildComponents(TypeHelper::asArray($data['components'])) : null, + security: isset($data['security']) ? new SecurityRequirement(TypeHelper::asSecurityListMapOrNull($data['security']) ?? []) : null, + tags: isset($data['tags']) ? new Tags($this->buildTags(TypeHelper::asList($data['tags']))) : null, + externalDocs: isset($data['externalDocs']) && is_array($data['externalDocs']) ? $this->buildExternalDocs(TypeHelper::asArray($data['externalDocs'])) : null, + ); + } + + protected function validateVersion(array $data): void + { + if (false === isset($data['openapi'])) { + throw new InvalidSchemaException('OpenAPI version is required'); + } + + $version = $data['openapi']; + if (false === is_string($version) || 1 !== preg_match('/^3\.[01]\.[0-9]+$/', $version)) { + throw new InvalidSchemaException('Unsupported OpenAPI version: ' . (string) $version . '. Only 3.0.x and 3.1.x are supported.'); + } + } + + protected function buildInfo(array $data): InfoObject + { + if (false === isset($data['title']) || false === isset($data['version'])) { + throw new InvalidSchemaException('Info object must have title and version'); + } + + return new InfoObject( + title: TypeHelper::asString($data['title']), + version: TypeHelper::asString($data['version']), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + termsOfService: TypeHelper::asStringOrNull($data['termsOfService'] ?? null), + contact: isset($data['contact']) ? $this->buildContact(TypeHelper::asArray($data['contact'])) : null, + license: isset($data['license']) ? $this->buildLicense(TypeHelper::asArray($data['license'])) : null, + ); + } + + protected function buildContact(array $data): Contact + { + return new Contact( + name: TypeHelper::asStringOrNull($data['name'] ?? null), + url: TypeHelper::asStringOrNull($data['url'] ?? null), + email: TypeHelper::asStringOrNull($data['email'] ?? null), + ); + } + + protected function buildLicense(array $data): License + { + return new License( + name: TypeHelper::asString($data['name']), + identifier: TypeHelper::asStringOrNull($data['identifier'] ?? null), + url: TypeHelper::asStringOrNull($data['url'] ?? null), + ); + } + + /** + * @return list + */ + protected function buildServers(array $data): array + { + return array_map(fn(array $server) => new Server( + url: TypeHelper::asString($server['url']), + description: TypeHelper::asStringOrNull($server['description'] ?? null), + variables: isset($server['variables']) && is_array($server['variables']) + ? TypeHelper::asStringMixedMapOrNull(TypeHelper::asArray($server['variables'])) + : null, + ), array_values($data)); + } + + /** + * @return list + */ + protected function buildTags(array $data): array + { + return array_map(fn(array $tag) => new Tag( + name: TypeHelper::asString($tag['name']), + description: TypeHelper::asStringOrNull($tag['description'] ?? null), + externalDocs: isset($tag['externalDocs']) && is_array($tag['externalDocs']) + ? $this->buildExternalDocs(TypeHelper::asArray($tag['externalDocs'])) + : null, + ), array_values($data)); + } + + protected function buildPaths(array $data): Paths + { + $paths = []; + + foreach ($data as $path => $pathItem) { + $paths[$path] = $this->buildPathItem(TypeHelper::asArray($pathItem)); + } + + /** @var array $paths */ + return new Paths($paths); + } + + protected function buildPathItem(array $data): PathItem + { + return new PathItem( + ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), + summary: TypeHelper::asStringOrNull($data['summary'] ?? null), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + get: isset($data['get']) ? $this->buildOperation(TypeHelper::asArray($data['get'])) : null, + put: isset($data['put']) ? $this->buildOperation(TypeHelper::asArray($data['put'])) : null, + post: isset($data['post']) ? $this->buildOperation(TypeHelper::asArray($data['post'])) : null, + delete: isset($data['delete']) ? $this->buildOperation(TypeHelper::asArray($data['delete'])) : null, + options: isset($data['options']) ? $this->buildOperation(TypeHelper::asArray($data['options'])) : null, + head: isset($data['head']) ? $this->buildOperation(TypeHelper::asArray($data['head'])) : null, + patch: isset($data['patch']) ? $this->buildOperation(TypeHelper::asArray($data['patch'])) : null, + trace: isset($data['trace']) ? $this->buildOperation(TypeHelper::asArray($data['trace'])) : null, + servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, + parameters: isset($data['parameters']) ? new Parameters($this->buildParameters(TypeHelper::asList($data['parameters']))) : null, + ); + } + + /** + * @return list + */ + protected function buildParameters(array $data): array + { + return array_map($this->buildParameter(...), array_values($data)); + } + + protected function buildParameter(array $data): Parameter + { + if (isset($data['$ref'])) { + return new Parameter( + ref: TypeHelper::asString($data['$ref']), + ); + } + + if (false === isset($data['name']) || false === isset($data['in'])) { + throw new InvalidSchemaException('Parameter must have name and in fields'); + } + + return new Parameter( + name: TypeHelper::asString($data['name']), + in: TypeHelper::asString($data['in']), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + required: (bool) ($data['required'] ?? false), + deprecated: (bool) ($data['deprecated'] ?? false), + allowEmptyValue: (bool) ($data['allowEmptyValue'] ?? false), + style: TypeHelper::asStringOrNull($data['style'] ?? null), + explode: (bool) ($data['explode'] ?? false), + allowReserved: (bool) ($data['allowReserved'] ?? false), + schema: isset($data['schema']) ? $this->buildSchema(TypeHelper::asArray($data['schema'])) : null, + examples: isset($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, + example: isset($data['example']) && false === is_array($data['example']) + ? (is_string($data['example']) ? $this->buildExample(['value' => $data['example']]) : null) + : null, + content: isset($data['content']) ? $this->buildContent(TypeHelper::asArray($data['content'])) : null, + ); + } + + protected function buildSchema(array $data): Schema + { + return new Schema( + ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), + format: TypeHelper::asStringOrNull($data['format'] ?? null), + title: TypeHelper::asStringOrNull($data['title'] ?? null), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + default: $data['default'] ?? null, + deprecated: (bool) ($data['deprecated'] ?? false), + type: TypeHelper::asStringOrNull($data['type'] ?? null), + nullable: (bool) ($data['nullable'] ?? false), + const: $data['const'] ?? null, + multipleOf: TypeHelper::asFloatOrNull($data['multipleOf'] ?? null), + maximum: TypeHelper::asFloatOrNull($data['maximum'] ?? null), + exclusiveMaximum: TypeHelper::asFloatOrNull($data['exclusiveMaximum'] ?? null), + minimum: TypeHelper::asFloatOrNull($data['minimum'] ?? null), + exclusiveMinimum: TypeHelper::asFloatOrNull($data['exclusiveMinimum'] ?? null), + maxLength: TypeHelper::asIntOrNull($data['maxLength'] ?? null), + minLength: TypeHelper::asIntOrNull($data['minLength'] ?? null), + pattern: TypeHelper::asStringOrNull($data['pattern'] ?? null), + maxItems: TypeHelper::asIntOrNull($data['maxItems'] ?? null), + minItems: TypeHelper::asIntOrNull($data['minItems'] ?? null), + uniqueItems: TypeHelper::asBoolOrNull($data['uniqueItems'] ?? null), + maxProperties: TypeHelper::asIntOrNull($data['maxProperties'] ?? null), + minProperties: TypeHelper::asIntOrNull($data['minProperties'] ?? null), + required: TypeHelper::asStringListOrNull($data['required'] ?? null), + allOf: isset($data['allOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['allOf']))) : null, + anyOf: isset($data['anyOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['anyOf']))) : null, + oneOf: isset($data['oneOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['oneOf']))) : null, + not: isset($data['not']) ? $this->buildSchema(TypeHelper::asArray($data['not'])) : null, + discriminator: isset($data['discriminator']) ? $this->buildDiscriminator(TypeHelper::asArray($data['discriminator'])) : null, + properties: isset($data['properties']) && is_array($data['properties']) + ? $this->buildProperties(TypeHelper::asArray($data['properties'])) + : null, + additionalProperties: isset($data['additionalProperties']) && is_array($data['additionalProperties']) + ? $this->buildSchema(TypeHelper::asArray($data['additionalProperties'])) + : (isset($data['additionalProperties']) ? (bool) $data['additionalProperties'] : null), + unevaluatedProperties: TypeHelper::asBoolOrNull($data['unevaluatedProperties'] ?? null), + items: isset($data['items']) && is_array($data['items']) ? $this->buildSchema(TypeHelper::asArray($data['items'])) : null, + prefixItems: isset($data['prefixItems']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['prefixItems']))) : null, + contains: isset($data['contains']) ? $this->buildSchema(TypeHelper::asArray($data['contains'])) : null, + minContains: TypeHelper::asIntOrNull($data['minContains'] ?? null), + maxContains: TypeHelper::asIntOrNull($data['maxContains'] ?? null), + patternProperties: isset($data['patternProperties']) && is_array($data['patternProperties']) + ? $this->buildProperties(TypeHelper::asArray($data['patternProperties'])) + : null, + propertyNames: isset($data['propertyNames']) ? $this->buildSchema(TypeHelper::asArray($data['propertyNames'])) : null, + dependentSchemas: isset($data['dependentSchemas']) && is_array($data['dependentSchemas']) + ? $this->buildProperties(TypeHelper::asArray($data['dependentSchemas'])) + : null, + if: isset($data['if']) ? $this->buildSchema(TypeHelper::asArray($data['if'])) : null, + then: isset($data['then']) ? $this->buildSchema(TypeHelper::asArray($data['then'])) : null, + else: isset($data['else']) ? $this->buildSchema(TypeHelper::asArray($data['else'])) : null, + unevaluatedItems: isset($data['unevaluatedItems']) ? $this->buildSchema(TypeHelper::asArray($data['unevaluatedItems'])) : null, + example: $data['example'] ?? null, + examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, + enum: TypeHelper::asEnumListOrNull($data['enum'] ?? null), + contentEncoding: TypeHelper::asStringOrNull($data['contentEncoding'] ?? null), + contentMediaType: TypeHelper::asStringOrNull($data['contentMediaType'] ?? null), + contentSchema: TypeHelper::asStringOrNull($data['contentSchema'] ?? null), + jsonSchemaDialect: TypeHelper::asStringOrNull($data['$schema'] ?? null), + ); + } + + /** + * @return array + */ + protected function buildProperties(array $data): array + { + $properties = []; + + foreach ($data as $name => $schema) { + if (is_string($name) && is_array($schema)) { + $properties[$name] = $this->buildSchema(TypeHelper::asArray($schema)); + } + } + + return $properties; + } + + protected function buildDiscriminator(array $data): Discriminator + { + if (false === isset($data['propertyName'])) { + throw new InvalidSchemaException('Discriminator must have propertyName'); + } + + return new Discriminator( + propertyName: TypeHelper::asString($data['propertyName']), + mapping: TypeHelper::asStringMapOrNull($data['mapping'] ?? null), + ); + } + + protected function buildOperation(array $data): Operation + { + return new Operation( + tags: TypeHelper::asStringListOrNull($data['tags'] ?? null), + summary: TypeHelper::asStringOrNull($data['summary'] ?? null), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + externalDocs: isset($data['externalDocs']) ? $this->buildExternalDocs(TypeHelper::asArray($data['externalDocs'])) : null, + operationId: TypeHelper::asStringOrNull($data['operationId'] ?? null), + parameters: isset($data['parameters']) ? new Parameters($this->buildParameters(TypeHelper::asList($data['parameters']))) : null, + requestBody: isset($data['requestBody']) ? $this->buildRequestBody(TypeHelper::asArray($data['requestBody'])) : null, + responses: isset($data['responses']) ? $this->buildResponses(TypeHelper::asArray($data['responses'])) : null, + callbacks: isset($data['callbacks']) ? $this->buildCallbacks(TypeHelper::asArray($data['callbacks'])) : null, + deprecated: (bool) ($data['deprecated'] ?? false), + security: isset($data['security']) ? new SecurityRequirement(TypeHelper::asSecurityListMapOrNull($data['security']) ?? []) : null, + servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, + ); + } + + protected function buildRequestBody(array $data): RequestBody + { + return new RequestBody( + description: TypeHelper::asStringOrNull($data['description'] ?? null), + content: isset($data['content']) && is_array($data['content']) + ? $this->buildContent(TypeHelper::asArray($data['content'])) + : null, + required: (bool) ($data['required'] ?? false), + ); + } + + protected function buildContent(array $data): Content + { + $mediaTypes = []; + + foreach ($data as $mediaType => $content) { + if (is_array($content)) { + $mediaTypes[$mediaType] = $this->buildMediaType(TypeHelper::asArray($content)); + } + } + + /** @var array $mediaTypes */ + return new Content($mediaTypes); + } + + protected function buildMediaType(array $data): MediaType + { + return new MediaType( + schema: isset($data['schema']) && is_array($data['schema']) + ? $this->buildSchema(TypeHelper::asArray($data['schema'])) + : null, + encoding: TypeHelper::asStringOrNull($data['encoding'] ?? null), + example: isset($data['example']) && false === is_array($data['example']) + ? $this->buildExample(['value' => $data['example']]) + : null, + examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, + ); + } + + protected function buildResponses(array $data): Responses + { + $responses = []; + + foreach ($data as $statusCode => $response) { + if (is_array($response)) { + $responses[$statusCode] = $this->buildResponse(TypeHelper::asArray($response)); + } + } + + /** @var array $responses */ + return new Responses($responses); + } + + protected function buildResponse(array $data): Response + { + if (isset($data['$ref'])) { + return new Response( + ref: TypeHelper::asString($data['$ref']), + ); + } + + return new Response( + description: TypeHelper::asStringOrNull($data['description'] ?? null), + headers: isset($data['headers']) && is_array($data['headers']) + ? $this->buildHeaders(TypeHelper::asArray($data['headers'])) + : null, + content: isset($data['content']) && is_array($data['content']) + ? $this->buildContent(TypeHelper::asArray($data['content'])) + : null, + links: isset($data['links']) && is_array($data['links']) + ? $this->buildLinks(TypeHelper::asArray($data['links'])) + : null, + ); + } + + protected function buildHeaders(array $data): Headers + { + $headers = []; + + foreach ($data as $headerName => $header) { + if (is_array($header)) { + $headers[$headerName] = $this->buildHeader(TypeHelper::asArray($header)); + } + } + + /** @var array $headers */ + return new Headers($headers); + } + + protected function buildHeader(array $data): Header + { + return new Header( + description: TypeHelper::asStringOrNull($data['description'] ?? null), + required: (bool) ($data['required'] ?? false), + deprecated: (bool) ($data['deprecated'] ?? false), + allowEmptyValue: (bool) ($data['allowEmptyValue'] ?? false), + schema: isset($data['schema']) && is_array($data['schema']) + ? $this->buildSchema(TypeHelper::asArray($data['schema'])) + : null, + example: $data['example'] ?? null, + examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, + content: isset($data['content']) && is_array($data['content']) + ? $this->buildContent(TypeHelper::asArray($data['content'])) + : null, + ); + } + + protected function buildLinks(array $data): Links + { + $links = []; + + foreach ($data as $linkName => $link) { + if (is_array($link)) { + $links[$linkName] = $this->buildLink(TypeHelper::asArray($link)); + } + } + + /** @var array $links */ + return new Links($links); + } + + protected function buildLink(array $data): Link + { + return new Link( + operationRef: TypeHelper::asStringOrNull($data['operationRef'] ?? null), + ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + operationId: TypeHelper::asStringOrNull($data['operationId'] ?? null), + parameters: isset($data['parameters']) && is_array($data['parameters']) ? TypeHelper::asStringMixedMapOrNull($data['parameters']) : null, + requestBody: isset($data['requestBody']) && is_array($data['requestBody']) + ? $this->buildRequestBody(TypeHelper::asArray($data['requestBody'])) + : null, + server: isset($data['server']) && is_array($data['server']) + ? $this->buildServer(TypeHelper::asArray($data['server'])) + : null, + ); + } + + protected function buildExternalDocs(array $data): ExternalDocs + { + if (false === isset($data['url'])) { + throw new InvalidSchemaException('External documentation must have url'); + } + + return new ExternalDocs( + url: TypeHelper::asString($data['url']), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + ); + } + + protected function buildWebhooks(array $data): Webhooks + { + $webhooks = []; + + foreach ($data as $webhookName => $webhook) { + if (is_array($webhook)) { + $webhooks[$webhookName] = $this->buildPathItem(TypeHelper::asArray($webhook)); + } + } + + /** @var array $webhooks */ + return new Webhooks($webhooks); + } + + protected function buildCallbacks(array $data): Callbacks + { + $callbacks = []; + + foreach ($data as $callbackName => $callback) { + if (is_array($callback)) { + foreach ($callback as $expression => $pathItem) { + if (is_string($expression) && is_array($pathItem)) { + $callbacks[$callbackName][$expression] = $this->buildPathItem(TypeHelper::asArray($pathItem)); + } + } + } + } + + /** @var array> $callbacks */ + return new Callbacks($callbacks); + } + + protected function buildComponents(array $data): Components + { + return new Components( + schemas: isset($data['schemas']) && is_array($data['schemas']) + ? $this->buildSchemas(TypeHelper::asArray($data['schemas'])) + : null, + responses: isset($data['responses']) && is_array($data['responses']) + ? $this->buildResponsesComponents(TypeHelper::asArray($data['responses'])) + : null, + parameters: isset($data['parameters']) && is_array($data['parameters']) + ? $this->buildParametersComponents(TypeHelper::asArray($data['parameters'])) + : null, + examples: isset($data['examples']) && is_array($data['examples']) + ? $this->buildExamplesComponents(TypeHelper::asArray($data['examples'])) + : null, + requestBodies: isset($data['requestBodies']) && is_array($data['requestBodies']) + ? $this->buildRequestBodiesComponents(TypeHelper::asArray($data['requestBodies'])) + : null, + headers: isset($data['headers']) && is_array($data['headers']) + ? $this->buildHeadersComponents(TypeHelper::asArray($data['headers'])) + : null, + securitySchemes: isset($data['securitySchemes']) && is_array($data['securitySchemes']) + ? $this->buildSecuritySchemesComponents(TypeHelper::asArray($data['securitySchemes'])) + : null, + links: isset($data['links']) && is_array($data['links']) + ? $this->buildLinksComponents(TypeHelper::asArray($data['links'])) + : null, + callbacks: isset($data['callbacks']) && is_array($data['callbacks']) + ? $this->buildCallbacksComponents(TypeHelper::asArray($data['callbacks'])) + : null, + pathItems: isset($data['pathItems']) && is_array($data['pathItems']) + ? $this->buildPathItemsComponents(TypeHelper::asArray($data['pathItems'])) + : null, + ); + } + + /** + * @return array + */ + protected function buildSchemas(array $data): array + { + $schemas = []; + + foreach ($data as $name => $schema) { + if (is_string($name) && is_array($schema)) { + $schemas[$name] = $this->buildSchema(TypeHelper::asArray($schema)); + } + } + + return $schemas; + } + + /** + * @return array + */ + protected function buildResponsesComponents(array $data): array + { + $responses = []; + + foreach ($data as $name => $response) { + if (is_string($name) && is_array($response)) { + $responses[$name] = $this->buildResponse(TypeHelper::asArray($response)); + } + } + + return $responses; + } + + /** + * @return array + */ + protected function buildParametersComponents(array $data): array + { + $parameters = []; + + foreach ($data as $name => $parameter) { + if (is_string($name) && is_array($parameter)) { + $parameters[$name] = $this->buildParameter(TypeHelper::asArray($parameter)); + } + } + + return $parameters; + } + + /** + * @return array + */ + protected function buildExamplesComponents(array $data): array + { + $examples = []; + + foreach ($data as $name => $example) { + if (is_string($name) && is_array($example)) { + $examples[$name] = $this->buildExample(TypeHelper::asArray($example)); + } + } + + return $examples; + } + + protected function buildExample(array $data): Example + { + return new Example( + summary: TypeHelper::asStringOrNull($data['summary'] ?? null), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + value: $data['value'] ?? null, + externalValue: TypeHelper::asStringOrNull($data['externalValue'] ?? null), + ); + } + + /** + * @return array + */ + protected function buildRequestBodiesComponents(array $data): array + { + $requestBodies = []; + + foreach ($data as $name => $requestBody) { + if (is_string($name) && is_array($requestBody)) { + $requestBodies[$name] = $this->buildRequestBody(TypeHelper::asArray($requestBody)); + } + } + + return $requestBodies; + } + + /** + * @return array + */ + protected function buildHeadersComponents(array $data): array + { + $headers = []; + + foreach ($data as $name => $header) { + if (is_string($name) && is_array($header)) { + $headers[$name] = $this->buildHeader(TypeHelper::asArray($header)); + } + } + + return $headers; + } + + /** + * @return array + */ + protected function buildSecuritySchemesComponents(array $data): array + { + $securitySchemes = []; + + foreach ($data as $name => $scheme) { + if (is_string($name) && is_array($scheme)) { + $securitySchemes[$name] = $this->buildSecurityScheme(TypeHelper::asArray($scheme)); + } + } + + return $securitySchemes; + } + + protected function buildSecurityScheme(array $data): SecurityScheme + { + if (false === isset($data['type'])) { + throw new InvalidSchemaException('Security scheme must have type'); + } + + return new SecurityScheme( + type: TypeHelper::asString($data['type']), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + name: TypeHelper::asStringOrNull($data['name'] ?? null), + in: TypeHelper::asStringOrNull($data['in'] ?? null), + scheme: TypeHelper::asStringOrNull($data['scheme'] ?? null), + bearerFormat: TypeHelper::asStringOrNull($data['bearerFormat'] ?? null), + flows: TypeHelper::asStringOrNull($data['flows'] ?? null), + authorizationUrl: TypeHelper::asStringOrNull($data['authorizationUrl'] ?? null), + tokenUrl: TypeHelper::asStringOrNull($data['tokenUrl'] ?? null), + refreshUrl: TypeHelper::asStringOrNull($data['refreshUrl'] ?? null), + scopes: isset($data['scopes']) ? TypeHelper::asArray($data['scopes']) : null, + ); + } + + /** + * @return array + */ + protected function buildLinksComponents(array $data): array + { + $links = []; + + foreach ($data as $name => $link) { + if (is_string($name) && is_array($link)) { + $links[$name] = $this->buildLink(TypeHelper::asArray($link)); + } + } + + return $links; + } + + /** + * @return array + */ + protected function buildCallbacksComponents(array $data): array + { + $callbacks = []; + + foreach ($data as $name => $callback) { + if (is_string($name) && is_array($callback)) { + $callbacks[$name] = $this->buildCallbacks(TypeHelper::asArray($callback)); + } + } + + return $callbacks; + } + + /** + * @return array + */ + protected function buildPathItemsComponents(array $data): array + { + $pathItems = []; + + foreach ($data as $name => $pathItem) { + if (is_string($name) && is_array($pathItem)) { + $pathItems[$name] = $this->buildPathItem(TypeHelper::asArray($pathItem)); + } + } + + return $pathItems; + } + + protected function buildServer(array $data): Server + { + return new Server( + url: TypeHelper::asString($data['url']), + description: TypeHelper::asStringOrNull($data['description'] ?? null), + variables: isset($data['variables']) && is_array($data['variables']) + ? TypeHelper::asStringMixedMapOrNull($data['variables']) + : null, + ); + } +} diff --git a/src/Schema/Parser/TypeHelper.php b/src/Schema/Parser/TypeHelper.php index b69a92e..857bf1f 100644 --- a/src/Schema/Parser/TypeHelper.php +++ b/src/Schema/Parser/TypeHelper.php @@ -12,7 +12,7 @@ use function is_int; use function is_string; -final readonly class TypeHelper +readonly class TypeHelper { /** * @param mixed $value diff --git a/src/Schema/Parser/YamlParser.php b/src/Schema/Parser/YamlParser.php index bcea758..0ad29e2 100644 --- a/src/Schema/Parser/YamlParser.php +++ b/src/Schema/Parser/YamlParser.php @@ -4,809 +4,20 @@ namespace Duyler\OpenApi\Schema\Parser; -use Duyler\OpenApi\Schema\Exception\InvalidSchemaException; -use Duyler\OpenApi\Schema\Model\Callbacks; -use Duyler\OpenApi\Schema\Model\Components; -use Duyler\OpenApi\Schema\Model\Contact; -use Duyler\OpenApi\Schema\Model\Content; -use Duyler\OpenApi\Schema\Model\Discriminator; -use Duyler\OpenApi\Schema\Model\Example; -use Duyler\OpenApi\Schema\Model\ExternalDocs; -use Duyler\OpenApi\Schema\Model\Header; -use Duyler\OpenApi\Schema\Model\Headers; -use Duyler\OpenApi\Schema\Model\InfoObject; -use Duyler\OpenApi\Schema\Model\License; -use Duyler\OpenApi\Schema\Model\Link; -use Duyler\OpenApi\Schema\Model\Links; -use Duyler\OpenApi\Schema\Model\MediaType; -use Duyler\OpenApi\Schema\Model\Operation; -use Duyler\OpenApi\Schema\Model\Parameter; -use Duyler\OpenApi\Schema\Model\Parameters; -use Duyler\OpenApi\Schema\Model\PathItem; -use Duyler\OpenApi\Schema\Model\Paths; -use Duyler\OpenApi\Schema\Model\RequestBody; -use Duyler\OpenApi\Schema\Model\Response; -use Duyler\OpenApi\Schema\Model\Responses; -use Duyler\OpenApi\Schema\Model\Schema; -use Duyler\OpenApi\Schema\Model\SecurityRequirement; -use Duyler\OpenApi\Schema\Model\SecurityScheme; -use Duyler\OpenApi\Schema\Model\Server; -use Duyler\OpenApi\Schema\Model\Servers; -use Duyler\OpenApi\Schema\Model\Tag; -use Duyler\OpenApi\Schema\Model\Tags; -use Duyler\OpenApi\Schema\Model\Webhooks; -use Duyler\OpenApi\Schema\OpenApiDocument; -use Duyler\OpenApi\Schema\SchemaParserInterface; use Override; use Symfony\Component\Yaml\Yaml; -use Throwable; -use function is_array; -use function is_string; - -final readonly class YamlParser implements SchemaParserInterface +readonly class YamlParser extends OpenApiBuilder { #[Override] - public function parse(string $content): OpenApiDocument - { - try { - $data = $this->parseYaml($content); - - if (false === is_array($data)) { - throw new InvalidSchemaException('Invalid YAML: expected array at root, got ' . get_debug_type($data)); - } - - return $this->buildDocument($data); - } catch (InvalidSchemaException $e) { - throw $e; - } catch (Throwable $e) { - throw new InvalidSchemaException('Failed to parse YAML: ' . $e->getMessage(), 0, $e); - } - } - - private function parseYaml(string $content): mixed + protected function parseContent(string $content): mixed { return Yaml::parse($content, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE); } - private function buildDocument(array $data): OpenApiDocument - { - $this->validateVersion($data); - - return new OpenApiDocument( - openapi: (string) $data['openapi'], - info: $this->buildInfo(TypeHelper::asArray($data['info'])), - jsonSchemaDialect: TypeHelper::asStringOrNull($data['jsonSchemaDialect'] ?? null), - servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, - paths: isset($data['paths']) ? $this->buildPaths(TypeHelper::asArray($data['paths'])) : null, - webhooks: isset($data['webhooks']) ? $this->buildWebhooks(TypeHelper::asArray($data['webhooks'])) : null, - components: isset($data['components']) ? $this->buildComponents(TypeHelper::asArray($data['components'])) : null, - security: isset($data['security']) ? new SecurityRequirement(TypeHelper::asSecurityListMapOrNull($data['security']) ?? []) : null, - tags: isset($data['tags']) ? new Tags($this->buildTags(TypeHelper::asList($data['tags']))) : null, - externalDocs: isset($data['externalDocs']) && is_array($data['externalDocs']) ? $this->buildExternalDocs(TypeHelper::asArray($data['externalDocs'])) : null, - ); - } - - private function validateVersion(array $data): void - { - if (false === isset($data['openapi'])) { - throw new InvalidSchemaException('OpenAPI version is required'); - } - - $version = $data['openapi']; - if (false === is_string($version) || 1 !== preg_match('/^3\.[01]\.[0-9]+$/', $version)) { - throw new InvalidSchemaException('Unsupported OpenAPI version: ' . (string) $version . '. Only 3.0.x and 3.1.x are supported.'); - } - } - - private function buildInfo(array $data): InfoObject - { - if (false === isset($data['title']) || false === isset($data['version'])) { - throw new InvalidSchemaException('Info object must have title and version'); - } - - return new InfoObject( - title: TypeHelper::asString($data['title']), - version: TypeHelper::asString($data['version']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - termsOfService: TypeHelper::asStringOrNull($data['termsOfService'] ?? null), - contact: isset($data['contact']) ? $this->buildContact(TypeHelper::asArray($data['contact'])) : null, - license: isset($data['license']) ? $this->buildLicense(TypeHelper::asArray($data['license'])) : null, - ); - } - - private function buildContact(array $data): Contact - { - return new Contact( - name: TypeHelper::asStringOrNull($data['name'] ?? null), - url: TypeHelper::asStringOrNull($data['url'] ?? null), - email: TypeHelper::asStringOrNull($data['email'] ?? null), - ); - } - - private function buildLicense(array $data): License - { - return new License( - name: TypeHelper::asString($data['name']), - identifier: TypeHelper::asStringOrNull($data['identifier'] ?? null), - url: TypeHelper::asStringOrNull($data['url'] ?? null), - ); - } - - /** - * @return list - */ - private function buildServers(array $data): array - { - return array_map(fn(array $server) => new Server( - url: TypeHelper::asString($server['url']), - description: TypeHelper::asStringOrNull($server['description'] ?? null), - variables: isset($server['variables']) && is_array($server['variables']) - ? TypeHelper::asStringMixedMapOrNull(TypeHelper::asArray($server['variables'])) - : null, - ), array_values($data)); - } - - /** - * @return list - */ - private function buildTags(array $data): array - { - return array_map(fn(array $tag) => new Tag( - name: TypeHelper::asString($tag['name']), - description: TypeHelper::asStringOrNull($tag['description'] ?? null), - externalDocs: isset($tag['externalDocs']) && is_array($tag['externalDocs']) - ? $this->buildExternalDocs(TypeHelper::asArray($tag['externalDocs'])) - : null, - ), array_values($data)); - } - - /** - * @return Paths - */ - private function buildPaths(array $data): Paths - { - $paths = []; - - foreach ($data as $path => $pathItem) { - $paths[$path] = $this->buildPathItem(TypeHelper::asArray($pathItem)); - } - - /** @var array $paths */ - return new Paths($paths); - } - - /** - */ - private function buildPathItem(array $data): PathItem - { - return new PathItem( - ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), - summary: TypeHelper::asStringOrNull($data['summary'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - get: isset($data['get']) ? $this->buildOperation(TypeHelper::asArray($data['get'])) : null, - put: isset($data['put']) ? $this->buildOperation(TypeHelper::asArray($data['put'])) : null, - post: isset($data['post']) ? $this->buildOperation(TypeHelper::asArray($data['post'])) : null, - delete: isset($data['delete']) ? $this->buildOperation(TypeHelper::asArray($data['delete'])) : null, - options: isset($data['options']) ? $this->buildOperation(TypeHelper::asArray($data['options'])) : null, - head: isset($data['head']) ? $this->buildOperation(TypeHelper::asArray($data['head'])) : null, - patch: isset($data['patch']) ? $this->buildOperation(TypeHelper::asArray($data['patch'])) : null, - trace: isset($data['trace']) ? $this->buildOperation(TypeHelper::asArray($data['trace'])) : null, - servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, - parameters: isset($data['parameters']) ? new Parameters($this->buildParameters(TypeHelper::asList($data['parameters']))) : null, - ); - } - - /** - * @return list - */ - private function buildParameters(array $data): array - { - return array_map($this->buildParameter(...), array_values($data)); - } - - /** - */ - private function buildParameter(array $data): Parameter - { - if (isset($data['$ref'])) { - return new Parameter( - ref: TypeHelper::asString($data['$ref']), - ); - } - - if (false === isset($data['name']) || false === isset($data['in'])) { - throw new InvalidSchemaException('Parameter must have name and in fields'); - } - - return new Parameter( - name: TypeHelper::asString($data['name']), - in: TypeHelper::asString($data['in']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - required: (bool) ($data['required'] ?? false), - deprecated: (bool) ($data['deprecated'] ?? false), - allowEmptyValue: (bool) ($data['allowEmptyValue'] ?? false), - style: TypeHelper::asStringOrNull($data['style'] ?? null), - explode: (bool) ($data['explode'] ?? false), - allowReserved: (bool) ($data['allowReserved'] ?? false), - schema: isset($data['schema']) ? $this->buildSchema(TypeHelper::asArray($data['schema'])) : null, - examples: isset($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - example: isset($data['example']) && false === is_array($data['example']) - ? (is_string($data['example']) ? $this->buildExample(['value' => $data['example']]) : null) - : null, - content: isset($data['content']) ? $this->buildContent(TypeHelper::asArray($data['content'])) : null, - ); - } - - /** - */ - private function buildSchema(array $data): Schema - { - return new Schema( - ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), - format: TypeHelper::asStringOrNull($data['format'] ?? null), - title: TypeHelper::asStringOrNull($data['title'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - default: $data['default'] ?? null, - deprecated: (bool) ($data['deprecated'] ?? false), - type: TypeHelper::asStringOrNull($data['type'] ?? null), - nullable: (bool) ($data['nullable'] ?? false), - const: $data['const'] ?? null, - multipleOf: TypeHelper::asFloatOrNull($data['multipleOf'] ?? null), - maximum: TypeHelper::asFloatOrNull($data['maximum'] ?? null), - exclusiveMaximum: TypeHelper::asFloatOrNull($data['exclusiveMaximum'] ?? null), - minimum: TypeHelper::asFloatOrNull($data['minimum'] ?? null), - exclusiveMinimum: TypeHelper::asFloatOrNull($data['exclusiveMinimum'] ?? null), - maxLength: TypeHelper::asIntOrNull($data['maxLength'] ?? null), - minLength: TypeHelper::asIntOrNull($data['minLength'] ?? null), - pattern: TypeHelper::asStringOrNull($data['pattern'] ?? null), - maxItems: TypeHelper::asIntOrNull($data['maxItems'] ?? null), - minItems: TypeHelper::asIntOrNull($data['minItems'] ?? null), - uniqueItems: TypeHelper::asBoolOrNull($data['uniqueItems'] ?? null), - maxProperties: TypeHelper::asIntOrNull($data['maxProperties'] ?? null), - minProperties: TypeHelper::asIntOrNull($data['minProperties'] ?? null), - required: TypeHelper::asStringListOrNull($data['required'] ?? null), - allOf: isset($data['allOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['allOf']))) : null, - anyOf: isset($data['anyOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['anyOf']))) : null, - oneOf: isset($data['oneOf']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['oneOf']))) : null, - not: isset($data['not']) ? $this->buildSchema(TypeHelper::asArray($data['not'])) : null, - discriminator: isset($data['discriminator']) ? $this->buildDiscriminator(TypeHelper::asArray($data['discriminator'])) : null, - properties: isset($data['properties']) && is_array($data['properties']) - ? $this->buildProperties(TypeHelper::asArray($data['properties'])) - : null, - additionalProperties: isset($data['additionalProperties']) && is_array($data['additionalProperties']) - ? $this->buildSchema(TypeHelper::asArray($data['additionalProperties'])) - : (isset($data['additionalProperties']) ? (bool) $data['additionalProperties'] : null), - unevaluatedProperties: TypeHelper::asBoolOrNull($data['unevaluatedProperties'] ?? null), - items: isset($data['items']) && is_array($data['items']) ? $this->buildSchema(TypeHelper::asArray($data['items'])) : null, - prefixItems: isset($data['prefixItems']) ? array_values(array_map(fn($s) => $this->buildSchema(TypeHelper::asArray($s)), TypeHelper::asArray($data['prefixItems']))) : null, - contains: isset($data['contains']) ? $this->buildSchema(TypeHelper::asArray($data['contains'])) : null, - minContains: TypeHelper::asIntOrNull($data['minContains'] ?? null), - maxContains: TypeHelper::asIntOrNull($data['maxContains'] ?? null), - patternProperties: isset($data['patternProperties']) && is_array($data['patternProperties']) - ? $this->buildProperties(TypeHelper::asArray($data['patternProperties'])) - : null, - propertyNames: isset($data['propertyNames']) ? $this->buildSchema(TypeHelper::asArray($data['propertyNames'])) : null, - dependentSchemas: isset($data['dependentSchemas']) && is_array($data['dependentSchemas']) - ? $this->buildProperties(TypeHelper::asArray($data['dependentSchemas'])) - : null, - if: isset($data['if']) ? $this->buildSchema(TypeHelper::asArray($data['if'])) : null, - then: isset($data['then']) ? $this->buildSchema(TypeHelper::asArray($data['then'])) : null, - else: isset($data['else']) ? $this->buildSchema(TypeHelper::asArray($data['else'])) : null, - unevaluatedItems: isset($data['unevaluatedItems']) ? $this->buildSchema(TypeHelper::asArray($data['unevaluatedItems'])) : null, - example: $data['example'] ?? null, - examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - enum: TypeHelper::asEnumListOrNull($data['enum'] ?? null), - contentEncoding: TypeHelper::asStringOrNull($data['contentEncoding'] ?? null), - contentMediaType: TypeHelper::asStringOrNull($data['contentMediaType'] ?? null), - contentSchema: TypeHelper::asStringOrNull($data['contentSchema'] ?? null), - jsonSchemaDialect: TypeHelper::asStringOrNull($data['$schema'] ?? null), - ); - } - - /** - * @return array - */ - private function buildProperties(array $data): array - { - $properties = []; - - foreach ($data as $name => $schema) { - if (is_string($name) && is_array($schema)) { - $properties[$name] = $this->buildSchema(TypeHelper::asArray($schema)); - } - } - - return $properties; - } - - /** - */ - private function buildDiscriminator(array $data): Discriminator - { - if (false === isset($data['propertyName'])) { - throw new InvalidSchemaException('Discriminator must have propertyName'); - } - - return new Discriminator( - propertyName: TypeHelper::asString($data['propertyName']), - mapping: TypeHelper::asStringMapOrNull($data['mapping'] ?? null), - ); - } - - /** - */ - private function buildOperation(array $data): Operation - { - return new Operation( - tags: TypeHelper::asStringListOrNull($data['tags'] ?? null), - summary: TypeHelper::asStringOrNull($data['summary'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - externalDocs: isset($data['externalDocs']) ? $this->buildExternalDocs(TypeHelper::asArray($data['externalDocs'])) : null, - operationId: TypeHelper::asStringOrNull($data['operationId'] ?? null), - parameters: isset($data['parameters']) ? new Parameters($this->buildParameters(TypeHelper::asList($data['parameters']))) : null, - requestBody: isset($data['requestBody']) ? $this->buildRequestBody(TypeHelper::asArray($data['requestBody'])) : null, - responses: isset($data['responses']) ? $this->buildResponses(TypeHelper::asArray($data['responses'])) : null, - callbacks: isset($data['callbacks']) ? $this->buildCallbacks(TypeHelper::asArray($data['callbacks'])) : null, - deprecated: (bool) ($data['deprecated'] ?? false), - security: isset($data['security']) ? new SecurityRequirement(TypeHelper::asSecurityListMapOrNull($data['security']) ?? []) : null, - servers: isset($data['servers']) ? new Servers($this->buildServers(TypeHelper::asList($data['servers']))) : null, - ); - } - - /** - */ - private function buildRequestBody(array $data): RequestBody - { - return new RequestBody( - description: TypeHelper::asStringOrNull($data['description'] ?? null), - content: isset($data['content']) && is_array($data['content']) - ? $this->buildContent(TypeHelper::asArray($data['content'])) - : null, - required: (bool) ($data['required'] ?? false), - ); - } - - /** - * @return Content - */ - private function buildContent(array $data): Content - { - $mediaTypes = []; - - foreach ($data as $mediaType => $content) { - if (is_array($content)) { - $mediaTypes[$mediaType] = $this->buildMediaType(TypeHelper::asArray($content)); - } - } - - /** @var array $mediaTypes */ - return new Content($mediaTypes); - } - - /** - */ - private function buildMediaType(array $data): MediaType - { - return new MediaType( - schema: isset($data['schema']) && is_array($data['schema']) - ? $this->buildSchema(TypeHelper::asArray($data['schema'])) - : null, - encoding: TypeHelper::asStringOrNull($data['encoding'] ?? null), - example: isset($data['example']) && false === is_array($data['example']) - ? $this->buildExample(['value' => $data['example']]) - : null, - examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - ); - } - - /** - * @return Responses - */ - private function buildResponses(array $data): Responses - { - $responses = []; - - foreach ($data as $statusCode => $response) { - if (is_array($response)) { - $responses[$statusCode] = $this->buildResponse(TypeHelper::asArray($response)); - } - } - - /** @var array $responses */ - return new Responses($responses); - } - - /** - */ - private function buildResponse(array $data): Response - { - if (isset($data['$ref'])) { - return new Response( - ref: TypeHelper::asString($data['$ref']), - ); - } - - return new Response( - description: TypeHelper::asStringOrNull($data['description'] ?? null), - headers: isset($data['headers']) && is_array($data['headers']) - ? $this->buildHeaders(TypeHelper::asArray($data['headers'])) - : null, - content: isset($data['content']) && is_array($data['content']) - ? $this->buildContent(TypeHelper::asArray($data['content'])) - : null, - links: isset($data['links']) && is_array($data['links']) - ? $this->buildLinks(TypeHelper::asArray($data['links'])) - : null, - ); - } - - /** - * @return Headers - */ - private function buildHeaders(array $data): Headers - { - $headers = []; - - foreach ($data as $headerName => $header) { - if (is_array($header)) { - $headers[$headerName] = $this->buildHeader(TypeHelper::asArray($header)); - } - } - - /** @var array $headers */ - return new Headers($headers); - } - - /** - */ - private function buildHeader(array $data): Header - { - return new Header( - description: TypeHelper::asStringOrNull($data['description'] ?? null), - required: (bool) ($data['required'] ?? false), - deprecated: (bool) ($data['deprecated'] ?? false), - allowEmptyValue: (bool) ($data['allowEmptyValue'] ?? false), - schema: isset($data['schema']) && is_array($data['schema']) - ? $this->buildSchema(TypeHelper::asArray($data['schema'])) - : null, - example: $data['example'] ?? null, - examples: isset($data['examples']) && is_array($data['examples']) ? TypeHelper::asStringMixedMapOrNull($data['examples']) : null, - content: isset($data['content']) && is_array($data['content']) - ? $this->buildContent(TypeHelper::asArray($data['content'])) - : null, - ); - } - - /** - * @return Links - */ - private function buildLinks(array $data): Links - { - $links = []; - - foreach ($data as $linkName => $link) { - if (is_array($link)) { - $links[$linkName] = $this->buildLink(TypeHelper::asArray($link)); - } - } - - /** @var array $links */ - return new Links($links); - } - - /** - */ - private function buildLink(array $data): Link - { - return new Link( - operationRef: TypeHelper::asStringOrNull($data['operationRef'] ?? null), - ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - operationId: TypeHelper::asStringOrNull($data['operationId'] ?? null), - parameters: isset($data['parameters']) && is_array($data['parameters']) ? TypeHelper::asStringMixedMapOrNull($data['parameters']) : null, - requestBody: isset($data['requestBody']) && is_array($data['requestBody']) - ? $this->buildRequestBody(TypeHelper::asArray($data['requestBody'])) - : null, - server: isset($data['server']) && is_array($data['server']) - ? $this->buildServer(TypeHelper::asArray($data['server'])) - : null, - ); - } - - /** - */ - private function buildExternalDocs(array $data): ExternalDocs - { - if (false === isset($data['url'])) { - throw new InvalidSchemaException('External documentation must have url'); - } - - return new ExternalDocs( - url: TypeHelper::asString($data['url']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - ); - } - - /** - * @return Webhooks - */ - private function buildWebhooks(array $data): Webhooks - { - $webhooks = []; - - foreach ($data as $webhookName => $webhook) { - if (is_array($webhook)) { - $webhooks[$webhookName] = $this->buildPathItem(TypeHelper::asArray($webhook)); - } - } - - /** @var array $webhooks */ - return new Webhooks($webhooks); - } - - /** - * @return Callbacks - */ - private function buildCallbacks(array $data): Callbacks - { - $callbacks = []; - - foreach ($data as $callbackName => $callback) { - if (is_array($callback)) { - foreach ($callback as $expression => $pathItem) { - if (is_string($expression) && is_array($pathItem)) { - $callbacks[$callbackName][$expression] = $this->buildPathItem(TypeHelper::asArray($pathItem)); - } - } - } - } - - /** @var array> $callbacks */ - return new Callbacks($callbacks); - } - - private function buildComponents(array $data): Components - { - return new Components( - schemas: isset($data['schemas']) && is_array($data['schemas']) - ? $this->buildSchemas(TypeHelper::asArray($data['schemas'])) - : null, - responses: isset($data['responses']) && is_array($data['responses']) - ? $this->buildResponsesComponents(TypeHelper::asArray($data['responses'])) - : null, - parameters: isset($data['parameters']) && is_array($data['parameters']) - ? $this->buildParametersComponents(TypeHelper::asArray($data['parameters'])) - : null, - examples: isset($data['examples']) && is_array($data['examples']) - ? $this->buildExamplesComponents(TypeHelper::asArray($data['examples'])) - : null, - requestBodies: isset($data['requestBodies']) && is_array($data['requestBodies']) - ? $this->buildRequestBodiesComponents(TypeHelper::asArray($data['requestBodies'])) - : null, - headers: isset($data['headers']) && is_array($data['headers']) - ? $this->buildHeadersComponents(TypeHelper::asArray($data['headers'])) - : null, - securitySchemes: isset($data['securitySchemes']) && is_array($data['securitySchemes']) - ? $this->buildSecuritySchemesComponents(TypeHelper::asArray($data['securitySchemes'])) - : null, - links: isset($data['links']) && is_array($data['links']) - ? $this->buildLinksComponents(TypeHelper::asArray($data['links'])) - : null, - callbacks: isset($data['callbacks']) && is_array($data['callbacks']) - ? $this->buildCallbacksComponents(TypeHelper::asArray($data['callbacks'])) - : null, - pathItems: isset($data['pathItems']) && is_array($data['pathItems']) - ? $this->buildPathItemsComponents(TypeHelper::asArray($data['pathItems'])) - : null, - ); - } - - /** - * @return array - */ - private function buildSchemas(array $data): array - { - $schemas = []; - - foreach ($data as $name => $schema) { - if (is_string($name) && is_array($schema)) { - $schemas[$name] = $this->buildSchema(TypeHelper::asArray($schema)); - } - } - - return $schemas; - } - - /** - * @return array - */ - private function buildResponsesComponents(array $data): array - { - $responses = []; - - foreach ($data as $name => $response) { - if (is_string($name) && is_array($response)) { - $responses[$name] = $this->buildResponse(TypeHelper::asArray($response)); - } - } - - return $responses; - } - - /** - * @return array - */ - private function buildParametersComponents(array $data): array - { - $parameters = []; - - foreach ($data as $name => $parameter) { - if (is_string($name) && is_array($parameter)) { - $parameters[$name] = $this->buildParameter(TypeHelper::asArray($parameter)); - } - } - - return $parameters; - } - - /** - * @return array - */ - private function buildExamplesComponents(array $data): array - { - $examples = []; - - foreach ($data as $name => $example) { - if (is_string($name) && is_array($example)) { - $examples[$name] = $this->buildExample(TypeHelper::asArray($example)); - } - } - - return $examples; - } - - /** - */ - private function buildExample(array $data): Example - { - return new Example( - summary: TypeHelper::asStringOrNull($data['summary'] ?? null), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - value: $data['value'] ?? null, - externalValue: TypeHelper::asStringOrNull($data['externalValue'] ?? null), - ); - } - - /** - * @return array - */ - private function buildRequestBodiesComponents(array $data): array - { - $requestBodies = []; - - foreach ($data as $name => $requestBody) { - if (is_string($name) && is_array($requestBody)) { - $requestBodies[$name] = $this->buildRequestBody(TypeHelper::asArray($requestBody)); - } - } - - return $requestBodies; - } - - /** - * @return array - */ - private function buildHeadersComponents(array $data): array - { - $headers = []; - - foreach ($data as $name => $header) { - if (is_string($name) && is_array($header)) { - $headers[$name] = $this->buildHeader(TypeHelper::asArray($header)); - } - } - - return $headers; - } - - /** - * @return array - */ - private function buildSecuritySchemesComponents(array $data): array - { - $securitySchemes = []; - - foreach ($data as $name => $scheme) { - if (is_string($name) && is_array($scheme)) { - $securitySchemes[$name] = $this->buildSecurityScheme(TypeHelper::asArray($scheme)); - } - } - - return $securitySchemes; - } - - /** - */ - private function buildSecurityScheme(array $data): SecurityScheme - { - if (false === isset($data['type'])) { - throw new InvalidSchemaException('Security scheme must have type'); - } - - return new SecurityScheme( - type: TypeHelper::asString($data['type']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - name: TypeHelper::asStringOrNull($data['name'] ?? null), - in: TypeHelper::asStringOrNull($data['in'] ?? null), - scheme: TypeHelper::asStringOrNull($data['scheme'] ?? null), - bearerFormat: TypeHelper::asStringOrNull($data['bearerFormat'] ?? null), - flows: TypeHelper::asStringOrNull($data['flows'] ?? null), - authorizationUrl: TypeHelper::asStringOrNull($data['authorizationUrl'] ?? null), - tokenUrl: TypeHelper::asStringOrNull($data['tokenUrl'] ?? null), - refreshUrl: TypeHelper::asStringOrNull($data['refreshUrl'] ?? null), - scopes: isset($data['scopes']) ? TypeHelper::asArray($data['scopes']) : null, - ); - } - - /** - * @return array - */ - private function buildLinksComponents(array $data): array - { - $links = []; - - foreach ($data as $name => $link) { - if (is_string($name) && is_array($link)) { - $links[$name] = $this->buildLink(TypeHelper::asArray($link)); - } - } - - return $links; - } - - /** - * @return array - */ - private function buildCallbacksComponents(array $data): array - { - $callbacks = []; - - foreach ($data as $name => $callback) { - if (is_string($name) && is_array($callback)) { - $callbacks[$name] = $this->buildCallbacks(TypeHelper::asArray($callback)); - } - } - - return $callbacks; - } - - /** - * @return array - */ - private function buildPathItemsComponents(array $data): array - { - $pathItems = []; - - foreach ($data as $name => $pathItem) { - if (is_string($name) && is_array($pathItem)) { - $pathItems[$name] = $this->buildPathItem(TypeHelper::asArray($pathItem)); - } - } - - return $pathItems; - } - - /** - */ - private function buildServer(array $data): Server + #[Override] + protected function getFormatName(): string { - return new Server( - url: TypeHelper::asString($data['url']), - description: TypeHelper::asStringOrNull($data['description'] ?? null), - variables: isset($data['variables']) && is_array($data['variables']) - ? TypeHelper::asStringMixedMapOrNull($data['variables']) - : null, - ); + return 'YAML'; } } diff --git a/src/Validator/EmptyArrayStrategy.php b/src/Validator/EmptyArrayStrategy.php new file mode 100644 index 0000000..bf5ce1c --- /dev/null +++ b/src/Validator/EmptyArrayStrategy.php @@ -0,0 +1,13 @@ + $stack diff --git a/src/Validator/Error/ValidationContext.php b/src/Validator/Error/ValidationContext.php index 551dd49..7b00cfa 100644 --- a/src/Validator/Error/ValidationContext.php +++ b/src/Validator/Error/ValidationContext.php @@ -4,16 +4,11 @@ namespace Duyler\OpenApi\Validator\Error; +use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Error\Formatter\ErrorFormatterInterface; use Duyler\OpenApi\Validator\Error\Formatter\SimpleFormatter; use Duyler\OpenApi\Validator\ValidatorPool; -/** - * Immutable context object passed through validation chain - * - * Contains breadcrumb manager for tracking path, validator pool - * for caching validator instances, and error formatter for displaying errors. - */ readonly class ValidationContext { public function __construct( @@ -21,15 +16,20 @@ public function __construct( public readonly ValidatorPool $pool, public readonly ErrorFormatterInterface $errorFormatter = new SimpleFormatter(), public readonly bool $nullableAsType = true, + public readonly EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, ) {} - public static function create(ValidatorPool $pool, bool $nullableAsType = true): self - { + public static function create( + ValidatorPool $pool, + bool $nullableAsType = true, + EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, + ): self { return new self( breadcrumbs: BreadcrumbManager::create(), pool: $pool, errorFormatter: new SimpleFormatter(), nullableAsType: $nullableAsType, + emptyArrayStrategy: $emptyArrayStrategy, ); } @@ -40,6 +40,7 @@ public function withBreadcrumb(string $segment): self pool: $this->pool, errorFormatter: $this->errorFormatter, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, ); } @@ -50,6 +51,7 @@ public function withBreadcrumbIndex(int $index): self pool: $this->pool, errorFormatter: $this->errorFormatter, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, ); } @@ -60,6 +62,7 @@ public function withoutBreadcrumb(): self pool: $this->pool, errorFormatter: $this->errorFormatter, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, ); } } diff --git a/src/Validator/Exception/AnyOfError.php b/src/Validator/Exception/AnyOfError.php index 2ae5b97..d9e0d40 100644 --- a/src/Validator/Exception/AnyOfError.php +++ b/src/Validator/Exception/AnyOfError.php @@ -6,7 +6,7 @@ use function sprintf; -class AnyOfError extends AbstractValidationError +final class AnyOfError extends AbstractValidationError { public function __construct( string $dataPath, diff --git a/src/Validator/Exception/ConstError.php b/src/Validator/Exception/ConstError.php index c836503..cc74494 100644 --- a/src/Validator/Exception/ConstError.php +++ b/src/Validator/Exception/ConstError.php @@ -6,7 +6,7 @@ use function sprintf; -class ConstError extends AbstractValidationError +final class ConstError extends AbstractValidationError { public function __construct( mixed $expected, diff --git a/src/Validator/Exception/DiscriminatorMismatchException.php b/src/Validator/Exception/DiscriminatorMismatchException.php index e0c3711..715a0ed 100644 --- a/src/Validator/Exception/DiscriminatorMismatchException.php +++ b/src/Validator/Exception/DiscriminatorMismatchException.php @@ -8,7 +8,7 @@ use function sprintf; -class DiscriminatorMismatchException extends AbstractValidationError +final class DiscriminatorMismatchException extends AbstractValidationError { public function __construct( string $expectedType, diff --git a/src/Validator/Exception/EnumError.php b/src/Validator/Exception/EnumError.php index fb1020d..44f3610 100644 --- a/src/Validator/Exception/EnumError.php +++ b/src/Validator/Exception/EnumError.php @@ -7,7 +7,7 @@ use function is_scalar; use function sprintf; -class EnumError extends AbstractValidationError +final class EnumError extends AbstractValidationError { public function __construct( array $allowedValues, diff --git a/src/Validator/Exception/InvalidDiscriminatorValueException.php b/src/Validator/Exception/InvalidDiscriminatorValueException.php index a50a46c..198fa03 100644 --- a/src/Validator/Exception/InvalidDiscriminatorValueException.php +++ b/src/Validator/Exception/InvalidDiscriminatorValueException.php @@ -8,7 +8,7 @@ use function sprintf; -class InvalidDiscriminatorValueException extends AbstractValidationError +final class InvalidDiscriminatorValueException extends AbstractValidationError { public function __construct( string $propertyName, diff --git a/src/Validator/Exception/InvalidFormatException.php b/src/Validator/Exception/InvalidFormatException.php index 70163fa..97aa8ed 100644 --- a/src/Validator/Exception/InvalidFormatException.php +++ b/src/Validator/Exception/InvalidFormatException.php @@ -7,7 +7,7 @@ use Override; use RuntimeException; -class InvalidFormatException extends RuntimeException implements ValidationErrorInterface +final class InvalidFormatException extends RuntimeException implements ValidationErrorInterface { public function __construct( public readonly string $format, diff --git a/src/Validator/Exception/MaxContainsError.php b/src/Validator/Exception/MaxContainsError.php index 32ac484..762e798 100644 --- a/src/Validator/Exception/MaxContainsError.php +++ b/src/Validator/Exception/MaxContainsError.php @@ -6,7 +6,7 @@ use function sprintf; -class MaxContainsError extends AbstractValidationError +final class MaxContainsError extends AbstractValidationError { public function __construct( int $maxContains, diff --git a/src/Validator/Exception/MaxItemsError.php b/src/Validator/Exception/MaxItemsError.php index cac377d..84f0605 100644 --- a/src/Validator/Exception/MaxItemsError.php +++ b/src/Validator/Exception/MaxItemsError.php @@ -6,7 +6,7 @@ use function sprintf; -class MaxItemsError extends AbstractValidationError +final class MaxItemsError extends AbstractValidationError { public function __construct( int $maxItems, diff --git a/src/Validator/Exception/MaxLengthError.php b/src/Validator/Exception/MaxLengthError.php index 927976f..a755551 100644 --- a/src/Validator/Exception/MaxLengthError.php +++ b/src/Validator/Exception/MaxLengthError.php @@ -6,7 +6,7 @@ use function sprintf; -class MaxLengthError extends AbstractValidationError +final class MaxLengthError extends AbstractValidationError { public function __construct( int $maxLength, diff --git a/src/Validator/Exception/MaxPropertiesError.php b/src/Validator/Exception/MaxPropertiesError.php index 7120ab1..e597927 100644 --- a/src/Validator/Exception/MaxPropertiesError.php +++ b/src/Validator/Exception/MaxPropertiesError.php @@ -6,7 +6,7 @@ use function sprintf; -class MaxPropertiesError extends AbstractValidationError +final class MaxPropertiesError extends AbstractValidationError { public function __construct( int $maxProperties, diff --git a/src/Validator/Exception/MaximumError.php b/src/Validator/Exception/MaximumError.php index 6c1a9d7..11f848e 100644 --- a/src/Validator/Exception/MaximumError.php +++ b/src/Validator/Exception/MaximumError.php @@ -6,7 +6,7 @@ use function sprintf; -class MaximumError extends AbstractValidationError +final class MaximumError extends AbstractValidationError { public function __construct( float $maximum, diff --git a/src/Validator/Exception/MinContainsError.php b/src/Validator/Exception/MinContainsError.php index 24ad17f..3a67f7e 100644 --- a/src/Validator/Exception/MinContainsError.php +++ b/src/Validator/Exception/MinContainsError.php @@ -6,7 +6,7 @@ use function sprintf; -class MinContainsError extends AbstractValidationError +final class MinContainsError extends AbstractValidationError { public function __construct( int $minContains, diff --git a/src/Validator/Exception/MinItemsError.php b/src/Validator/Exception/MinItemsError.php index 0b08b6a..ec8a671 100644 --- a/src/Validator/Exception/MinItemsError.php +++ b/src/Validator/Exception/MinItemsError.php @@ -6,7 +6,7 @@ use function sprintf; -class MinItemsError extends AbstractValidationError +final class MinItemsError extends AbstractValidationError { public function __construct( int $minItems, diff --git a/src/Validator/Exception/MinLengthError.php b/src/Validator/Exception/MinLengthError.php index 7cd3bea..c229bb8 100644 --- a/src/Validator/Exception/MinLengthError.php +++ b/src/Validator/Exception/MinLengthError.php @@ -6,7 +6,7 @@ use function sprintf; -class MinLengthError extends AbstractValidationError +final class MinLengthError extends AbstractValidationError { public function __construct( int $minLength, diff --git a/src/Validator/Exception/MinPropertiesError.php b/src/Validator/Exception/MinPropertiesError.php index a3b8ee9..daef16c 100644 --- a/src/Validator/Exception/MinPropertiesError.php +++ b/src/Validator/Exception/MinPropertiesError.php @@ -6,7 +6,7 @@ use function sprintf; -class MinPropertiesError extends AbstractValidationError +final class MinPropertiesError extends AbstractValidationError { public function __construct( int $minProperties, diff --git a/src/Validator/Exception/MinimumError.php b/src/Validator/Exception/MinimumError.php index 33ddebb..b6ae98b 100644 --- a/src/Validator/Exception/MinimumError.php +++ b/src/Validator/Exception/MinimumError.php @@ -6,7 +6,7 @@ use function sprintf; -class MinimumError extends AbstractValidationError +final class MinimumError extends AbstractValidationError { public function __construct( float $minimum, diff --git a/src/Validator/Exception/MissingDiscriminatorPropertyException.php b/src/Validator/Exception/MissingDiscriminatorPropertyException.php index 501bcb6..e005db9 100644 --- a/src/Validator/Exception/MissingDiscriminatorPropertyException.php +++ b/src/Validator/Exception/MissingDiscriminatorPropertyException.php @@ -8,7 +8,7 @@ use function sprintf; -class MissingDiscriminatorPropertyException extends AbstractValidationError +final class MissingDiscriminatorPropertyException extends AbstractValidationError { public function __construct( string $propertyName, diff --git a/src/Validator/Exception/MissingParameterException.php b/src/Validator/Exception/MissingParameterException.php index 1a7e243..38e0763 100644 --- a/src/Validator/Exception/MissingParameterException.php +++ b/src/Validator/Exception/MissingParameterException.php @@ -9,7 +9,7 @@ use function sprintf; -class MissingParameterException extends RuntimeException +final class MissingParameterException extends RuntimeException { public function __construct( public readonly string $location, diff --git a/src/Validator/Exception/MultipleOfError.php b/src/Validator/Exception/MultipleOfError.php index f01550e..2ea1301 100644 --- a/src/Validator/Exception/MultipleOfError.php +++ b/src/Validator/Exception/MultipleOfError.php @@ -6,7 +6,7 @@ use function sprintf; -class MultipleOfError extends AbstractValidationError +final class MultipleOfError extends AbstractValidationError { public function __construct( int $validCount, diff --git a/src/Validator/Exception/MultipleOfKeywordError.php b/src/Validator/Exception/MultipleOfKeywordError.php index ed23d1f..e267631 100644 --- a/src/Validator/Exception/MultipleOfKeywordError.php +++ b/src/Validator/Exception/MultipleOfKeywordError.php @@ -6,7 +6,7 @@ use function sprintf; -class MultipleOfKeywordError extends AbstractValidationError +final class MultipleOfKeywordError extends AbstractValidationError { public function __construct( float $multipleOf, diff --git a/src/Validator/Exception/OneOfError.php b/src/Validator/Exception/OneOfError.php index f2ea59e..f25c2df 100644 --- a/src/Validator/Exception/OneOfError.php +++ b/src/Validator/Exception/OneOfError.php @@ -6,7 +6,7 @@ use function sprintf; -class OneOfError extends AbstractValidationError +final class OneOfError extends AbstractValidationError { public function __construct( string $dataPath, diff --git a/src/Validator/Exception/PathMismatchException.php b/src/Validator/Exception/PathMismatchException.php index 760bbbc..78c3107 100644 --- a/src/Validator/Exception/PathMismatchException.php +++ b/src/Validator/Exception/PathMismatchException.php @@ -9,7 +9,7 @@ use function sprintf; -class PathMismatchException extends RuntimeException +final class PathMismatchException extends RuntimeException { public function __construct( public readonly string $template, diff --git a/src/Validator/Exception/PatternMismatchError.php b/src/Validator/Exception/PatternMismatchError.php index 494499f..73fad09 100644 --- a/src/Validator/Exception/PatternMismatchError.php +++ b/src/Validator/Exception/PatternMismatchError.php @@ -6,7 +6,7 @@ use function sprintf; -class PatternMismatchError extends AbstractValidationError +final class PatternMismatchError extends AbstractValidationError { public function __construct( string $pattern, diff --git a/src/Validator/Exception/RequiredError.php b/src/Validator/Exception/RequiredError.php index ea1022a..1646ad0 100644 --- a/src/Validator/Exception/RequiredError.php +++ b/src/Validator/Exception/RequiredError.php @@ -6,7 +6,7 @@ use function sprintf; -class RequiredError extends AbstractValidationError +final class RequiredError extends AbstractValidationError { public function __construct( string $property, diff --git a/src/Validator/Exception/TypeMismatchError.php b/src/Validator/Exception/TypeMismatchError.php index 87f206b..747a328 100644 --- a/src/Validator/Exception/TypeMismatchError.php +++ b/src/Validator/Exception/TypeMismatchError.php @@ -6,7 +6,7 @@ use function sprintf; -class TypeMismatchError extends AbstractValidationError +final class TypeMismatchError extends AbstractValidationError { public function __construct( string $expected, diff --git a/src/Validator/Exception/UndefinedResponseException.php b/src/Validator/Exception/UndefinedResponseException.php index 1cdd399..7f2f9b8 100644 --- a/src/Validator/Exception/UndefinedResponseException.php +++ b/src/Validator/Exception/UndefinedResponseException.php @@ -9,7 +9,7 @@ use function sprintf; -class UndefinedResponseException extends RuntimeException +final class UndefinedResponseException extends RuntimeException { /** * @param list $definedResponses diff --git a/src/Validator/Exception/UnknownDiscriminatorValueException.php b/src/Validator/Exception/UnknownDiscriminatorValueException.php index 8a4145e..3188e92 100644 --- a/src/Validator/Exception/UnknownDiscriminatorValueException.php +++ b/src/Validator/Exception/UnknownDiscriminatorValueException.php @@ -10,7 +10,7 @@ use function count; use function sprintf; -class UnknownDiscriminatorValueException extends AbstractValidationError +final class UnknownDiscriminatorValueException extends AbstractValidationError { public function __construct( string $value, diff --git a/src/Validator/Exception/UnsupportedMediaTypeException.php b/src/Validator/Exception/UnsupportedMediaTypeException.php index cf8e453..db1a5e1 100644 --- a/src/Validator/Exception/UnsupportedMediaTypeException.php +++ b/src/Validator/Exception/UnsupportedMediaTypeException.php @@ -9,7 +9,7 @@ use function sprintf; -class UnsupportedMediaTypeException extends RuntimeException +final class UnsupportedMediaTypeException extends RuntimeException { /** * @param list $supportedTypes diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php index 3ac557e..b21ade9 100644 --- a/src/Validator/Exception/ValidationException.php +++ b/src/Validator/Exception/ValidationException.php @@ -7,7 +7,7 @@ use Exception; use Throwable; -class ValidationException extends Exception +final class ValidationException extends Exception { /** * @param array $errors diff --git a/src/Validator/Format/BuiltinFormats.php b/src/Validator/Format/BuiltinFormats.php index 8bf673f..d46b63f 100644 --- a/src/Validator/Format/BuiltinFormats.php +++ b/src/Validator/Format/BuiltinFormats.php @@ -19,7 +19,7 @@ use Duyler\OpenApi\Validator\Format\String\UriValidator; use Duyler\OpenApi\Validator\Format\String\UuidValidator; -final readonly class BuiltinFormats +readonly class BuiltinFormats { public static function create(): FormatRegistry { diff --git a/src/Validator/Format/FormatRegistry.php b/src/Validator/Format/FormatRegistry.php index 7d3ba18..37d5868 100644 --- a/src/Validator/Format/FormatRegistry.php +++ b/src/Validator/Format/FormatRegistry.php @@ -4,7 +4,7 @@ namespace Duyler\OpenApi\Validator\Format; -final readonly class FormatRegistry +readonly class FormatRegistry { /** * @param array> $validators diff --git a/src/Validator/Format/Numeric/FloatDoubleValidator.php b/src/Validator/Format/Numeric/FloatDoubleValidator.php index cba8962..606ad54 100644 --- a/src/Validator/Format/Numeric/FloatDoubleValidator.php +++ b/src/Validator/Format/Numeric/FloatDoubleValidator.php @@ -12,7 +12,7 @@ use function is_float; -final readonly class FloatDoubleValidator implements FormatValidatorInterface +readonly class FloatDoubleValidator implements FormatValidatorInterface { private const string FLOAT = 'float'; private const string DOUBLE = 'double'; diff --git a/src/Validator/Format/String/ByteValidator.php b/src/Validator/Format/String/ByteValidator.php index 544f9c2..fbc0aaa 100644 --- a/src/Validator/Format/String/ByteValidator.php +++ b/src/Validator/Format/String/ByteValidator.php @@ -10,7 +10,7 @@ use function base64_decode; use function base64_encode; -final readonly class ByteValidator extends AbstractStringFormatValidator +readonly class ByteValidator extends AbstractStringFormatValidator { #[Override] protected function getFormatName(): string diff --git a/src/Validator/Format/String/DateTimeValidator.php b/src/Validator/Format/String/DateTimeValidator.php index ecac3db..f970c01 100644 --- a/src/Validator/Format/String/DateTimeValidator.php +++ b/src/Validator/Format/String/DateTimeValidator.php @@ -8,7 +8,7 @@ use Duyler\OpenApi\Validator\Exception\InvalidFormatException; use Override; -final readonly class DateTimeValidator extends AbstractStringFormatValidator +readonly class DateTimeValidator extends AbstractStringFormatValidator { #[Override] protected function getFormatName(): string diff --git a/src/Validator/Format/String/DateValidator.php b/src/Validator/Format/String/DateValidator.php index 1a59d29..8132156 100644 --- a/src/Validator/Format/String/DateValidator.php +++ b/src/Validator/Format/String/DateValidator.php @@ -8,7 +8,7 @@ use Duyler\OpenApi\Validator\Exception\InvalidFormatException; use Override; -final readonly class DateValidator extends AbstractStringFormatValidator +readonly class DateValidator extends AbstractStringFormatValidator { private const string DATE_FORMAT = 'Y-m-d'; diff --git a/src/Validator/Format/String/DurationValidator.php b/src/Validator/Format/String/DurationValidator.php index fef959a..b607554 100644 --- a/src/Validator/Format/String/DurationValidator.php +++ b/src/Validator/Format/String/DurationValidator.php @@ -11,7 +11,7 @@ use function str_contains; use function str_starts_with; -final readonly class DurationValidator extends AbstractStringFormatValidator +readonly class DurationValidator extends AbstractStringFormatValidator { private const string DURATION_PATTERN = '/^P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/'; diff --git a/src/Validator/Format/String/EmailValidator.php b/src/Validator/Format/String/EmailValidator.php index 7f18461..3138bcc 100644 --- a/src/Validator/Format/String/EmailValidator.php +++ b/src/Validator/Format/String/EmailValidator.php @@ -9,7 +9,7 @@ use const FILTER_VALIDATE_EMAIL; -final readonly class EmailValidator extends AbstractStringFormatValidator +readonly class EmailValidator extends AbstractStringFormatValidator { #[Override] protected function getFormatName(): string diff --git a/src/Validator/Format/String/HostnameValidator.php b/src/Validator/Format/String/HostnameValidator.php index 0f831f8..063b104 100644 --- a/src/Validator/Format/String/HostnameValidator.php +++ b/src/Validator/Format/String/HostnameValidator.php @@ -13,7 +13,7 @@ use function str_ends_with; use function str_starts_with; -final readonly class HostnameValidator extends AbstractStringFormatValidator +readonly class HostnameValidator extends AbstractStringFormatValidator { private const string HOSTNAME_PATTERN = '/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/'; private const int MAX_HOSTNAME_LENGTH = 253; diff --git a/src/Validator/Format/String/Ipv4Validator.php b/src/Validator/Format/String/Ipv4Validator.php index d9bd915..906ba1b 100644 --- a/src/Validator/Format/String/Ipv4Validator.php +++ b/src/Validator/Format/String/Ipv4Validator.php @@ -10,7 +10,7 @@ use const FILTER_FLAG_IPV4; use const FILTER_VALIDATE_IP; -final readonly class Ipv4Validator extends AbstractStringFormatValidator +readonly class Ipv4Validator extends AbstractStringFormatValidator { #[Override] protected function getFormatName(): string diff --git a/src/Validator/Format/String/Ipv6Validator.php b/src/Validator/Format/String/Ipv6Validator.php index 8494ed4..e2ef5dd 100644 --- a/src/Validator/Format/String/Ipv6Validator.php +++ b/src/Validator/Format/String/Ipv6Validator.php @@ -10,7 +10,7 @@ use const FILTER_FLAG_IPV6; use const FILTER_VALIDATE_IP; -final readonly class Ipv6Validator extends AbstractStringFormatValidator +readonly class Ipv6Validator extends AbstractStringFormatValidator { #[Override] protected function getFormatName(): string diff --git a/src/Validator/Format/String/JsonPointerValidator.php b/src/Validator/Format/String/JsonPointerValidator.php index a975cb6..e9c6173 100644 --- a/src/Validator/Format/String/JsonPointerValidator.php +++ b/src/Validator/Format/String/JsonPointerValidator.php @@ -9,7 +9,7 @@ use function preg_match; -final readonly class JsonPointerValidator extends AbstractStringFormatValidator +readonly class JsonPointerValidator extends AbstractStringFormatValidator { private const string POINTER_PATTERN = '/^(?:\/(?:[^~\/]|~0|~1)*)*$/'; diff --git a/src/Validator/Format/String/RelativeJsonPointerValidator.php b/src/Validator/Format/String/RelativeJsonPointerValidator.php index 610c4a1..1633ec4 100644 --- a/src/Validator/Format/String/RelativeJsonPointerValidator.php +++ b/src/Validator/Format/String/RelativeJsonPointerValidator.php @@ -9,7 +9,7 @@ use function preg_match; -final readonly class RelativeJsonPointerValidator extends AbstractStringFormatValidator +readonly class RelativeJsonPointerValidator extends AbstractStringFormatValidator { private const string RELATIVE_POINTER_PATTERN = '/^(0|[1-9]\d*)(#|\/(\/(?:[^~\/]|~0|~1)*)*)?$/'; diff --git a/src/Validator/Format/String/TimeValidator.php b/src/Validator/Format/String/TimeValidator.php index 61d156e..ef6bfd4 100644 --- a/src/Validator/Format/String/TimeValidator.php +++ b/src/Validator/Format/String/TimeValidator.php @@ -11,7 +11,7 @@ use function preg_match; use function substr; -final readonly class TimeValidator extends AbstractStringFormatValidator +readonly class TimeValidator extends AbstractStringFormatValidator { private const string TIME_FORMAT = 'H:i:s'; diff --git a/src/Validator/Format/String/UriValidator.php b/src/Validator/Format/String/UriValidator.php index b0e7368..6429d2d 100644 --- a/src/Validator/Format/String/UriValidator.php +++ b/src/Validator/Format/String/UriValidator.php @@ -9,7 +9,7 @@ use const FILTER_VALIDATE_URL; -final readonly class UriValidator extends AbstractStringFormatValidator +readonly class UriValidator extends AbstractStringFormatValidator { #[Override] protected function getFormatName(): string diff --git a/src/Validator/Format/String/UuidValidator.php b/src/Validator/Format/String/UuidValidator.php index 9d0bb2b..a40bb4b 100644 --- a/src/Validator/Format/String/UuidValidator.php +++ b/src/Validator/Format/String/UuidValidator.php @@ -9,7 +9,7 @@ use function preg_match; -final readonly class UuidValidator extends AbstractStringFormatValidator +readonly class UuidValidator extends AbstractStringFormatValidator { private const string UUID_PATTERN = '/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/'; diff --git a/src/Validator/OpenApiValidator.php b/src/Validator/OpenApiValidator.php index da4ab44..b5ddf72 100644 --- a/src/Validator/OpenApiValidator.php +++ b/src/Validator/OpenApiValidator.php @@ -46,12 +46,6 @@ use function sprintf; -/** - * OpenAPI validator for HTTP requests and responses. - * - * Validates incoming requests and outgoing responses against OpenAPI specification. - * Supports caching, custom format validators, error formatting, and event dispatching. - */ readonly class OpenApiValidator implements OpenApiValidatorInterface { public function __construct( @@ -64,6 +58,7 @@ public function __construct( public readonly ?object $logger = null, public readonly bool $coercion = false, public readonly bool $nullableAsType = true, + public readonly EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, public readonly ?EventDispatcherInterface $eventDispatcher = null, ) {} @@ -249,6 +244,7 @@ private function createRequestValidator(): RequestValidator negotiator: new ContentTypeNegotiator(), bodyParser: $bodyParser, nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, coercion: $this->coercion, ), ); @@ -262,6 +258,7 @@ private function createResponseValidator(): ResponseValidatorWithContext coercion: $this->coercion, statusCodeValidator: new StatusCodeValidator(), nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, ); } @@ -271,6 +268,8 @@ private function createValidationContext(): ValidationContext breadcrumbs: BreadcrumbManager::create(), pool: $this->pool, errorFormatter: $this->errorFormatter, + nullableAsType: $this->nullableAsType, + emptyArrayStrategy: $this->emptyArrayStrategy, ); } diff --git a/src/Validator/Operation.php b/src/Validator/Operation.php index 1525990..d226aa0 100644 --- a/src/Validator/Operation.php +++ b/src/Validator/Operation.php @@ -13,7 +13,7 @@ use function count; use function is_string; -final readonly class Operation implements Stringable +readonly class Operation implements Stringable { public function __construct( public readonly string $path, diff --git a/src/Validator/PathFinder.php b/src/Validator/PathFinder.php index f73973d..0a018de 100644 --- a/src/Validator/PathFinder.php +++ b/src/Validator/PathFinder.php @@ -16,7 +16,7 @@ use function strtoupper; use function usort; -final readonly class PathFinder +readonly class PathFinder { public function __construct( private readonly OpenApiDocument $document, diff --git a/src/Validator/Registry/DefaultValidatorRegistry.php b/src/Validator/Registry/DefaultValidatorRegistry.php index 263c70c..43e9648 100644 --- a/src/Validator/Registry/DefaultValidatorRegistry.php +++ b/src/Validator/Registry/DefaultValidatorRegistry.php @@ -41,7 +41,7 @@ use function assert; -final readonly class DefaultValidatorRegistry implements ValidatorRegistryInterface +readonly class DefaultValidatorRegistry implements ValidatorRegistryInterface { public readonly FormatRegistry $formatRegistry; diff --git a/src/Validator/Request/BodyParser/BodyParser.php b/src/Validator/Request/BodyParser/BodyParser.php index 04c5560..4e2fb81 100644 --- a/src/Validator/Request/BodyParser/BodyParser.php +++ b/src/Validator/Request/BodyParser/BodyParser.php @@ -4,7 +4,7 @@ namespace Duyler\OpenApi\Validator\Request\BodyParser; -final readonly class BodyParser +readonly class BodyParser { public function __construct( private readonly JsonBodyParser $jsonParser, diff --git a/src/Validator/Request/BodyParser/FormBodyParser.php b/src/Validator/Request/BodyParser/FormBodyParser.php index e81c663..31e4a61 100644 --- a/src/Validator/Request/BodyParser/FormBodyParser.php +++ b/src/Validator/Request/BodyParser/FormBodyParser.php @@ -4,7 +4,7 @@ namespace Duyler\OpenApi\Validator\Request\BodyParser; -final readonly class FormBodyParser +readonly class FormBodyParser { /** * @return array diff --git a/src/Validator/Request/BodyParser/JsonBodyParser.php b/src/Validator/Request/BodyParser/JsonBodyParser.php index 9733b18..6942f37 100644 --- a/src/Validator/Request/BodyParser/JsonBodyParser.php +++ b/src/Validator/Request/BodyParser/JsonBodyParser.php @@ -9,7 +9,7 @@ use const JSON_THROW_ON_ERROR; -final readonly class JsonBodyParser +readonly class JsonBodyParser { /** * @throws JsonException diff --git a/src/Validator/Request/BodyParser/MultipartBodyParser.php b/src/Validator/Request/BodyParser/MultipartBodyParser.php index ea3c9f5..bc723f4 100644 --- a/src/Validator/Request/BodyParser/MultipartBodyParser.php +++ b/src/Validator/Request/BodyParser/MultipartBodyParser.php @@ -6,7 +6,7 @@ use function count; -final readonly class MultipartBodyParser +readonly class MultipartBodyParser { /** * @return list> diff --git a/src/Validator/Request/BodyParser/TextBodyParser.php b/src/Validator/Request/BodyParser/TextBodyParser.php index e2eba4f..3c66636 100644 --- a/src/Validator/Request/BodyParser/TextBodyParser.php +++ b/src/Validator/Request/BodyParser/TextBodyParser.php @@ -4,7 +4,7 @@ namespace Duyler\OpenApi\Validator\Request\BodyParser; -final readonly class TextBodyParser +readonly class TextBodyParser { public function parse(string $body): string { diff --git a/src/Validator/Request/BodyParser/XmlBodyParser.php b/src/Validator/Request/BodyParser/XmlBodyParser.php index 99f8c0c..4ca62c0 100644 --- a/src/Validator/Request/BodyParser/XmlBodyParser.php +++ b/src/Validator/Request/BodyParser/XmlBodyParser.php @@ -8,7 +8,7 @@ use function is_array; -final readonly class XmlBodyParser +readonly class XmlBodyParser { /** * @return array|string @@ -19,7 +19,15 @@ public function parse(string $body): array|string return ''; } + libxml_set_external_entity_loader(null); + libxml_use_internal_errors(true); + try { + /** + * IMPORTANT: Never add LIBXML_NOENT flag here. + * Adding LIBXML_NOENT would enable entity substitution and allow + * XXE attacks even with external entity loader disabled. + */ $xml = simplexml_load_string($body); if (false === $xml) { @@ -39,6 +47,8 @@ public function parse(string $body): array|string return $decoded; } catch (ValueError) { return $body; + } finally { + libxml_clear_errors(); } } } diff --git a/src/Validator/Request/ContentTypeNegotiator.php b/src/Validator/Request/ContentTypeNegotiator.php index 3a32de2..badf142 100644 --- a/src/Validator/Request/ContentTypeNegotiator.php +++ b/src/Validator/Request/ContentTypeNegotiator.php @@ -4,7 +4,7 @@ namespace Duyler\OpenApi\Validator\Request; -final readonly class ContentTypeNegotiator +readonly class ContentTypeNegotiator { public function getMediaType(string $contentType): string { diff --git a/src/Validator/Request/CookieValidator.php b/src/Validator/Request/CookieValidator.php index 72a8db0..af03a49 100644 --- a/src/Validator/Request/CookieValidator.php +++ b/src/Validator/Request/CookieValidator.php @@ -8,7 +8,7 @@ use function count; -final readonly class CookieValidator extends AbstractParameterValidator +readonly class CookieValidator extends AbstractParameterValidator { public function parseCookies(string $cookieHeader): array { diff --git a/src/Validator/Request/HeaderFinder.php b/src/Validator/Request/HeaderFinder.php index 9db53cc..57a2b2c 100644 --- a/src/Validator/Request/HeaderFinder.php +++ b/src/Validator/Request/HeaderFinder.php @@ -10,7 +10,7 @@ use function is_string; use function strval; -final readonly class HeaderFinder +readonly class HeaderFinder { public function find(array $headers, string $name): ?string { diff --git a/src/Validator/Request/HeadersValidator.php b/src/Validator/Request/HeadersValidator.php index 844b834..c8ab51f 100644 --- a/src/Validator/Request/HeadersValidator.php +++ b/src/Validator/Request/HeadersValidator.php @@ -8,7 +8,7 @@ use Duyler\OpenApi\Validator\SchemaValidator\SchemaValidatorInterface; use Override; -final readonly class HeadersValidator extends AbstractParameterValidator +readonly class HeadersValidator extends AbstractParameterValidator { public function __construct( protected readonly SchemaValidatorInterface $schemaValidator, diff --git a/src/Validator/Request/ParameterDeserializer.php b/src/Validator/Request/ParameterDeserializer.php index 703b3f5..082c5fc 100644 --- a/src/Validator/Request/ParameterDeserializer.php +++ b/src/Validator/Request/ParameterDeserializer.php @@ -11,7 +11,7 @@ use function strlen; use function assert; -final readonly class ParameterDeserializer +readonly class ParameterDeserializer { /** * Deserialize parameter value based on style diff --git a/src/Validator/Request/PathParametersValidator.php b/src/Validator/Request/PathParametersValidator.php index 186df24..0a65ddb 100644 --- a/src/Validator/Request/PathParametersValidator.php +++ b/src/Validator/Request/PathParametersValidator.php @@ -6,7 +6,7 @@ use Override; -final readonly class PathParametersValidator extends AbstractParameterValidator +readonly class PathParametersValidator extends AbstractParameterValidator { #[Override] protected function getLocation(): string diff --git a/src/Validator/Request/PathParser.php b/src/Validator/Request/PathParser.php index 8096a90..d4ca056 100644 --- a/src/Validator/Request/PathParser.php +++ b/src/Validator/Request/PathParser.php @@ -9,7 +9,7 @@ use function is_string; use function assert; -final readonly class PathParser +readonly class PathParser { /** * Extract parameter names from path template diff --git a/src/Validator/Request/QueryParametersValidator.php b/src/Validator/Request/QueryParametersValidator.php index 43af8d8..c4af34d 100644 --- a/src/Validator/Request/QueryParametersValidator.php +++ b/src/Validator/Request/QueryParametersValidator.php @@ -7,7 +7,7 @@ use Duyler\OpenApi\Schema\Model\Parameter; use Override; -final readonly class QueryParametersValidator extends AbstractParameterValidator +readonly class QueryParametersValidator extends AbstractParameterValidator { #[Override] protected function getLocation(): string diff --git a/src/Validator/Request/QueryParser.php b/src/Validator/Request/QueryParser.php index 9a53b67..4f14cb1 100644 --- a/src/Validator/Request/QueryParser.php +++ b/src/Validator/Request/QueryParser.php @@ -4,7 +4,7 @@ namespace Duyler\OpenApi\Validator\Request; -final readonly class QueryParser +readonly class QueryParser { /** * Parse query string into parameters diff --git a/src/Validator/Request/RequestBodyCoercer.php b/src/Validator/Request/RequestBodyCoercer.php index 2ef0dda..5842053 100644 --- a/src/Validator/Request/RequestBodyCoercer.php +++ b/src/Validator/Request/RequestBodyCoercer.php @@ -14,7 +14,7 @@ use function is_numeric; use function is_string; -final readonly class RequestBodyCoercer +readonly class RequestBodyCoercer { public function coerce(mixed $value, ?Schema $schema, bool $enabled, bool $strict = false, bool $nullableAsType = true): mixed { diff --git a/src/Validator/Request/RequestBodyValidator.php b/src/Validator/Request/RequestBodyValidator.php index f92a40f..9e930e0 100644 --- a/src/Validator/Request/RequestBodyValidator.php +++ b/src/Validator/Request/RequestBodyValidator.php @@ -14,7 +14,7 @@ use Duyler\OpenApi\Validator\Request\BodyParser\XmlBodyParser; use Duyler\OpenApi\Validator\SchemaValidator\SchemaValidatorInterface; -final readonly class RequestBodyValidator implements RequestBodyValidatorInterface +readonly class RequestBodyValidator implements RequestBodyValidatorInterface { public function __construct( private readonly SchemaValidatorInterface $schemaValidator, diff --git a/src/Validator/Request/RequestBodyValidatorWithContext.php b/src/Validator/Request/RequestBodyValidatorWithContext.php index 852d248..03ce77f 100644 --- a/src/Validator/Request/RequestBodyValidatorWithContext.php +++ b/src/Validator/Request/RequestBodyValidatorWithContext.php @@ -6,6 +6,7 @@ use Duyler\OpenApi\Schema\Model\RequestBody; use Duyler\OpenApi\Schema\OpenApiDocument; +use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Exception\UnsupportedMediaTypeException; use Duyler\OpenApi\Validator\Request\BodyParser\BodyParser; use Duyler\OpenApi\Validator\Schema\RefResolver; @@ -18,7 +19,7 @@ use Duyler\OpenApi\Validator\Format\BuiltinFormats; use Override; -final readonly class RequestBodyValidatorWithContext implements RequestBodyValidatorInterface +readonly class RequestBodyValidatorWithContext implements RequestBodyValidatorInterface { private SchemaValidator $regularSchemaValidator; private SchemaValidatorWithContext $contextSchemaValidator; @@ -31,13 +32,14 @@ public function __construct( private readonly BodyParser $bodyParser, private readonly ContentTypeNegotiator $negotiator = new ContentTypeNegotiator(), private readonly bool $nullableAsType = true, + private readonly EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, private readonly bool $coercion = false, ) { $formatRegistry = BuiltinFormats::create(); $this->regularSchemaValidator = new SchemaValidator($this->pool, $formatRegistry); $this->refResolver = new RefResolver(); - $this->contextSchemaValidator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document, $this->nullableAsType); + $this->contextSchemaValidator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document, $this->nullableAsType, $this->emptyArrayStrategy); $this->coercer = new RequestBodyCoercer(); } @@ -87,7 +89,7 @@ public function validate( if ($hasDiscriminator) { $this->contextSchemaValidator->validate($parsedBody, $schema); } else { - $context = ValidationContext::create($this->pool, $this->nullableAsType); + $context = ValidationContext::create($this->pool, $this->nullableAsType, $this->emptyArrayStrategy); $this->regularSchemaValidator->validate($parsedBody, $schema, $context); } } diff --git a/src/Validator/Request/RequestValidator.php b/src/Validator/Request/RequestValidator.php index a22e284..b161b16 100644 --- a/src/Validator/Request/RequestValidator.php +++ b/src/Validator/Request/RequestValidator.php @@ -10,7 +10,7 @@ use function is_array; -final readonly class RequestValidator +readonly class RequestValidator { public function __construct( private readonly PathParser $pathParser, diff --git a/src/Validator/Request/TypeCoercer.php b/src/Validator/Request/TypeCoercer.php index 2f4d288..724e924 100644 --- a/src/Validator/Request/TypeCoercer.php +++ b/src/Validator/Request/TypeCoercer.php @@ -16,7 +16,7 @@ use function is_object; use function get_object_vars; -final readonly class TypeCoercer +readonly class TypeCoercer { /** * @return array|int|string|float|bool diff --git a/src/Validator/Response/ResponseBodyValidator.php b/src/Validator/Response/ResponseBodyValidator.php index 5a977e6..d2395e2 100644 --- a/src/Validator/Response/ResponseBodyValidator.php +++ b/src/Validator/Response/ResponseBodyValidator.php @@ -10,7 +10,7 @@ use Duyler\OpenApi\Validator\SchemaValidator\SchemaValidatorInterface; use Duyler\OpenApi\Validator\TypeGuarantor; -final readonly class ResponseBodyValidator +readonly class ResponseBodyValidator { public function __construct( private readonly SchemaValidatorInterface $schemaValidator, diff --git a/src/Validator/Response/ResponseBodyValidatorWithContext.php b/src/Validator/Response/ResponseBodyValidatorWithContext.php index b16b5db..76782da 100644 --- a/src/Validator/Response/ResponseBodyValidatorWithContext.php +++ b/src/Validator/Response/ResponseBodyValidatorWithContext.php @@ -6,6 +6,7 @@ use Duyler\OpenApi\Schema\Model\Content; use Duyler\OpenApi\Schema\OpenApiDocument; +use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Request\BodyParser\BodyParser; use Duyler\OpenApi\Validator\Request\ContentTypeNegotiator; use Duyler\OpenApi\Validator\Schema\RefResolver; @@ -16,7 +17,7 @@ use Duyler\OpenApi\Validator\Error\ValidationContext; use Duyler\OpenApi\Validator\Format\BuiltinFormats; -final readonly class ResponseBodyValidatorWithContext +readonly class ResponseBodyValidatorWithContext { private SchemaValidator $regularSchemaValidator; private SchemaValidatorWithContext $contextSchemaValidator; @@ -30,12 +31,13 @@ public function __construct( private readonly ResponseTypeCoercer $typeCoercer = new ResponseTypeCoercer(), private readonly bool $coercion = false, private readonly bool $nullableAsType = true, + private readonly EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, ) { $formatRegistry = BuiltinFormats::create(); $this->regularSchemaValidator = new SchemaValidator($this->pool, $formatRegistry); $this->refResolver = new RefResolver(); - $this->contextSchemaValidator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document, $this->nullableAsType); + $this->contextSchemaValidator = new SchemaValidatorWithContext($this->pool, $this->refResolver, $this->document, $this->nullableAsType, $this->emptyArrayStrategy); } public function validate( @@ -65,7 +67,7 @@ public function validate( $schema = $mediaTypeSchema->schema; $hasDiscriminator = null !== $schema->discriminator || $this->refResolver->schemaHasDiscriminator($schema, $this->document); - $context = ValidationContext::create($this->pool, $this->nullableAsType); + $context = ValidationContext::create($this->pool, $this->nullableAsType, $this->emptyArrayStrategy); if ($hasDiscriminator) { $this->contextSchemaValidator->validate($parsedBody, $schema); diff --git a/src/Validator/Response/ResponseHeadersValidator.php b/src/Validator/Response/ResponseHeadersValidator.php index a8adfbe..6904711 100644 --- a/src/Validator/Response/ResponseHeadersValidator.php +++ b/src/Validator/Response/ResponseHeadersValidator.php @@ -19,7 +19,7 @@ use function is_numeric; use function strtolower; -final readonly class ResponseHeadersValidator +readonly class ResponseHeadersValidator { public function __construct( private readonly SchemaValidatorInterface $schemaValidator, diff --git a/src/Validator/Response/ResponseTypeCoercer.php b/src/Validator/Response/ResponseTypeCoercer.php index 238670c..619d8b6 100644 --- a/src/Validator/Response/ResponseTypeCoercer.php +++ b/src/Validator/Response/ResponseTypeCoercer.php @@ -12,7 +12,7 @@ use function is_int; use function is_string; -final readonly class ResponseTypeCoercer +readonly class ResponseTypeCoercer { public function coerce(mixed $value, ?Schema $schema, bool $enabled, bool $nullableAsType = true): mixed { diff --git a/src/Validator/Response/ResponseValidator.php b/src/Validator/Response/ResponseValidator.php index 0ef66ed..37e256b 100644 --- a/src/Validator/Response/ResponseValidator.php +++ b/src/Validator/Response/ResponseValidator.php @@ -9,7 +9,7 @@ use function is_array; -final readonly class ResponseValidator +readonly class ResponseValidator { public function __construct( private readonly StatusCodeValidator $statusCodeValidator, diff --git a/src/Validator/Response/ResponseValidatorWithContext.php b/src/Validator/Response/ResponseValidatorWithContext.php index 29bb3c6..376f161 100644 --- a/src/Validator/Response/ResponseValidatorWithContext.php +++ b/src/Validator/Response/ResponseValidatorWithContext.php @@ -6,6 +6,7 @@ use Duyler\OpenApi\Schema\Model\Operation; use Duyler\OpenApi\Schema\OpenApiDocument; +use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Request\BodyParser\BodyParser; use Duyler\OpenApi\Validator\Request\BodyParser\FormBodyParser; use Duyler\OpenApi\Validator\Request\BodyParser\JsonBodyParser; @@ -22,7 +23,7 @@ use function is_array; use function assert; -final readonly class ResponseValidatorWithContext +readonly class ResponseValidatorWithContext { public function __construct( private readonly ValidatorPool $pool, @@ -30,6 +31,7 @@ public function __construct( private readonly bool $coercion = false, private readonly StatusCodeValidator $statusCodeValidator = new StatusCodeValidator(), private readonly bool $nullableAsType = true, + private readonly EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, private readonly ?RefResolverInterface $refResolver = null, ) {} @@ -73,7 +75,7 @@ public function validate( xmlParser: new XmlBodyParser(), ); - $bodyValidator = new ResponseBodyValidatorWithContext($this->pool, $this->document, $bodyParser, coercion: $this->coercion, nullableAsType: $this->nullableAsType); + $bodyValidator = new ResponseBodyValidatorWithContext($this->pool, $this->document, $bodyParser, coercion: $this->coercion, nullableAsType: $this->nullableAsType, emptyArrayStrategy: $this->emptyArrayStrategy); $bodyValidator->validate($body, $contentType, $responseDefinition->content ?? null); } diff --git a/src/Validator/Response/StatusCodeValidator.php b/src/Validator/Response/StatusCodeValidator.php index ea4bbac..373d90e 100644 --- a/src/Validator/Response/StatusCodeValidator.php +++ b/src/Validator/Response/StatusCodeValidator.php @@ -7,7 +7,7 @@ use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Validator\Exception\UndefinedResponseException; -final readonly class StatusCodeValidator +readonly class StatusCodeValidator { /** * @param array $responses diff --git a/src/Validator/Schema/DiscriminatorValidator.php b/src/Validator/Schema/DiscriminatorValidator.php index 7c540ee..ef60bf5 100644 --- a/src/Validator/Schema/DiscriminatorValidator.php +++ b/src/Validator/Schema/DiscriminatorValidator.php @@ -17,7 +17,7 @@ use function is_array; use function is_string; -final readonly class DiscriminatorValidator +readonly class DiscriminatorValidator { public function __construct( private readonly RefResolverInterface $refResolver, diff --git a/src/Validator/Schema/Exception/UnresolvableRefException.php b/src/Validator/Schema/Exception/UnresolvableRefException.php index da16ba2..c8b9b72 100644 --- a/src/Validator/Schema/Exception/UnresolvableRefException.php +++ b/src/Validator/Schema/Exception/UnresolvableRefException.php @@ -10,7 +10,7 @@ use function sprintf; -class UnresolvableRefException extends RuntimeException +final class UnresolvableRefException extends RuntimeException { public function __construct( public readonly string $ref, diff --git a/src/Validator/Schema/ItemsValidatorWithContext.php b/src/Validator/Schema/ItemsValidatorWithContext.php index ef816b5..f8179c7 100644 --- a/src/Validator/Schema/ItemsValidatorWithContext.php +++ b/src/Validator/Schema/ItemsValidatorWithContext.php @@ -18,7 +18,7 @@ use function count; use function sprintf; -final readonly class ItemsValidatorWithContext +readonly class ItemsValidatorWithContext { public function __construct( private readonly ValidatorPool $pool, diff --git a/src/Validator/Schema/OneOfValidatorWithContext.php b/src/Validator/Schema/OneOfValidatorWithContext.php index 9b1c9d9..ed01808 100644 --- a/src/Validator/Schema/OneOfValidatorWithContext.php +++ b/src/Validator/Schema/OneOfValidatorWithContext.php @@ -16,7 +16,7 @@ use function is_array; use function assert; -final readonly class OneOfValidatorWithContext +readonly class OneOfValidatorWithContext { public function __construct( private readonly ValidatorPool $pool, diff --git a/src/Validator/Schema/PropertiesValidatorWithContext.php b/src/Validator/Schema/PropertiesValidatorWithContext.php index 5206675..bbf7d01 100644 --- a/src/Validator/Schema/PropertiesValidatorWithContext.php +++ b/src/Validator/Schema/PropertiesValidatorWithContext.php @@ -19,7 +19,7 @@ use function count; use function sprintf; -final readonly class PropertiesValidatorWithContext +readonly class PropertiesValidatorWithContext { public function __construct( private readonly ValidatorPool $pool, diff --git a/src/Validator/Schema/RefResolver.php b/src/Validator/Schema/RefResolver.php index cae3660..a8221c5 100644 --- a/src/Validator/Schema/RefResolver.php +++ b/src/Validator/Schema/RefResolver.php @@ -16,7 +16,7 @@ use function is_array; use function is_object; -class RefResolver implements RefResolverInterface +final class RefResolver implements RefResolverInterface { private WeakMap $cache; @@ -28,7 +28,9 @@ public function __construct() #[Override] public function resolve(string $ref, OpenApiDocument $document): Schema { - $result = $this->resolveRef($ref, $document); + /** @var array $visited */ + $visited = []; + $result = $this->resolveRef($ref, $document, $visited); if (false === $result instanceof Schema) { throw new UnresolvableRefException( @@ -43,7 +45,9 @@ public function resolve(string $ref, OpenApiDocument $document): Schema #[Override] public function resolveParameter(string $ref, OpenApiDocument $document): Parameter { - $result = $this->resolveRef($ref, $document); + /** @var array $visited */ + $visited = []; + $result = $this->resolveRef($ref, $document, $visited); if (false === $result instanceof Parameter) { throw new UnresolvableRefException( @@ -58,7 +62,9 @@ public function resolveParameter(string $ref, OpenApiDocument $document): Parame #[Override] public function resolveResponse(string $ref, OpenApiDocument $document): Response { - $result = $this->resolveRef($ref, $document); + /** @var array $visited */ + $visited = []; + $result = $this->resolveRef($ref, $document, $visited); if (false === $result instanceof Response) { throw new UnresolvableRefException( @@ -125,8 +131,20 @@ public function schemaHasDiscriminator(Schema $schema, OpenApiDocument $document return false; } - private function resolveRef(string $ref, OpenApiDocument $document): Schema|Parameter|Response + /** + * @param array $visited + */ + private function resolveRef(string $ref, OpenApiDocument $document, array &$visited): Schema|Parameter|Response { + if (isset($visited[$ref])) { + throw new UnresolvableRefException( + $ref, + 'Circular reference detected: ' . $this->formatCircularPath($visited, $ref), + ); + } + + $visited[$ref] = true; + if (isset($this->cache[$document])) { /** @var array */ $cacheEntry = $this->cache[$document]; @@ -148,6 +166,10 @@ private function resolveRef(string $ref, OpenApiDocument $document): Schema|Para throw new UnresolvableRefException($ref, $e->reason, previous: $e); } + if (null !== $result->ref) { + return $this->resolveRef($result->ref, $document, $visited); + } + /** @var array */ $cacheArray = $this->cache[$document] ?? []; $cacheArray[$ref] = $result; @@ -217,4 +239,14 @@ private function getProperty(object|array $container, string $property): object| return $value; } + + /** + * @param array $visited + */ + private function formatCircularPath(array $visited, string $circularRef): string + { + $path = array_keys($visited); + $path[] = $circularRef; + return implode(' -> ', $path); + } } diff --git a/src/Validator/Schema/SchemaValidatorWithContext.php b/src/Validator/Schema/SchemaValidatorWithContext.php index b0008f6..3236b57 100644 --- a/src/Validator/Schema/SchemaValidatorWithContext.php +++ b/src/Validator/Schema/SchemaValidatorWithContext.php @@ -6,6 +6,7 @@ use Duyler\OpenApi\Schema\Model\Schema; use Duyler\OpenApi\Schema\OpenApiDocument; +use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Error\ValidationContext; use Duyler\OpenApi\Validator\Exception\AbstractValidationError; use Duyler\OpenApi\Validator\Exception\ValidationException; @@ -39,13 +40,14 @@ use function count; use function is_array; -final readonly class SchemaValidatorWithContext +readonly class SchemaValidatorWithContext { public function __construct( private readonly ValidatorPool $pool, private readonly RefResolverInterface $refResolver, private readonly OpenApiDocument $document, private readonly bool $nullableAsType = true, + private readonly EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, ) {} /** @@ -53,7 +55,7 @@ public function __construct( */ public function validate(array|int|string|float|bool|null $data, Schema $schema, bool $useDiscriminator = true): void { - $context = ValidationContext::create($this->pool, $this->nullableAsType); + $context = ValidationContext::create($this->pool, $this->nullableAsType, $this->emptyArrayStrategy); if ($useDiscriminator && null !== $schema->discriminator && null !== $schema->oneOf) { $oneOfValidator = new OneOfValidatorWithContext($this->pool, $this->refResolver, $this->document); diff --git a/src/Validator/Schema/SchemaValueNormalizer.php b/src/Validator/Schema/SchemaValueNormalizer.php index b7e6087..1f73e8d 100644 --- a/src/Validator/Schema/SchemaValueNormalizer.php +++ b/src/Validator/Schema/SchemaValueNormalizer.php @@ -13,7 +13,7 @@ use function is_string; use function sprintf; -final readonly class SchemaValueNormalizer +readonly class SchemaValueNormalizer { /** * Normalize data to match SchemaValidatorInterface requirements diff --git a/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php b/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php index 5d47393..c7dbd18 100644 --- a/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php +++ b/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php @@ -11,7 +11,7 @@ use function is_array; -final readonly class AdditionalPropertiesValidator extends AbstractSchemaValidator +readonly class AdditionalPropertiesValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/AllOfValidator.php b/src/Validator/SchemaValidator/AllOfValidator.php index 3698bc4..e5b7dca 100644 --- a/src/Validator/SchemaValidator/AllOfValidator.php +++ b/src/Validator/SchemaValidator/AllOfValidator.php @@ -11,7 +11,7 @@ use function count; -final readonly class AllOfValidator extends AbstractCompositionalValidator +readonly class AllOfValidator extends AbstractCompositionalValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/AnyOfValidator.php b/src/Validator/SchemaValidator/AnyOfValidator.php index 10ae086..70fa482 100644 --- a/src/Validator/SchemaValidator/AnyOfValidator.php +++ b/src/Validator/SchemaValidator/AnyOfValidator.php @@ -9,7 +9,7 @@ use Duyler\OpenApi\Validator\Exception\ValidationException; use Override; -final readonly class AnyOfValidator extends AbstractCompositionalValidator +readonly class AnyOfValidator extends AbstractCompositionalValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/ArrayLengthValidator.php b/src/Validator/SchemaValidator/ArrayLengthValidator.php index 5199cc8..fe04d0e 100644 --- a/src/Validator/SchemaValidator/ArrayLengthValidator.php +++ b/src/Validator/SchemaValidator/ArrayLengthValidator.php @@ -17,7 +17,7 @@ use const SORT_REGULAR; -final readonly class ArrayLengthValidator extends AbstractSchemaValidator +readonly class ArrayLengthValidator extends AbstractSchemaValidator { use LengthValidationTrait; diff --git a/src/Validator/SchemaValidator/ConstValidator.php b/src/Validator/SchemaValidator/ConstValidator.php index 42b7ba9..0cdeb77 100644 --- a/src/Validator/SchemaValidator/ConstValidator.php +++ b/src/Validator/SchemaValidator/ConstValidator.php @@ -9,7 +9,7 @@ use Duyler\OpenApi\Validator\Exception\ConstError; use Override; -final readonly class ConstValidator extends AbstractSchemaValidator +readonly class ConstValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/ContainsRangeValidator.php b/src/Validator/SchemaValidator/ContainsRangeValidator.php index 14f6663..d968331 100644 --- a/src/Validator/SchemaValidator/ContainsRangeValidator.php +++ b/src/Validator/SchemaValidator/ContainsRangeValidator.php @@ -13,7 +13,7 @@ use function is_array; -final readonly class ContainsRangeValidator extends AbstractSchemaValidator +readonly class ContainsRangeValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/ContainsValidator.php b/src/Validator/SchemaValidator/ContainsValidator.php index 2d0fccd..abb2375 100644 --- a/src/Validator/SchemaValidator/ContainsValidator.php +++ b/src/Validator/SchemaValidator/ContainsValidator.php @@ -13,7 +13,7 @@ use function is_array; -final readonly class ContainsValidator extends AbstractSchemaValidator +readonly class ContainsValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/DependentSchemasValidator.php b/src/Validator/SchemaValidator/DependentSchemasValidator.php index 86e88b7..ce556e0 100644 --- a/src/Validator/SchemaValidator/DependentSchemasValidator.php +++ b/src/Validator/SchemaValidator/DependentSchemasValidator.php @@ -15,7 +15,7 @@ use function is_array; use function sprintf; -final readonly class DependentSchemasValidator extends AbstractSchemaValidator +readonly class DependentSchemasValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/EnumValidator.php b/src/Validator/SchemaValidator/EnumValidator.php index 9cf2b62..86afacd 100644 --- a/src/Validator/SchemaValidator/EnumValidator.php +++ b/src/Validator/SchemaValidator/EnumValidator.php @@ -9,7 +9,7 @@ use Duyler\OpenApi\Validator\Exception\EnumError; use Override; -final readonly class EnumValidator extends AbstractSchemaValidator +readonly class EnumValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/FormatValidator.php b/src/Validator/SchemaValidator/FormatValidator.php index da83337..e9407a5 100644 --- a/src/Validator/SchemaValidator/FormatValidator.php +++ b/src/Validator/SchemaValidator/FormatValidator.php @@ -12,7 +12,7 @@ use function is_array; -final readonly class FormatValidator implements SchemaValidatorInterface +readonly class FormatValidator implements SchemaValidatorInterface { public function __construct( private readonly ValidatorPool $pool, diff --git a/src/Validator/SchemaValidator/IfThenElseValidator.php b/src/Validator/SchemaValidator/IfThenElseValidator.php index fc8f5fa..be602de 100644 --- a/src/Validator/SchemaValidator/IfThenElseValidator.php +++ b/src/Validator/SchemaValidator/IfThenElseValidator.php @@ -12,7 +12,7 @@ use Duyler\OpenApi\Validator\Schema\SchemaValueNormalizer; use Override; -final readonly class IfThenElseValidator extends AbstractSchemaValidator +readonly class IfThenElseValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/ItemsValidator.php b/src/Validator/SchemaValidator/ItemsValidator.php index 11942e0..8ad1657 100644 --- a/src/Validator/SchemaValidator/ItemsValidator.php +++ b/src/Validator/SchemaValidator/ItemsValidator.php @@ -14,7 +14,7 @@ use function is_array; use function sprintf; -final readonly class ItemsValidator extends AbstractSchemaValidator +readonly class ItemsValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/NotValidator.php b/src/Validator/SchemaValidator/NotValidator.php index 24b70fc..b6ef16c 100644 --- a/src/Validator/SchemaValidator/NotValidator.php +++ b/src/Validator/SchemaValidator/NotValidator.php @@ -12,7 +12,7 @@ use Duyler\OpenApi\Validator\Schema\SchemaValueNormalizer; use Override; -final readonly class NotValidator extends AbstractSchemaValidator +readonly class NotValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/NumericRangeValidator.php b/src/Validator/SchemaValidator/NumericRangeValidator.php index d204142..706c990 100644 --- a/src/Validator/SchemaValidator/NumericRangeValidator.php +++ b/src/Validator/SchemaValidator/NumericRangeValidator.php @@ -14,7 +14,7 @@ use function is_float; use function is_int; -final readonly class NumericRangeValidator extends AbstractSchemaValidator +readonly class NumericRangeValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/ObjectLengthValidator.php b/src/Validator/SchemaValidator/ObjectLengthValidator.php index 3e7e230..d8a4f38 100644 --- a/src/Validator/SchemaValidator/ObjectLengthValidator.php +++ b/src/Validator/SchemaValidator/ObjectLengthValidator.php @@ -14,7 +14,7 @@ use function count; use function is_array; -final readonly class ObjectLengthValidator extends AbstractSchemaValidator +readonly class ObjectLengthValidator extends AbstractSchemaValidator { use LengthValidationTrait; diff --git a/src/Validator/SchemaValidator/OneOfValidator.php b/src/Validator/SchemaValidator/OneOfValidator.php index dd27ed5..2b2f2c0 100644 --- a/src/Validator/SchemaValidator/OneOfValidator.php +++ b/src/Validator/SchemaValidator/OneOfValidator.php @@ -10,7 +10,7 @@ use Duyler\OpenApi\Validator\Exception\ValidationException; use Override; -final readonly class OneOfValidator extends AbstractCompositionalValidator +readonly class OneOfValidator extends AbstractCompositionalValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/PatternPropertiesValidator.php b/src/Validator/SchemaValidator/PatternPropertiesValidator.php index 773e31f..eb09751 100644 --- a/src/Validator/SchemaValidator/PatternPropertiesValidator.php +++ b/src/Validator/SchemaValidator/PatternPropertiesValidator.php @@ -13,7 +13,7 @@ use function is_array; use function is_string; -final readonly class PatternPropertiesValidator extends AbstractSchemaValidator +readonly class PatternPropertiesValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/PatternValidator.php b/src/Validator/SchemaValidator/PatternValidator.php index f442e2c..4797867 100644 --- a/src/Validator/SchemaValidator/PatternValidator.php +++ b/src/Validator/SchemaValidator/PatternValidator.php @@ -13,7 +13,7 @@ use function assert; use function is_string; -final readonly class PatternValidator extends AbstractSchemaValidator +readonly class PatternValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/PrefixItemsValidator.php b/src/Validator/SchemaValidator/PrefixItemsValidator.php index 666f06e..a9bf369 100644 --- a/src/Validator/SchemaValidator/PrefixItemsValidator.php +++ b/src/Validator/SchemaValidator/PrefixItemsValidator.php @@ -16,7 +16,7 @@ use function is_array; use function sprintf; -final readonly class PrefixItemsValidator extends AbstractSchemaValidator +readonly class PrefixItemsValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/PropertiesValidator.php b/src/Validator/SchemaValidator/PropertiesValidator.php index 4c4d3e7..d82fb83 100644 --- a/src/Validator/SchemaValidator/PropertiesValidator.php +++ b/src/Validator/SchemaValidator/PropertiesValidator.php @@ -15,7 +15,7 @@ use function is_array; use function sprintf; -final readonly class PropertiesValidator extends AbstractSchemaValidator +readonly class PropertiesValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/PropertyNamesValidator.php b/src/Validator/SchemaValidator/PropertyNamesValidator.php index 860d1a7..d479a5a 100644 --- a/src/Validator/SchemaValidator/PropertyNamesValidator.php +++ b/src/Validator/SchemaValidator/PropertyNamesValidator.php @@ -11,7 +11,7 @@ use function is_array; -final readonly class PropertyNamesValidator extends AbstractSchemaValidator +readonly class PropertyNamesValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/RequiredValidator.php b/src/Validator/SchemaValidator/RequiredValidator.php index c8134c1..0dccdc8 100644 --- a/src/Validator/SchemaValidator/RequiredValidator.php +++ b/src/Validator/SchemaValidator/RequiredValidator.php @@ -13,7 +13,7 @@ use function array_key_exists; use function is_array; -final readonly class RequiredValidator extends AbstractSchemaValidator +readonly class RequiredValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/SchemaValidator.php b/src/Validator/SchemaValidator/SchemaValidator.php index a8f563e..82ff355 100644 --- a/src/Validator/SchemaValidator/SchemaValidator.php +++ b/src/Validator/SchemaValidator/SchemaValidator.php @@ -15,7 +15,7 @@ use function assert; -final readonly class SchemaValidator implements SchemaValidatorInterface +readonly class SchemaValidator implements SchemaValidatorInterface { public readonly FormatRegistry $formatRegistry; diff --git a/src/Validator/SchemaValidator/StringLengthValidator.php b/src/Validator/SchemaValidator/StringLengthValidator.php index 4859b6b..38b9163 100644 --- a/src/Validator/SchemaValidator/StringLengthValidator.php +++ b/src/Validator/SchemaValidator/StringLengthValidator.php @@ -13,7 +13,7 @@ use function is_string; -final readonly class StringLengthValidator extends AbstractSchemaValidator +readonly class StringLengthValidator extends AbstractSchemaValidator { use LengthValidationTrait; diff --git a/src/Validator/SchemaValidator/TypeValidator.php b/src/Validator/SchemaValidator/TypeValidator.php index bbd1e57..50ef6d1 100644 --- a/src/Validator/SchemaValidator/TypeValidator.php +++ b/src/Validator/SchemaValidator/TypeValidator.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Validator\SchemaValidator; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Error\ValidationContext; use Duyler\OpenApi\Validator\Exception\TypeMismatchError; use Override; @@ -15,7 +16,7 @@ use function is_int; use function is_string; -final readonly class TypeValidator extends AbstractSchemaValidator +readonly class TypeValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void @@ -32,8 +33,10 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex return; } + $emptyArrayStrategy = $context?->emptyArrayStrategy ?? EmptyArrayStrategy::AllowBoth; + if (is_array($schema->type)) { - if (false === $this->isValidUnionType($data, $schema->type)) { + if (false === $this->isValidUnionType($data, $schema->type, $emptyArrayStrategy)) { throw new TypeMismatchError( expected: implode('|', $schema->type), actual: get_debug_type($data), @@ -45,7 +48,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex return; } - if (false === $this->isValidType($data, $schema->type)) { + if (false === $this->isValidType($data, $schema->type, $emptyArrayStrategy)) { throw new TypeMismatchError( expected: $schema->type, actual: get_debug_type($data), @@ -55,7 +58,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex } } - private function isValidType(mixed $data, string $type): bool + private function isValidType(mixed $data, string $type, EmptyArrayStrategy $strategy): bool { return match ($type) { 'string' => is_string($data), @@ -63,8 +66,8 @@ private function isValidType(mixed $data, string $type): bool 'integer' => is_int($data), 'boolean' => is_bool($data), 'null' => null === $data, - 'array' => is_array($data) && ([] === $data || array_is_list($data)), - 'object' => is_array($data) && ([] === $data || false === array_is_list($data)), + 'array' => $this->isArray($data, $strategy), + 'object' => $this->isObject($data, $strategy), default => true, }; } @@ -72,8 +75,44 @@ private function isValidType(mixed $data, string $type): bool /** * @param array $types */ - private function isValidUnionType(mixed $data, array $types): bool + private function isValidUnionType(mixed $data, array $types, EmptyArrayStrategy $strategy): bool + { + return array_any($types, fn($type) => $this->isValidType($data, $type, $strategy)); + } + + private function isArray(mixed $data, EmptyArrayStrategy $strategy): bool { - return array_any($types, fn($type) => $this->isValidType($data, $type)); + if (false === is_array($data)) { + return false; + } + + if ([] === $data) { + return match ($strategy) { + EmptyArrayStrategy::PreferArray => true, + EmptyArrayStrategy::PreferObject => false, + EmptyArrayStrategy::Reject => false, + EmptyArrayStrategy::AllowBoth => true, + }; + } + + return array_is_list($data); + } + + private function isObject(mixed $data, EmptyArrayStrategy $strategy): bool + { + if (false === is_array($data)) { + return false; + } + + if ([] === $data) { + return match ($strategy) { + EmptyArrayStrategy::PreferArray => false, + EmptyArrayStrategy::PreferObject => true, + EmptyArrayStrategy::Reject => false, + EmptyArrayStrategy::AllowBoth => true, + }; + } + + return false === array_is_list($data); } } diff --git a/src/Validator/SchemaValidator/UnevaluatedItemsValidator.php b/src/Validator/SchemaValidator/UnevaluatedItemsValidator.php index 333aa63..3a6d34d 100644 --- a/src/Validator/SchemaValidator/UnevaluatedItemsValidator.php +++ b/src/Validator/SchemaValidator/UnevaluatedItemsValidator.php @@ -14,7 +14,7 @@ use const PHP_INT_MAX; -final readonly class UnevaluatedItemsValidator extends AbstractSchemaValidator +readonly class UnevaluatedItemsValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php b/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php index d320fd0..db428fa 100644 --- a/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php +++ b/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php @@ -13,7 +13,7 @@ use function is_array; use function is_string; -final readonly class UnevaluatedPropertiesValidator extends AbstractSchemaValidator +readonly class UnevaluatedPropertiesValidator extends AbstractSchemaValidator { #[Override] public function validate(mixed $data, Schema $schema, ?ValidationContext $context = null): void diff --git a/src/Validator/TypeGuarantor.php b/src/Validator/TypeGuarantor.php index 08e3da7..a525777 100644 --- a/src/Validator/TypeGuarantor.php +++ b/src/Validator/TypeGuarantor.php @@ -10,7 +10,7 @@ use function is_int; use function is_string; -final readonly class TypeGuarantor +readonly class TypeGuarantor { public static function ensureValidType(mixed $value, bool $nullableAsType = true): array|int|string|float|bool|null { diff --git a/src/Validator/ValidatorPool.php b/src/Validator/ValidatorPool.php index eae8edf..31d2688 100644 --- a/src/Validator/ValidatorPool.php +++ b/src/Validator/ValidatorPool.php @@ -6,7 +6,7 @@ use WeakMap; -final readonly class ValidatorPool +readonly class ValidatorPool { /** @var WeakMap */ public WeakMap $pool; diff --git a/tests/Schema/Parser/JsonParserRefTest.php b/tests/Schema/Parser/JsonParserRefTest.php new file mode 100644 index 0000000..31fc13a --- /dev/null +++ b/tests/Schema/Parser/JsonParserRefTest.php @@ -0,0 +1,228 @@ +parser = new JsonParser(); + } + + #[Test] + public function parameter_with_ref_parses_correctly(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"parameters":[{"$ref":"#/components/parameters/TestId"}],"responses":{"200":{"description":"OK"}}}}},"components":{"parameters":{"TestId":{"name":"id","in":"path","required":true,"schema":{"type":"string"}}}}}'; + + $document = $this->parser->parse($json); + + self::assertNotNull($document->paths); + self::assertArrayHasKey('/test', $document->paths->paths); + $pathItem = $document->paths->paths['/test']; + self::assertNotNull($pathItem->get); + self::assertNotNull($pathItem->get->parameters); + self::assertCount(1, $pathItem->get->parameters->parameters); + $param = $pathItem->get->parameters->parameters[0]; + self::assertNotNull($param->ref); + self::assertSame('#/components/parameters/TestId', $param->ref); + self::assertNull($param->name); + self::assertNull($param->in); + } + + #[Test] + public function response_with_ref_parses_correctly(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"responses":{"200":{"$ref":"#/components/responses/Success"}}}}},"components":{"responses":{"Success":{"description":"Success"}}}}'; + + $document = $this->parser->parse($json); + + self::assertNotNull($document->paths); + self::assertArrayHasKey('/test', $document->paths->paths); + $pathItem = $document->paths->paths['/test']; + self::assertNotNull($pathItem->get); + self::assertNotNull($pathItem->get->responses); + self::assertArrayHasKey('200', $pathItem->get->responses->responses); + $response = $pathItem->get->responses->responses['200']; + self::assertNotNull($response->ref); + self::assertSame('#/components/responses/Success', $response->ref); + self::assertNull($response->description); + } + + #[Test] + public function parameter_without_ref_requires_name_and_in(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"parameters":[{"description":"Missing name and in"}],"responses":{"200":{"description":"OK"}}}}}}'; + + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Parameter must have name and in fields'); + + $this->parser->parse($json); + } + + #[Test] + public function json_yaml_equivalence_for_parameter_ref(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"parameters":[{"$ref":"#/components/parameters/TestId"}],"responses":{"200":{"description":"OK"}}}}},"components":{"parameters":{"TestId":{"name":"id","in":"path","required":true,"schema":{"type":"string"}}}}}'; + + $yaml = <<<'YAML' +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: + /test: + get: + parameters: + - $ref: "#/components/parameters/TestId" + responses: + "200": + description: OK +components: + parameters: + TestId: + name: id + in: path + required: true + schema: + type: string +YAML; + + $jsonDocument = $this->parser->parse($json); + $yamlParser = new YamlParser(); + $yamlDocument = $yamlParser->parse($yaml); + + $jsonParam = $jsonDocument->paths?->paths['/test']->get?->parameters?->parameters[0]; + $yamlParam = $yamlDocument->paths?->paths['/test']->get?->parameters?->parameters[0]; + + self::assertNotNull($jsonParam->ref); + self::assertNotNull($yamlParam->ref); + self::assertSame($jsonParam->ref, $yamlParam->ref); + } + + #[Test] + public function json_yaml_equivalence_for_response_ref(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"responses":{"200":{"$ref":"#/components/responses/Success"}}}}},"components":{"responses":{"Success":{"description":"Success"}}}}'; + + $yaml = <<<'YAML' +openapi: "3.0.0" +info: + title: Test + version: "1.0" +paths: + /test: + get: + responses: + "200": + $ref: "#/components/responses/Success" +components: + responses: + Success: + description: Success +YAML; + + $jsonDocument = $this->parser->parse($json); + $yamlParser = new YamlParser(); + $yamlDocument = $yamlParser->parse($yaml); + + $jsonResponse = $jsonDocument->paths?->paths['/test']->get?->responses?->responses['200']; + $yamlResponse = $yamlDocument->paths?->paths['/test']->get?->responses?->responses['200']; + + self::assertNotNull($jsonResponse->ref); + self::assertNotNull($yamlResponse->ref); + self::assertSame($jsonResponse->ref, $yamlResponse->ref); + } + + #[Test] + public function parameter_with_ref_in_components(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{},"components":{"parameters":{"RefParam":{"$ref":"#/components/parameters/BaseParam"},"BaseParam":{"name":"base","in":"query"}}}}'; + + $document = $this->parser->parse($json); + + self::assertNotNull($document->components); + self::assertNotNull($document->components->parameters); + self::assertArrayHasKey('RefParam', $document->components->parameters); + $refParam = $document->components->parameters['RefParam']; + self::assertNotNull($refParam->ref); + self::assertSame('#/components/parameters/BaseParam', $refParam->ref); + } + + #[Test] + public function response_with_ref_in_components(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{},"components":{"responses":{"RefResponse":{"$ref":"#/components/responses/BaseResponse"},"BaseResponse":{"description":"Base response"}}}}'; + + $document = $this->parser->parse($json); + + self::assertNotNull($document->components); + self::assertNotNull($document->components->responses); + self::assertArrayHasKey('RefResponse', $document->components->responses); + $refResponse = $document->components->responses['RefResponse']; + self::assertNotNull($refResponse->ref); + self::assertSame('#/components/responses/BaseResponse', $refResponse->ref); + } + + #[Test] + public function parameter_with_ref_ignores_other_fields(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"parameters":[{"$ref":"#/components/parameters/TestId","name":"ignored","in":"query"}],"responses":{"200":{"description":"OK"}}}}},"components":{"parameters":{"TestId":{"name":"id","in":"path"}}}}'; + + $document = $this->parser->parse($json); + + $param = $document->paths?->paths['/test']->get?->parameters?->parameters[0]; + self::assertNotNull($param->ref); + self::assertSame('#/components/parameters/TestId', $param->ref); + self::assertNull($param->name); + self::assertNull($param->in); + } + + #[Test] + public function response_with_ref_ignores_other_fields(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"responses":{"200":{"$ref":"#/components/responses/Success","description":"ignored"}}}}},"components":{"responses":{"Success":{"description":"Success"}}}}'; + + $document = $this->parser->parse($json); + + $response = $document->paths?->paths['/test']->get?->responses?->responses['200']; + self::assertNotNull($response->ref); + self::assertSame('#/components/responses/Success', $response->ref); + self::assertNull($response->description); + } + + #[Test] + public function multiple_parameters_with_ref(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"parameters":[{"$ref":"#/components/parameters/Param1"},{"$ref":"#/components/parameters/Param2"}],"responses":{"200":{"description":"OK"}}}}},"components":{"parameters":{"Param1":{"name":"param1","in":"query"},"Param2":{"name":"param2","in":"header"}}}}'; + + $document = $this->parser->parse($json); + + $params = $document->paths?->paths['/test']->get?->parameters?->parameters; + self::assertCount(2, $params); + self::assertSame('#/components/parameters/Param1', $params[0]->ref); + self::assertSame('#/components/parameters/Param2', $params[1]->ref); + } + + #[Test] + public function multiple_responses_with_ref(): void + { + $json = '{"openapi":"3.0.0","info":{"title":"Test","version":"1.0"},"paths":{"/test":{"get":{"responses":{"200":{"$ref":"#/components/responses/Success"},"400":{"$ref":"#/components/responses/Error"}}}}},"components":{"responses":{"Success":{"description":"OK"},"Error":{"description":"Bad Request"}}}}'; + + $document = $this->parser->parse($json); + + $responses = $document->paths?->paths['/test']->get?->responses?->responses; + self::assertCount(2, $responses); + self::assertSame('#/components/responses/Success', $responses['200']->ref); + self::assertSame('#/components/responses/Error', $responses['400']->ref); + } +} diff --git a/tests/Schema/Parser/OpenApiBuilderTest.php b/tests/Schema/Parser/OpenApiBuilderTest.php new file mode 100644 index 0000000..717b05f --- /dev/null +++ b/tests/Schema/Parser/OpenApiBuilderTest.php @@ -0,0 +1,956 @@ +parser = new JsonParser(); + } + + #[Test] + public function build_document_with_all_fields(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + 'description' => 'Test description', + 'termsOfService' => 'https://example.com/terms', + 'contact' => [ + 'name' => 'Support', + 'url' => 'https://example.com/support', + 'email' => 'support@example.com', + ], + 'license' => [ + 'name' => 'MIT', + 'identifier' => 'MIT', + 'url' => 'https://opensource.org/licenses/MIT', + ], + ], + 'jsonSchemaDialect' => 'https://json-schema.org/draft/2020-12/schema', + 'servers' => [ + [ + 'url' => 'https://api.example.com', + 'description' => 'Production server', + 'variables' => [ + 'version' => [ + 'default' => 'v1', + ], + ], + ], + ], + 'paths' => [ + '/test' => [ + 'get' => [ + 'operationId' => 'getTest', + 'responses' => [ + '200' => ['description' => 'OK'], + ], + ], + ], + ], + 'webhooks' => [ + 'newPet' => [ + 'post' => [ + 'requestBody' => [ + 'description' => 'Information about a new pet', + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object'], + ], + ], + ], + 'responses' => [ + '200' => ['description' => 'OK'], + ], + ], + ], + ], + 'components' => [ + 'schemas' => [ + 'Pet' => ['type' => 'object'], + ], + 'responses' => [ + 'NotFound' => ['description' => 'Not found'], + ], + 'parameters' => [ + 'limitParam' => [ + 'name' => 'limit', + 'in' => 'query', + ], + ], + 'examples' => [ + 'example1' => [ + 'summary' => 'Example summary', + 'value' => ['foo' => 'bar'], + ], + ], + 'requestBodies' => [ + 'body1' => [ + 'description' => 'Request body', + ], + ], + 'headers' => [ + 'X-Custom' => [ + 'description' => 'Custom header', + ], + ], + 'securitySchemes' => [ + 'bearerAuth' => [ + 'type' => 'http', + 'scheme' => 'bearer', + ], + ], + 'links' => [ + 'link1' => [ + 'operationRef' => '#/paths/~1users/get', + ], + ], + 'callbacks' => [ + 'callback1' => [ + 'expression' => [ + 'post' => [ + 'responses' => [ + '200' => ['description' => 'OK'], + ], + ], + ], + ], + ], + 'pathItems' => [ + 'path1' => [ + 'get' => [ + 'responses' => [ + '200' => ['description' => 'OK'], + ], + ], + ], + ], + ], + 'security' => [ + ['bearerAuth' => []], + ], + 'tags' => [ + [ + 'name' => 'pets', + 'description' => 'Pets operations', + 'externalDocs' => [ + 'url' => 'https://example.com/docs', + 'description' => 'External docs', + ], + ], + ], + 'externalDocs' => [ + 'url' => 'https://example.com/docs', + 'description' => 'External documentation', + ], + ]); + + $document = $this->parser->parse($json); + + $this->assertSame('3.1.0', $document->openapi); + $this->assertSame('Test API', $document->info->title); + $this->assertSame('https://json-schema.org/draft/2020-12/schema', $document->jsonSchemaDialect); + $this->assertInstanceOf(Servers::class, $document->servers); + $this->assertInstanceOf(Paths::class, $document->paths); + $this->assertInstanceOf(Webhooks::class, $document->webhooks); + $this->assertInstanceOf(Components::class, $document->components); + $this->assertInstanceOf(SecurityRequirement::class, $document->security); + $this->assertInstanceOf(Tags::class, $document->tags); + $this->assertInstanceOf(ExternalDocs::class, $document->externalDocs); + } + + #[Test] + public function build_info_with_contact(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + 'contact' => [ + 'name' => 'Support', + 'url' => 'https://example.com', + 'email' => 'test@example.com', + ], + ], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertInstanceOf(Contact::class, $document->info->contact); + $this->assertSame('Support', $document->info->contact->name); + $this->assertSame('https://example.com', $document->info->contact->url); + $this->assertSame('test@example.com', $document->info->contact->email); + } + + #[Test] + public function build_info_with_license(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + 'license' => [ + 'name' => 'MIT', + 'identifier' => 'MIT', + 'url' => 'https://opensource.org/licenses/MIT', + ], + ], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertInstanceOf(License::class, $document->info->license); + $this->assertSame('MIT', $document->info->license->name); + $this->assertSame('MIT', $document->info->license->identifier); + $this->assertSame('https://opensource.org/licenses/MIT', $document->info->license->url); + } + + #[Test] + public function build_server_with_variables(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'servers' => [ + [ + 'url' => 'https://{environment}.example.com', + 'description' => 'API server', + 'variables' => [ + 'environment' => [ + 'default' => 'api', + 'enum' => ['api', 'staging'], + ], + ], + ], + ], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertCount(1, $document->servers->servers); + $server = $document->servers->servers[0]; + $this->assertSame('https://{environment}.example.com', $server->url); + $this->assertSame('API server', $server->description); + $this->assertNotNull($server->variables); + } + + #[Test] + public function build_operation_with_all_fields(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/users' => [ + 'get' => [ + 'tags' => ['users'], + 'summary' => 'Get users', + 'description' => 'Returns a list of users', + 'externalDocs' => [ + 'url' => 'https://example.com/docs/users', + ], + 'operationId' => 'getUsers', + 'parameters' => [ + [ + 'name' => 'limit', + 'in' => 'query', + 'description' => 'Limit results', + ], + ], + 'requestBody' => [ + 'description' => 'Request body', + 'content' => [], + ], + 'responses' => [ + '200' => ['description' => 'OK'], + ], + 'callbacks' => [ + 'onEvent' => [ + '{$request.body#/callbackUrl}' => [ + 'post' => [ + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ], + 'deprecated' => true, + 'security' => [['bearerAuth' => []]], + 'servers' => [ + ['url' => 'https://api.example.com'], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $operation = $document->paths->paths['/users']->get; + + $this->assertSame(['users'], $operation->tags); + $this->assertSame('Get users', $operation->summary); + $this->assertSame('Returns a list of users', $operation->description); + $this->assertInstanceOf(ExternalDocs::class, $operation->externalDocs); + $this->assertSame('getUsers', $operation->operationId); + $this->assertInstanceOf(Parameters::class, $operation->parameters); + $this->assertInstanceOf(RequestBody::class, $operation->requestBody); + $this->assertInstanceOf(Responses::class, $operation->responses); + $this->assertInstanceOf(Callbacks::class, $operation->callbacks); + $this->assertTrue($operation->deprecated); + $this->assertInstanceOf(SecurityRequirement::class, $operation->security); + $this->assertInstanceOf(Servers::class, $operation->servers); + } + + #[Test] + public function build_parameter_with_content(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/users' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'filter', + 'in' => 'query', + 'description' => 'Filter parameter', + 'required' => true, + 'deprecated' => true, + 'allowEmptyValue' => true, + 'style' => 'form', + 'explode' => true, + 'allowReserved' => true, + 'schema' => ['type' => 'string'], + 'examples' => ['example1' => ['value' => 'test']], + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object'], + ], + ], + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $param = $document->paths->paths['/users']->get->parameters->parameters[0]; + + $this->assertSame('filter', $param->name); + $this->assertSame('query', $param->in); + $this->assertSame('Filter parameter', $param->description); + $this->assertTrue($param->required); + $this->assertTrue($param->deprecated); + $this->assertTrue($param->allowEmptyValue); + $this->assertSame('form', $param->style); + $this->assertTrue($param->explode); + $this->assertTrue($param->allowReserved); + $this->assertInstanceOf(Schema::class, $param->schema); + $this->assertInstanceOf(Content::class, $param->content); + } + + #[Test] + public function build_schema_with_all_fields(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'TestSchema' => [ + '$ref' => '#/components/schemas/Other', + 'format' => 'date-time', + 'title' => 'Test Schema', + 'description' => 'Test description', + 'default' => 'default_value', + 'deprecated' => true, + 'type' => 'string', + 'nullable' => true, + 'const' => 'constant_value', + 'multipleOf' => 2, + 'maximum' => 100, + 'exclusiveMaximum' => 50, + 'minimum' => 0, + 'exclusiveMinimum' => 1, + 'maxLength' => 100, + 'minLength' => 1, + 'pattern' => '^[a-z]+$', + 'maxItems' => 10, + 'minItems' => 1, + 'uniqueItems' => true, + 'maxProperties' => 20, + 'minProperties' => 1, + 'required' => ['id'], + 'allOf' => [['type' => 'object']], + 'anyOf' => [['type' => 'string']], + 'oneOf' => [['type' => 'number']], + 'not' => ['type' => 'null'], + 'discriminator' => [ + 'propertyName' => 'type', + 'mapping' => ['dog' => '#/components/schemas/Dog'], + ], + 'properties' => [ + 'id' => ['type' => 'string'], + ], + 'additionalProperties' => true, + 'unevaluatedProperties' => false, + 'items' => ['type' => 'string'], + 'prefixItems' => [['type' => 'string']], + 'contains' => ['type' => 'number'], + 'minContains' => 1, + 'maxContains' => 5, + 'patternProperties' => [ + '^x-' => ['type' => 'string'], + ], + 'propertyNames' => ['pattern' => '^[a-z]+$'], + 'dependentSchemas' => [ + 'creditCard' => ['type' => 'object'], + ], + 'if' => ['type' => 'object'], + 'then' => ['required' => ['name']], + 'else' => ['required' => ['id']], + 'unevaluatedItems' => ['type' => 'string'], + 'example' => 'example_value', + 'examples' => ['ex1' => ['value' => 'test']], + 'enum' => ['a', 'b', 'c'], + 'contentEncoding' => 'base64', + 'contentMediaType' => 'application/json', + 'contentSchema' => '{"type": "object"}', + '$schema' => 'https://json-schema.org/draft/2020-12/schema', + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $schema = $document->components->schemas['TestSchema']; + + $this->assertSame('#/components/schemas/Other', $schema->ref); + $this->assertSame('date-time', $schema->format); + $this->assertSame('Test Schema', $schema->title); + $this->assertSame('Test description', $schema->description); + $this->assertSame('default_value', $schema->default); + $this->assertTrue($schema->deprecated); + $this->assertSame('string', $schema->type); + $this->assertTrue($schema->nullable); + $this->assertSame('constant_value', $schema->const); + $this->assertSame(2.0, $schema->multipleOf); + $this->assertSame(100.0, $schema->maximum); + $this->assertSame(50.0, $schema->exclusiveMaximum); + $this->assertSame(0.0, $schema->minimum); + $this->assertSame(1.0, $schema->exclusiveMinimum); + $this->assertSame(100, $schema->maxLength); + $this->assertSame(1, $schema->minLength); + $this->assertSame('^[a-z]+$', $schema->pattern); + $this->assertSame(10, $schema->maxItems); + $this->assertSame(1, $schema->minItems); + $this->assertTrue($schema->uniqueItems); + $this->assertSame(20, $schema->maxProperties); + $this->assertSame(1, $schema->minProperties); + $this->assertSame(['id'], $schema->required); + $this->assertCount(1, $schema->allOf); + $this->assertCount(1, $schema->anyOf); + $this->assertCount(1, $schema->oneOf); + $this->assertInstanceOf(Schema::class, $schema->not); + $this->assertInstanceOf(Discriminator::class, $schema->discriminator); + $this->assertArrayHasKey('id', $schema->properties); + $this->assertTrue($schema->additionalProperties); + $this->assertFalse($schema->unevaluatedProperties); + $this->assertInstanceOf(Schema::class, $schema->items); + $this->assertCount(1, $schema->prefixItems); + $this->assertInstanceOf(Schema::class, $schema->contains); + $this->assertSame(1, $schema->minContains); + $this->assertSame(5, $schema->maxContains); + $this->assertArrayHasKey('^x-', $schema->patternProperties); + $this->assertInstanceOf(Schema::class, $schema->propertyNames); + $this->assertArrayHasKey('creditCard', $schema->dependentSchemas); + $this->assertInstanceOf(Schema::class, $schema->if); + $this->assertInstanceOf(Schema::class, $schema->then); + $this->assertInstanceOf(Schema::class, $schema->else); + $this->assertInstanceOf(Schema::class, $schema->unevaluatedItems); + $this->assertSame('example_value', $schema->example); + $this->assertNotNull($schema->examples); + $this->assertSame(['a', 'b', 'c'], $schema->enum); + $this->assertSame('base64', $schema->contentEncoding); + $this->assertSame('application/json', $schema->contentMediaType); + $this->assertSame('{"type": "object"}', $schema->contentSchema); + $this->assertSame('https://json-schema.org/draft/2020-12/schema', $schema->jsonSchemaDialect); + } + + #[Test] + public function build_schema_with_additional_properties_schema(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'TestSchema' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string'], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $schema = $document->components->schemas['TestSchema']; + + $this->assertInstanceOf(Schema::class, $schema->additionalProperties); + } + + #[Test] + public function build_response_with_headers_and_links(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/users' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Success', + 'headers' => [ + 'X-Rate-Limit' => [ + 'description' => 'Rate limit', + 'required' => true, + 'deprecated' => true, + 'allowEmptyValue' => true, + 'schema' => ['type' => 'integer'], + 'example' => 100, + 'examples' => ['default' => ['value' => 100]], + 'content' => [ + 'text/plain' => [ + 'schema' => ['type' => 'string'], + ], + ], + ], + ], + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object'], + 'encoding' => 'utf-8', + 'example' => '{"id": 1}', + 'examples' => ['example1' => ['value' => ['id' => 1]]], + ], + ], + 'links' => [ + 'userPosts' => [ + 'operationRef' => '#/paths/~1users~1{id}~1posts/get', + '$ref' => '#/components/links/UserPosts', + 'description' => 'Get user posts', + 'operationId' => 'getUserPosts', + 'parameters' => ['userId' => '$response.body#/id'], + 'requestBody' => [ + 'description' => 'Request body', + ], + 'server' => [ + 'url' => 'https://api.example.com', + ], + ], + ], + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $response = $document->paths->paths['/users']->get->responses->responses['200']; + + $this->assertSame('Success', $response->description); + $this->assertInstanceOf(Headers::class, $response->headers); + $this->assertInstanceOf(Content::class, $response->content); + $this->assertInstanceOf(Links::class, $response->links); + + $header = $response->headers->headers['X-Rate-Limit']; + $this->assertSame('Rate limit', $header->description); + $this->assertTrue($header->required); + $this->assertTrue($header->deprecated); + $this->assertTrue($header->allowEmptyValue); + $this->assertInstanceOf(Schema::class, $header->schema); + $this->assertSame(100, $header->example); + $this->assertNotNull($header->examples); + $this->assertInstanceOf(Content::class, $header->content); + + $link = $response->links->links['userPosts']; + $this->assertSame('#/paths/~1users~1{id}~1posts/get', $link->operationRef); + $this->assertSame('#/components/links/UserPosts', $link->ref); + $this->assertSame('Get user posts', $link->description); + $this->assertSame('getUserPosts', $link->operationId); + $this->assertSame(['userId' => '$response.body#/id'], $link->parameters); + $this->assertInstanceOf(RequestBody::class, $link->requestBody); + $this->assertInstanceOf(Server::class, $link->server); + } + + #[Test] + public function build_security_scheme_with_all_fields(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'securitySchemes' => [ + 'bearerAuth' => [ + 'type' => 'http', + 'description' => 'Bearer authentication', + 'name' => 'Authorization', + 'in' => 'header', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + 'authorizationUrl' => 'https://example.com/auth', + 'tokenUrl' => 'https://example.com/token', + 'refreshUrl' => 'https://example.com/refresh', + 'scopes' => ['read' => 'Read access', 'write' => 'Write access'], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $scheme = $document->components->securitySchemes['bearerAuth']; + + $this->assertSame('http', $scheme->type); + $this->assertSame('Bearer authentication', $scheme->description); + $this->assertSame('Authorization', $scheme->name); + $this->assertSame('header', $scheme->in); + $this->assertSame('bearer', $scheme->scheme); + $this->assertSame('JWT', $scheme->bearerFormat); + $this->assertSame('https://example.com/auth', $scheme->authorizationUrl); + $this->assertSame('https://example.com/token', $scheme->tokenUrl); + $this->assertSame('https://example.com/refresh', $scheme->refreshUrl); + $this->assertSame(['read' => 'Read access', 'write' => 'Write access'], $scheme->scopes); + } + + #[Test] + public function build_example(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'examples' => [ + 'example1' => [ + 'summary' => 'Example summary', + 'description' => 'Example description', + 'value' => ['foo' => 'bar'], + 'externalValue' => 'https://example.com/example.json', + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $example = $document->components->examples['example1']; + + $this->assertSame('Example summary', $example->summary); + $this->assertSame('Example description', $example->description); + $this->assertSame(['foo' => 'bar'], $example->value); + $this->assertSame('https://example.com/example.json', $example->externalValue); + } + + #[Test] + public function build_external_docs(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'externalDocs' => [ + 'url' => 'https://example.com/docs', + 'description' => 'External documentation', + ], + ]); + + $document = $this->parser->parse($json); + + $this->assertSame('https://example.com/docs', $document->externalDocs->url); + $this->assertSame('External documentation', $document->externalDocs->description); + } + + #[Test] + public function build_discriminator_without_mapping(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'Pet' => [ + 'type' => 'object', + 'discriminator' => [ + 'propertyName' => 'petType', + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $schema = $document->components->schemas['Pet']; + + $this->assertSame('petType', $schema->discriminator->propertyName); + $this->assertNull($schema->discriminator->mapping); + } + + #[Test] + public function build_callbacks_in_components(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'callbacks' => [ + 'myCallback' => [ + 'expression' => [ + 'post' => [ + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + + $this->assertArrayHasKey('myCallback', $document->components->callbacks); + $this->assertInstanceOf(Callbacks::class, $document->components->callbacks['myCallback']); + } + + #[Test] + public function build_path_item_with_all_methods(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + '$ref' => '#/components/pathItems/Test', + 'summary' => 'Test path', + 'description' => 'Test description', + 'get' => ['responses' => ['200' => ['description' => 'OK']]], + 'put' => ['responses' => ['200' => ['description' => 'OK']]], + 'post' => ['responses' => ['200' => ['description' => 'OK']]], + 'delete' => ['responses' => ['200' => ['description' => 'OK']]], + 'options' => ['responses' => ['200' => ['description' => 'OK']]], + 'head' => ['responses' => ['200' => ['description' => 'OK']]], + 'patch' => ['responses' => ['200' => ['description' => 'OK']]], + 'trace' => ['responses' => ['200' => ['description' => 'OK']]], + 'servers' => [['url' => 'https://api.example.com']], + 'parameters' => [['name' => 'id', 'in' => 'path']], + ], + ], + ]); + + $document = $this->parser->parse($json); + $pathItem = $document->paths->paths['/test']; + + $this->assertSame('#/components/pathItems/Test', $pathItem->ref); + $this->assertSame('Test path', $pathItem->summary); + $this->assertSame('Test description', $pathItem->description); + $this->assertInstanceOf(Operation::class, $pathItem->get); + $this->assertInstanceOf(Operation::class, $pathItem->put); + $this->assertInstanceOf(Operation::class, $pathItem->post); + $this->assertInstanceOf(Operation::class, $pathItem->delete); + $this->assertInstanceOf(Operation::class, $pathItem->options); + $this->assertInstanceOf(Operation::class, $pathItem->head); + $this->assertInstanceOf(Operation::class, $pathItem->patch); + $this->assertInstanceOf(Operation::class, $pathItem->trace); + $this->assertInstanceOf(Servers::class, $pathItem->servers); + $this->assertInstanceOf(Parameters::class, $pathItem->parameters); + } + + #[Test] + public function build_response_with_ref(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => ['$ref' => '#/components/responses/Success'], + ], + ], + ], + ], + 'components' => [ + 'responses' => [ + 'Success' => ['description' => 'Success response'], + ], + ], + ]); + + $document = $this->parser->parse($json); + $response = $document->paths->paths['/test']->get->responses->responses['200']; + + $this->assertSame('#/components/responses/Success', $response->ref); + } + + #[Test] + public function invalid_discriminator_throws_exception(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'Pet' => [ + 'type' => 'object', + 'discriminator' => [], + ], + ], + ], + ]); + + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Discriminator must have propertyName'); + $this->parser->parse($json); + } + + #[Test] + public function invalid_external_docs_throws_exception(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'externalDocs' => [], + ]); + + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('External documentation must have url'); + $this->parser->parse($json); + } + + #[Test] + public function invalid_security_scheme_throws_exception(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'securitySchemes' => [ + 'invalid' => [], + ], + ], + ]); + + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Security scheme must have type'); + $this->parser->parse($json); + } + + #[Test] + public function parameter_with_example_string(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'q', + 'in' => 'query', + 'example' => 'search term', + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $param = $document->paths->paths['/test']->get->parameters->parameters[0]; + + $this->assertInstanceOf(Example::class, $param->example); + $this->assertSame('search term', $param->example->value); + } + + #[Test] + public function media_type_with_example_string(): void + { + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'OK', + 'content' => [ + 'application/json' => [ + 'example' => '{"id": 1}', + ], + ], + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $mediaType = $document->paths->paths['/test']->get->responses->responses['200']->content->mediaTypes['application/json']; + + $this->assertInstanceOf(Example::class, $mediaType->example); + $this->assertSame('{"id": 1}', $mediaType->example->value); + } +} diff --git a/tests/Schema/Parser/TypeHelperTest.php b/tests/Schema/Parser/TypeHelperTest.php index ea50639..cdb8911 100644 --- a/tests/Schema/Parser/TypeHelperTest.php +++ b/tests/Schema/Parser/TypeHelperTest.php @@ -317,4 +317,192 @@ public function as_security_list_map_or_null_throws_on_non_string_inner_values() $this->expectException(TypeError::class); TypeHelper::asSecurityListMapOrNull([['scheme1' => [123]]]); } + + #[Test] + public function as_int_returns_int(): void + { + $result = TypeHelper::asInt(42); + $this->assertSame(42, $result); + } + + #[Test] + public function as_int_throws_on_non_int(): void + { + $this->expectException(TypeError::class); + TypeHelper::asInt('42'); + } + + #[Test] + public function as_float_returns_float(): void + { + $result = TypeHelper::asFloat(3.14); + $this->assertSame(3.14, $result); + } + + #[Test] + public function as_float_converts_int_to_float(): void + { + $result = TypeHelper::asFloat(42); + $this->assertSame(42.0, $result); + } + + #[Test] + public function as_float_throws_on_non_float(): void + { + $this->expectException(TypeError::class); + TypeHelper::asFloat('3.14'); + } + + #[Test] + public function as_bool_returns_bool(): void + { + $result = TypeHelper::asBool(true); + $this->assertTrue($result); + } + + #[Test] + public function as_bool_throws_on_non_bool(): void + { + $this->expectException(TypeError::class); + TypeHelper::asBool('true'); + } + + #[Test] + public function as_type_or_null_returns_null(): void + { + $result = TypeHelper::asTypeOrNull(null); + $this->assertNull($result); + } + + #[Test] + public function as_type_or_null_returns_string(): void + { + $result = TypeHelper::asTypeOrNull('string'); + $this->assertSame('string', $result); + } + + #[Test] + public function as_type_or_null_returns_array_of_strings(): void + { + $result = TypeHelper::asTypeOrNull(['string', 'number']); + $this->assertSame(['string', 'number'], $result); + } + + #[Test] + public function as_type_or_null_returns_array_with_null(): void + { + $result = TypeHelper::asTypeOrNull(['string', null]); + $this->assertSame(['string', null], $result); + } + + #[Test] + public function as_type_or_null_throws_on_invalid_type_in_array(): void + { + $this->expectException(TypeError::class); + TypeHelper::asTypeOrNull(['string', 123]); + } + + #[Test] + public function as_type_or_null_throws_on_non_string_non_array(): void + { + $this->expectException(TypeError::class); + TypeHelper::asTypeOrNull(123); + } + + #[Test] + public function as_enum_list_returns_list(): void + { + $result = TypeHelper::asEnumList(['value1', 'value2']); + $this->assertSame(['value1', 'value2'], $result); + } + + #[Test] + public function as_enum_list_throws_on_non_array(): void + { + $this->expectException(TypeError::class); + TypeHelper::asEnumList('not an array'); + } + + #[Test] + public function as_enum_list_or_null_returns_list(): void + { + $result = TypeHelper::asEnumListOrNull(['value1', 'value2']); + $this->assertSame(['value1', 'value2'], $result); + } + + #[Test] + public function as_enum_list_or_null_returns_null(): void + { + $result = TypeHelper::asEnumListOrNull(null); + $this->assertNull($result); + } + + #[Test] + public function as_string_mixed_map_or_null_returns_map(): void + { + $result = TypeHelper::asStringMixedMapOrNull(['key1' => 'value', 'key2' => 123]); + $this->assertSame(['key1' => 'value', 'key2' => 123], $result); + } + + #[Test] + public function as_string_mixed_map_or_null_returns_null(): void + { + $result = TypeHelper::asStringMixedMapOrNull(null); + $this->assertNull($result); + } + + #[Test] + public function as_string_mixed_map_or_null_throws_on_non_array(): void + { + $this->expectException(TypeError::class); + TypeHelper::asStringMixedMapOrNull('not an array'); + } + + #[Test] + public function as_string_mixed_map_or_null_throws_on_non_string_keys(): void + { + $this->expectException(TypeError::class); + TypeHelper::asStringMixedMapOrNull([123 => 'value']); + } + + #[Test] + public function as_security_list_map_returns_list(): void + { + $result = TypeHelper::asSecurityListMap([ + ['scheme1' => ['scope1']], + ['scheme2' => ['scope2', 'scope3']], + ]); + $this->assertSame([ + ['scheme1' => ['scope1']], + ['scheme2' => ['scope2', 'scope3']], + ], $result); + } + + #[Test] + public function as_security_list_map_throws_on_non_array(): void + { + $this->expectException(TypeError::class); + TypeHelper::asSecurityListMap('not an array'); + } + + #[Test] + public function as_security_list_map_throws_on_non_array_item(): void + { + $this->expectException(TypeError::class); + TypeHelper::asSecurityListMap(['not an array']); + } + + #[Test] + public function as_security_list_map_throws_on_non_string_key(): void + { + $this->expectException(TypeError::class); + TypeHelper::asSecurityListMap([[123 => ['scope']]]); + } + + #[Test] + public function as_security_list_map_throws_on_non_array_value(): void + { + $this->expectException(TypeError::class); + TypeHelper::asSecurityListMap([['scheme' => 'not an array']]); + } } diff --git a/tests/Security/XmlSecurityTest.php b/tests/Security/XmlSecurityTest.php new file mode 100644 index 0000000..05f0e0e --- /dev/null +++ b/tests/Security/XmlSecurityTest.php @@ -0,0 +1,220 @@ +psrFactory = new Psr17Factory(); + } + + #[Test] + public function xxe_attack_via_request_body_does_not_leak_file_content(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/security-specs/xml-endpoint.yaml') + ->build(); + + $xxePayload = <<<'XML' + + +]> + + test + &xxe; + +XML; + + $request = $this->psrFactory->createServerRequest('POST', '/xml-endpoint') + ->withHeader('Content-Type', 'application/xml') + ->withBody($this->psrFactory->createStream($xxePayload)); + + $exceptionThrown = false; + $errorMessage = ''; + + try { + $validator->validateRequest($request); + } catch (Throwable $e) { + $exceptionThrown = true; + $errorMessage = $e->getMessage(); + } + + $this->assertStringNotContainsString('/etc/passwd', $errorMessage); + $this->assertStringNotContainsString('root:', $errorMessage); + $this->assertStringNotContainsString('/bin/bash', $errorMessage); + } + + #[Test] + public function ssrf_via_xxe_blocked(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/security-specs/xml-endpoint.yaml') + ->build(); + + $ssrfPayload = <<<'XML' + + +]> + + test + &xxe; + +XML; + + $request = $this->psrFactory->createServerRequest('POST', '/xml-endpoint') + ->withHeader('Content-Type', 'application/xml') + ->withBody($this->psrFactory->createStream($ssrfPayload)); + + $errorMessage = ''; + + try { + $validator->validateRequest($request); + } catch (Throwable $e) { + $errorMessage = $e->getMessage(); + } + + $this->assertStringNotContainsString('meta-data', $errorMessage); + $this->assertStringNotContainsString('169.254.169.254', $errorMessage); + } + + #[Test] + public function valid_xml_request_body(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/security-specs/xml-endpoint.yaml') + ->build(); + + $validXml = <<<'XML' + + + John Doe + Test Value + +XML; + + $request = $this->psrFactory->createServerRequest('POST', '/xml-endpoint') + ->withHeader('Content-Type', 'application/xml') + ->withBody($this->psrFactory->createStream($validXml)); + + $operation = $validator->validateRequest($request); + + $this->assertSame('POST', $operation->method); + $this->assertSame('/xml-endpoint', $operation->path); + } + + #[Test] + public function billion_laughs_no_memory_exhaustion(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/security-specs/xml-endpoint.yaml') + ->build(); + + $billionLaughs = <<<'XML' + + + + +]> + + test + &lol3; + +XML; + + $request = $this->psrFactory->createServerRequest('POST', '/xml-endpoint') + ->withHeader('Content-Type', 'application/xml') + ->withBody($this->psrFactory->createStream($billionLaughs)); + + $memoryBefore = memory_get_usage(); + + try { + $validator->validateRequest($request); + } catch (Throwable) { + } + + $memoryAfter = memory_get_usage(); + $memoryIncrease = $memoryAfter - $memoryBefore; + + $this->assertLessThan(10 * 1024 * 1024, $memoryIncrease, 'Memory should not increase significantly during billion laughs attack'); + } + + #[Test] + public function external_dtd_blocked(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/security-specs/xml-endpoint.yaml') + ->build(); + + $externalDtdPayload = <<<'XML' + + + + test + data + +XML; + + $request = $this->psrFactory->createServerRequest('POST', '/xml-endpoint') + ->withHeader('Content-Type', 'application/xml') + ->withBody($this->psrFactory->createStream($externalDtdPayload)); + + $errorMessage = ''; + + try { + $validator->validateRequest($request); + } catch (Throwable $e) { + $errorMessage = $e->getMessage(); + } + + $this->assertStringNotContainsString('attacker.com', $errorMessage); + } + + #[Test] + public function xxe_cdata_no_data_leak(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/security-specs/xml-endpoint.yaml') + ->build(); + + $xxePayload = <<<'XML' + + +]> + + test + &xxe; + +XML; + + $request = $this->psrFactory->createServerRequest('POST', '/xml-endpoint') + ->withHeader('Content-Type', 'application/xml') + ->withBody($this->psrFactory->createStream($xxePayload)); + + $errorMessage = ''; + + try { + $validator->validateRequest($request); + } catch (Throwable $e) { + $errorMessage = $e->getMessage(); + } + + $this->assertStringNotContainsString('PATH=', $errorMessage); + $this->assertStringNotContainsString('HOME=', $errorMessage); + } +} diff --git a/tests/Validator/Request/BodyParser/XmlBodyParserTest.php b/tests/Validator/Request/BodyParser/XmlBodyParserTest.php new file mode 100644 index 0000000..9d097d8 --- /dev/null +++ b/tests/Validator/Request/BodyParser/XmlBodyParserTest.php @@ -0,0 +1,394 @@ +parser = new XmlBodyParser(); + } + + #[Test] + public function xxe_external_entity_blocked(): void + { + $xxePayload = <<<'XML' + + +]> +&xxe; +XML; + + $result = $this->parser->parse($xxePayload); + + if (is_array($result)) { + $this->assertStringNotContainsString('root:', (string) ($result['root'] ?? '')); + $this->assertStringNotContainsString('/bin/bash', (string) ($result['root'] ?? '')); + } else { + $this->assertStringNotContainsString('root:', $result); + $this->assertStringNotContainsString('/bin/bash', $result); + } + } + + #[Test] + public function xxe_parameter_entity_blocked(): void + { + $xxePayload = <<<'XML' + + + %xxe; +]> +test +XML; + + $result = $this->parser->parse($xxePayload); + + if (is_array($result)) { + $this->assertArrayNotHasKey('xxe', $result); + } + + $this->assertTrue(is_array($result) || is_string($result)); + } + + #[Test] + public function billion_laughs_attack_handled(): void + { + $billionLaughs = <<<'XML' + + + + +]> +&lol3; +XML; + + $memoryBefore = memory_get_usage(); + $result = $this->parser->parse($billionLaughs); + $memoryAfter = memory_get_usage(); + $memoryIncrease = $memoryAfter - $memoryBefore; + + $this->assertLessThan(10 * 1024 * 1024, $memoryIncrease, 'Billion laughs attack should not cause memory exhaustion'); + $this->assertTrue(is_array($result) || is_string($result)); + } + + #[Test] + public function valid_xml_parsed_correctly(): void + { + $xml = <<<'XML' + + + John Doe + john@example.com + 30 + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + $this->assertArrayHasKey('name', $result); + $this->assertArrayHasKey('email', $result); + $this->assertArrayHasKey('age', $result); + $this->assertSame('John Doe', $result['name']); + $this->assertSame('john@example.com', $result['email']); + $this->assertSame('30', $result['age']); + } + + #[Test] + public function empty_xml_returns_empty_string(): void + { + $result = $this->parser->parse(''); + + $this->assertSame('', $result); + } + + #[Test] + public function whitespace_only_returns_empty_string(): void + { + $result = $this->parser->parse(' '); + + $this->assertSame('', $result); + } + + #[Test] + public function invalid_xml_returns_raw_body(): void + { + $invalidXml = ''; + + $result = $this->parser->parse($invalidXml); + + $this->assertSame($invalidXml, $result); + } + + #[Test] + public function nested_xml_parsed_correctly(): void + { + $xml = <<<'XML' + + + + + Alice + Developer + + + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + $this->assertArrayHasKey('department', $result); + } + + #[Test] + public function xml_with_attributes_parsed(): void + { + $xml = <<<'XML' + + + John + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + $this->assertArrayHasKey('name', $result); + $this->assertSame('John', $result['name']); + } + + #[Test] + public function xxe_ssrf_blocked(): void + { + $xxeSsrf = <<<'XML' + + +]> +&xxe; +XML; + + $result = $this->parser->parse($xxeSsrf); + + if (is_array($result)) { + $this->assertStringNotContainsString('secret', (string) ($result['root'] ?? '')); + } else { + $this->assertStringNotContainsString('secret', $result); + } + } + + #[Test] + public function xml_with_cdata_parsed(): void + { + $xml = <<<'XML' + + + alert('xss')]]> + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + $this->assertArrayHasKey('content', $result); + } + + #[Test] + public function xxe_no_file_disclosure(): void + { + $xxePayload = <<<'XML' + + +]> +&file; +XML; + + $result = $this->parser->parse($xxePayload); + + $resultString = is_array($result) ? json_encode($result) : $result; + $this->assertStringNotContainsString('root:x:0:0', $resultString ?: ''); + $this->assertStringNotContainsString('/bin/bash', $resultString ?: ''); + } + + #[Test] + public function xml_with_only_text_content(): void + { + $xml = 'simple text content'; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + } + + #[Test] + public function xml_with_mixed_content(): void + { + $xml = <<<'XML' + + + text before + child content + text after + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + } + + #[Test] + public function xml_with_numeric_values(): void + { + $xml = <<<'XML' + + + 42 + 3.14 + hello + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + $this->assertArrayHasKey('int', $result); + $this->assertArrayHasKey('float', $result); + $this->assertArrayHasKey('string', $result); + } + + #[Test] + public function xml_with_empty_elements(): void + { + $xml = <<<'XML' + + + + + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + } + + #[Test] + public function xml_special_characters_handled(): void + { + $xml = <<<'XML' + + + & + < + > + " + ' + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + } + + #[Test] + public function deeply_nested_xml_handled(): void + { + $xml = <<<'XML' + + + + + + deep value + + + + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + } + + #[Test] + public function xml_with_unicode_content(): void + { + $xml = <<<'XML' + + + 中文测试 + 😀🎉 + Привет мир + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + $this->assertArrayHasKey('chinese', $result); + $this->assertArrayHasKey('emoji', $result); + $this->assertArrayHasKey('russian', $result); + } + + #[Test] + public function xml_with_duplicate_element_names(): void + { + $xml = <<<'XML' + + + Alice + Bob + Charlie + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + } + + #[Test] + public function xml_declares_wrong_encoding_returns_raw(): void + { + $xml = '' . "\xFF\xFE" . ''; + + $result = $this->parser->parse($xml); + + $this->assertTrue(is_array($result) || is_string($result)); + } + + #[Test] + public function xml_with_namespace_parsed(): void + { + $xml = <<<'XML' + + + namespaced content + +XML; + + $result = $this->parser->parse($xml); + + $this->assertIsArray($result); + } +} diff --git a/tests/Validator/Schema/RefResolverCircularTest.php b/tests/Validator/Schema/RefResolverCircularTest.php new file mode 100644 index 0000000..6271603 --- /dev/null +++ b/tests/Validator/Schema/RefResolverCircularTest.php @@ -0,0 +1,226 @@ +resolver = new RefResolver(); + } + + #[Test] + public function direct_circular_ref_throws_exception(): void + { + $schemaA = new Schema(ref: '#/components/schemas/B'); + $schemaB = new Schema(ref: '#/components/schemas/A'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: ['A' => $schemaA, 'B' => $schemaB]), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Circular reference detected'); + + $this->resolver->resolve('#/components/schemas/A', $document); + } + + #[Test] + public function indirect_circular_ref_throws_exception(): void + { + $schemaA = new Schema(ref: '#/components/schemas/B'); + $schemaB = new Schema(ref: '#/components/schemas/C'); + $schemaC = new Schema(ref: '#/components/schemas/A'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: ['A' => $schemaA, 'B' => $schemaB, 'C' => $schemaC]), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Circular reference detected'); + + $this->resolver->resolve('#/components/schemas/A', $document); + } + + #[Test] + public function self_referencing_schema_throws_exception(): void + { + $schemaA = new Schema(ref: '#/components/schemas/A'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: ['A' => $schemaA]), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Circular reference detected'); + + $this->resolver->resolve('#/components/schemas/A', $document); + } + + #[Test] + public function valid_ref_chain_resolves(): void + { + $schemaC = new Schema(title: 'FinalSchema', type: 'object'); + $schemaB = new Schema(ref: '#/components/schemas/C'); + $schemaA = new Schema(ref: '#/components/schemas/B'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: ['A' => $schemaA, 'B' => $schemaB, 'C' => $schemaC]), + ); + + $result = $this->resolver->resolve('#/components/schemas/A', $document); + + self::assertSame('FinalSchema', $result->title); + self::assertSame('object', $result->type); + } + + #[Test] + public function error_message_contains_full_path(): void + { + $schemaA = new Schema(ref: '#/components/schemas/B'); + $schemaB = new Schema(ref: '#/components/schemas/C'); + $schemaC = new Schema(ref: '#/components/schemas/A'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: ['A' => $schemaA, 'B' => $schemaB, 'C' => $schemaC]), + ); + + try { + $this->resolver->resolve('#/components/schemas/A', $document); + $this->fail('Expected UnresolvableRefException was not thrown'); + } catch (UnresolvableRefException $e) { + self::assertStringContainsString('#/components/schemas/A', $e->reason); + self::assertStringContainsString('#/components/schemas/B', $e->reason); + self::assertStringContainsString('#/components/schemas/C', $e->reason); + self::assertStringContainsString(' -> ', $e->reason); + } + } + + #[Test] + public function parameter_circular_ref_throws_exception(): void + { + $paramA = new Parameter(ref: '#/components/parameters/B'); + $paramB = new Parameter(ref: '#/components/parameters/A'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(parameters: ['A' => $paramA, 'B' => $paramB]), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Circular reference detected'); + + $this->resolver->resolveParameter('#/components/parameters/A', $document); + } + + #[Test] + public function response_circular_ref_throws_exception(): void + { + $responseA = new Response(ref: '#/components/responses/B'); + $responseB = new Response(ref: '#/components/responses/A'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(responses: ['A' => $responseA, 'B' => $responseB]), + ); + + $this->expectException(UnresolvableRefException::class); + $this->expectExceptionMessage('Circular reference detected'); + + $this->resolver->resolveResponse('#/components/responses/A', $document); + } + + #[Test] + public function schema_without_ref_resolves_directly(): void + { + $schema = new Schema(title: 'DirectSchema', type: 'string'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: ['Direct' => $schema]), + ); + + $result = $this->resolver->resolve('#/components/schemas/Direct', $document); + + self::assertSame('DirectSchema', $result->title); + self::assertSame('string', $result->type); + } + + #[Test] + public function valid_parameter_ref_chain_resolves(): void + { + $paramB = new Parameter(name: 'id', in: 'path'); + $paramA = new Parameter(ref: '#/components/parameters/B'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(parameters: ['A' => $paramA, 'B' => $paramB]), + ); + + $result = $this->resolver->resolveParameter('#/components/parameters/A', $document); + + self::assertSame('id', $result->name); + self::assertSame('path', $result->in); + } + + #[Test] + public function valid_response_ref_chain_resolves(): void + { + $responseB = new Response(description: 'Success'); + $responseA = new Response(ref: '#/components/responses/B'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(responses: ['A' => $responseA, 'B' => $responseB]), + ); + + $result = $this->resolver->resolveResponse('#/components/responses/A', $document); + + self::assertSame('Success', $result->description); + } + + #[Test] + public function deep_valid_ref_chain_resolves(): void + { + $schemaE = new Schema(title: 'FinalSchema'); + $schemaD = new Schema(ref: '#/components/schemas/E'); + $schemaC = new Schema(ref: '#/components/schemas/D'); + $schemaB = new Schema(ref: '#/components/schemas/C'); + $schemaA = new Schema(ref: '#/components/schemas/B'); + $document = new OpenApiDocument( + openapi: '3.0.0', + info: new InfoObject(title: 'Test', version: '1.0'), + components: new Components(schemas: [ + 'A' => $schemaA, + 'B' => $schemaB, + 'C' => $schemaC, + 'D' => $schemaD, + 'E' => $schemaE, + ]), + ); + + $result = $this->resolver->resolve('#/components/schemas/A', $document); + + self::assertSame('FinalSchema', $result->title); + } +} diff --git a/tests/Validator/SchemaValidator/EmptyArrayStrategyTest.php b/tests/Validator/SchemaValidator/EmptyArrayStrategyTest.php new file mode 100644 index 0000000..53f1f32 --- /dev/null +++ b/tests/Validator/SchemaValidator/EmptyArrayStrategyTest.php @@ -0,0 +1,224 @@ +pool = new ValidatorPool(); + } + + #[Test] + public function empty_array_with_prefer_array_strategy_valid_for_array(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferArray); + $schema = new Schema(type: 'array'); + + $validator->validate([], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function empty_array_with_prefer_array_strategy_invalid_for_object(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferArray); + $schema = new Schema(type: 'object'); + + $this->expectException(TypeMismatchError::class); + + $validator->validate([], $schema, $context); + } + + #[Test] + public function empty_array_with_prefer_object_strategy_invalid_for_array(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferObject); + $schema = new Schema(type: 'array'); + + $this->expectException(TypeMismatchError::class); + + $validator->validate([], $schema, $context); + } + + #[Test] + public function empty_array_with_prefer_object_strategy_valid_for_object(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferObject); + $schema = new Schema(type: 'object'); + + $validator->validate([], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function empty_array_with_reject_strategy_invalid_for_array(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::Reject); + $schema = new Schema(type: 'array'); + + $this->expectException(TypeMismatchError::class); + + $validator->validate([], $schema, $context); + } + + #[Test] + public function empty_array_with_reject_strategy_invalid_for_object(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::Reject); + $schema = new Schema(type: 'object'); + + $this->expectException(TypeMismatchError::class); + + $validator->validate([], $schema, $context); + } + + #[Test] + public function empty_array_with_allow_both_strategy_valid_for_array(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::AllowBoth); + $schema = new Schema(type: 'array'); + + $validator->validate([], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function empty_array_with_allow_both_strategy_valid_for_object(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::AllowBoth); + $schema = new Schema(type: 'object'); + + $validator->validate([], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function empty_array_in_union_type_with_prefer_array(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferArray); + $schema = new Schema(type: ['array', 'object']); + + $validator->validate([], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function empty_array_in_union_type_with_prefer_object(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferObject); + $schema = new Schema(type: ['array', 'object']); + + $validator->validate([], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function empty_array_in_union_type_with_reject(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::Reject); + $schema = new Schema(type: ['array', 'object']); + + $this->expectException(TypeMismatchError::class); + + $validator->validate([], $schema, $context); + } + + #[Test] + public function empty_array_in_union_type_with_allow_both(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::AllowBoth); + $schema = new Schema(type: ['array', 'object']); + + $validator->validate([], $schema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function strategy_configured_via_builder(): void + { + $spec = '{"openapi":"3.0.3","info":{"title":"Test","version":"1.0.0"},"paths":{}}'; + + $validator = OpenApiValidatorBuilder::create() + ->fromJsonString($spec) + ->withEmptyArrayStrategy(EmptyArrayStrategy::PreferArray) + ->build(); + + $this->assertSame(EmptyArrayStrategy::PreferArray, $validator->emptyArrayStrategy); + } + + #[Test] + public function default_strategy_is_allow_both(): void + { + $spec = '{"openapi":"3.0.3","info":{"title":"Test","version":"1.0.0"},"paths":{}}'; + + $validator = OpenApiValidatorBuilder::create() + ->fromJsonString($spec) + ->build(); + + $this->assertSame(EmptyArrayStrategy::AllowBoth, $validator->emptyArrayStrategy); + } + + #[Test] + public function non_empty_array_validation_unchanged_with_prefer_array(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferArray); + + $arraySchema = new Schema(type: 'array'); + $objectSchema = new Schema(type: 'object'); + + $validator->validate([1, 2, 3], $arraySchema, $context); + $validator->validate(['key' => 'value'], $objectSchema, $context); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function non_empty_object_validation_unchanged_with_prefer_object(): void + { + $validator = new TypeValidator($this->pool); + $context = ValidationContext::create($this->pool, true, EmptyArrayStrategy::PreferObject); + + $arraySchema = new Schema(type: 'array'); + $objectSchema = new Schema(type: 'object'); + + $validator->validate([1, 2, 3], $arraySchema, $context); + $validator->validate(['key' => 'value'], $objectSchema, $context); + + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/fixtures/security-specs/xml-endpoint.yaml b/tests/fixtures/security-specs/xml-endpoint.yaml new file mode 100644 index 0000000..0e10362 --- /dev/null +++ b/tests/fixtures/security-specs/xml-endpoint.yaml @@ -0,0 +1,24 @@ +openapi: 3.1.0 +info: + title: XML Security Test API + version: 1.0.0 +paths: + /xml-endpoint: + post: + summary: Accepts XML body + requestBody: + required: true + content: + application/xml: + schema: + type: object + properties: + name: + type: string + value: + type: string + required: + - name + responses: + '200': + description: Success