From f78ec937417063752e3ddb53c4a9ac8c9e61cd37 Mon Sep 17 00:00:00 2001 From: Mikhail Ilinsky Date: Sat, 21 Feb 2026 15:56:17 +1000 Subject: [PATCH] feat: OpenAPI 3.2 support --- README.md | 20 +- psalm-baseline.xml | 26 +- src/Compiler/ValidatorCompiler.php | 2 +- src/Exception/RefResolutionException.php | 9 + src/Schema/Model/Components.php | 26 +- src/Schema/Model/Contact.php | 6 +- src/Schema/Model/Discriminator.php | 17 +- src/Schema/Model/Encoding.php | 59 + src/Schema/Model/Example.php | 23 +- src/Schema/Model/ExternalDocs.php | 2 +- src/Schema/Model/Header.php | 10 +- src/Schema/Model/InfoObject.php | 8 +- src/Schema/Model/License.php | 4 +- src/Schema/Model/Link.php | 14 +- src/Schema/Model/MediaType.php | 27 +- src/Schema/Model/OAuthFlow.php | 52 + src/Schema/Model/OAuthFlows.php | 47 + src/Schema/Model/Operation.php | 22 +- src/Schema/Model/Parameter.php | 34 +- src/Schema/Model/PathItem.php | 39 +- src/Schema/Model/RequestBody.php | 4 +- src/Schema/Model/Response.php | 29 +- src/Schema/Model/Schema.php | 118 +- src/Schema/Model/SecurityScheme.php | 42 +- src/Schema/Model/Server.php | 9 +- src/Schema/Model/Tag.php | 19 +- src/Schema/Model/Xml.php | 61 + src/Schema/OpenApiDocument.php | 21 +- src/Schema/Parser/DeprecationLogger.php | 35 + src/Schema/Parser/JsonParser.php | 2 +- src/Schema/Parser/OpenApiBuilder.php | 284 +++- src/Schema/Parser/TypeHelper.php | 22 +- src/Schema/Parser/YamlParser.php | 2 +- .../Exception/InvalidParameterException.php | 41 + src/Validator/OpenApiValidator.php | 28 +- src/Validator/PathFinder.php | 13 +- .../Request/AbstractParameterValidator.php | 2 +- .../BodyParser/MultipartBodyParser.php | 9 +- src/Validator/Request/CookieValidator.php | 123 ++ .../Request/ParameterDeserializer.php | 11 +- src/Validator/Request/QueryParser.php | 49 + .../Request/QueryStringValidator.php | 109 ++ .../Request/RequestBodyValidator.php | 2 - src/Validator/Request/RequestValidator.php | 7 +- .../Response/ResponseBodyValidator.php | 35 + .../ResponseBodyValidatorWithContext.php | 42 + .../Response/StreamingContentParser.php | 170 +++ .../Response/StreamingMediaTypeDetector.php | 20 + .../Schema/DiscriminatorValidator.php | 37 +- .../Schema/OneOfValidatorWithContext.php | 4 +- src/Validator/Schema/RefResolver.php | 172 +++ src/Validator/Schema/RefResolverInterface.php | 58 + .../AdditionalPropertiesValidator.php | 22 +- .../SchemaValidator/ArrayLengthValidator.php | 2 +- .../SchemaValidator/IfThenElseValidator.php | 2 +- .../UnevaluatedPropertiesValidator.php | 2 +- .../StreamingResponseValidationTest.php | 250 ++++ tests/Integration/EdgeCasesV32Test.php | 344 +++++ tests/Integration/OpenApiV32Test.php | 302 ++++ tests/Schema/Model/ComponentsTest.php | 52 + tests/Schema/Model/DiscriminatorTest.php | 99 ++ tests/Schema/Model/EncodingTest.php | 226 +++ tests/Schema/Model/ExampleTest.php | 74 + tests/Schema/Model/MediaTypeTest.php | 122 +- tests/Schema/Model/OAuthFlowTest.php | 193 +++ tests/Schema/Model/OAuthFlowsTest.php | 232 +++ tests/Schema/Model/PathItemTest.php | 79 + tests/Schema/Model/ResponseTest.php | 37 + tests/Schema/Model/SchemaTest.php | 41 +- tests/Schema/Model/SecuritySchemeTest.php | 184 ++- tests/Schema/Model/ServerTest.php | 38 + tests/Schema/Model/TagTest.php | 104 ++ tests/Schema/Model/XmlTest.php | 191 +++ tests/Schema/OpenApiDocumentTest.php | 41 + tests/Schema/Parser/DeprecationLoggerTest.php | 110 ++ tests/Schema/Parser/OpenApiBuilderTest.php | 1316 ++++++++++++++++- tests/Schema/Parser/ReferenceOverrideTest.php | 454 ++++++ .../InvalidParameterExceptionTest.php | 62 + .../Validator/OpenApiValidatorMethodsTest.php | 206 +++ .../Validator/Request/CookieValidatorTest.php | 411 +++++ .../Request/ParameterDeserializerTest.php | 9 + tests/Validator/Request/QueryParserTest.php | 161 ++ .../Request/QueryStringValidatorTest.php | 270 ++++ .../RequestValidatorIntegrationTest.php | 4 + .../Response/ResponseBodyValidatorTest.php | 190 +++ .../ResponseBodyValidatorWithContextTest.php | 488 ++++++ .../Response/StreamingContentParserTest.php | 257 ++++ .../StreamingMediaTypeDetectorTest.php | 86 ++ .../Schema/DiscriminatorValidatorTest.php | 160 ++ .../Schema/ItemsValidatorWithContextTest.php | 161 ++ .../Schema/OneOfValidatorWithContextTest.php | 442 ++++++ tests/Validator/Schema/RefResolverTest.php | 99 ++ .../Webhook/WebhookValidatorTest.php | 4 + tests/fixtures/openapi-3.2-basic.yaml | 35 + .../response-validation-specs/streaming.yaml | 100 ++ tests/fixtures/v3.0/simple-api.yaml | 53 + tests/fixtures/v3.2/full-spec.yaml | 182 +++ tests/fixtures/v3.2/query-method.yaml | 33 + tests/fixtures/v3.2/streaming-events.yaml | 31 + 99 files changed, 9667 insertions(+), 321 deletions(-) create mode 100644 src/Exception/RefResolutionException.php create mode 100644 src/Schema/Model/Encoding.php create mode 100644 src/Schema/Model/OAuthFlow.php create mode 100644 src/Schema/Model/OAuthFlows.php create mode 100644 src/Schema/Model/Xml.php create mode 100644 src/Schema/Parser/DeprecationLogger.php create mode 100644 src/Validator/Exception/InvalidParameterException.php create mode 100644 src/Validator/Request/QueryStringValidator.php create mode 100644 src/Validator/Response/StreamingContentParser.php create mode 100644 src/Validator/Response/StreamingMediaTypeDetector.php create mode 100644 tests/Functional/Response/StreamingResponseValidationTest.php create mode 100644 tests/Integration/EdgeCasesV32Test.php create mode 100644 tests/Integration/OpenApiV32Test.php create mode 100644 tests/Schema/Model/EncodingTest.php create mode 100644 tests/Schema/Model/OAuthFlowTest.php create mode 100644 tests/Schema/Model/OAuthFlowsTest.php create mode 100644 tests/Schema/Model/XmlTest.php create mode 100644 tests/Schema/Parser/DeprecationLoggerTest.php create mode 100644 tests/Schema/Parser/ReferenceOverrideTest.php create mode 100644 tests/Validator/Exception/InvalidParameterExceptionTest.php create mode 100644 tests/Validator/Request/QueryStringValidatorTest.php create mode 100644 tests/Validator/Response/ResponseBodyValidatorWithContextTest.php create mode 100644 tests/Validator/Response/StreamingContentParserTest.php create mode 100644 tests/Validator/Response/StreamingMediaTypeDetectorTest.php create mode 100644 tests/Validator/Schema/OneOfValidatorWithContextTest.php create mode 100644 tests/fixtures/openapi-3.2-basic.yaml create mode 100644 tests/fixtures/response-validation-specs/streaming.yaml create mode 100644 tests/fixtures/v3.0/simple-api.yaml create mode 100644 tests/fixtures/v3.2/full-spec.yaml create mode 100644 tests/fixtures/v3.2/query-method.yaml create mode 100644 tests/fixtures/v3.2/streaming-events.yaml diff --git a/README.md b/README.md index 00cba73..715814d 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ [![psalm-level](https://shepherd.dev/github/duyler/openapi/level.svg)](https://shepherd.dev/github/duyler/openapi) ![PHP Version](https://img.shields.io/packagist/dependency-v/duyler/openapi/php?version=dev-main) -OpenAPI 3.1 validator for PHP 8.4+ +OpenAPI 3.2 validator for PHP 8.4+ ## Features -- **Full OpenAPI 3.1 Support** - Complete implementation of OpenAPI 3.1 specification +- **Full OpenAPI 3.2 Support** - Complete implementation of OpenAPI 3.2 specification - **JSON Schema Validation** - Full JSON Schema draft 2020-12 validation with 25+ validators - **PSR-7 Integration** - Works with any PSR-7 HTTP message implementation - **Request Validation** - Validate path parameters, query parameters, headers, cookies, and request body @@ -229,7 +229,7 @@ Validate polymorphic schemas with discriminators: ```php $yaml = << - @@ -108,6 +92,8 @@ MixedAssignment suppressions: + + @@ -245,9 +231,6 @@ MixedAssignment suppressions: - - - @@ -259,6 +242,8 @@ MixedAssignment suppressions: + + @@ -282,9 +267,6 @@ MixedAssignment suppressions: - - - diff --git a/src/Compiler/ValidatorCompiler.php b/src/Compiler/ValidatorCompiler.php index 63f7c6e..f4ad52b 100644 --- a/src/Compiler/ValidatorCompiler.php +++ b/src/Compiler/ValidatorCompiler.php @@ -332,7 +332,7 @@ private function generateArrayCheck(Schema $schema): string $code .= " }\n\n"; } - if (null !== $schema->uniqueItems && true === $schema->uniqueItems) { + if (null !== $schema->uniqueItems && $schema->uniqueItems) { $code .= " if (count(\$data) !== count(array_unique(\$data, SORT_REGULAR))) {\n"; $code .= " throw new \\RuntimeException('Array items must be unique');\n"; $code .= " }\n\n"; diff --git a/src/Exception/RefResolutionException.php b/src/Exception/RefResolutionException.php new file mode 100644 index 0000000..d65eafd --- /dev/null +++ b/src/Exception/RefResolutionException.php @@ -0,0 +1,9 @@ +|null $links * @param array|null $callbacks * @param array|null $pathItems + * @param array|null $mediaTypes */ public function __construct( public ?array $schemas = null, @@ -32,6 +33,7 @@ public function __construct( public ?array $links = null, public ?array $callbacks = null, public ?array $pathItems = null, + public ?array $mediaTypes = null, ) {} #[Override] @@ -39,46 +41,50 @@ public function jsonSerialize(): array { $data = []; - if ($this->schemas !== null) { + if (null !== $this->schemas) { $data['schemas'] = $this->schemas; } - if ($this->responses !== null) { + if (null !== $this->responses) { $data['responses'] = $this->responses; } - if ($this->parameters !== null) { + if (null !== $this->parameters) { $data['parameters'] = $this->parameters; } - if ($this->examples !== null) { + if (null !== $this->examples) { $data['examples'] = $this->examples; } - if ($this->requestBodies !== null) { + if (null !== $this->requestBodies) { $data['requestBodies'] = $this->requestBodies; } - if ($this->headers !== null) { + if (null !== $this->headers) { $data['headers'] = $this->headers; } - if ($this->securitySchemes !== null) { + if (null !== $this->securitySchemes) { $data['securitySchemes'] = $this->securitySchemes; } - if ($this->links !== null) { + if (null !== $this->links) { $data['links'] = $this->links; } - if ($this->callbacks !== null) { + if (null !== $this->callbacks) { $data['callbacks'] = $this->callbacks; } - if ($this->pathItems !== null) { + if (null !== $this->pathItems) { $data['pathItems'] = $this->pathItems; } + if (null !== $this->mediaTypes) { + $data['mediaTypes'] = $this->mediaTypes; + } + return $data; } } diff --git a/src/Schema/Model/Contact.php b/src/Schema/Model/Contact.php index 8cf014f..b3aea0b 100644 --- a/src/Schema/Model/Contact.php +++ b/src/Schema/Model/Contact.php @@ -20,15 +20,15 @@ public function jsonSerialize(): array { $data = []; - if ($this->name !== null) { + if (null !== $this->name) { $data['name'] = $this->name; } - if ($this->url !== null) { + if (null !== $this->url) { $data['url'] = $this->url; } - if ($this->email !== null) { + if (null !== $this->email) { $data['email'] = $this->email; } diff --git a/src/Schema/Model/Discriminator.php b/src/Schema/Model/Discriminator.php index ab3a67d..d8b4d96 100644 --- a/src/Schema/Model/Discriminator.php +++ b/src/Schema/Model/Discriminator.php @@ -13,21 +13,28 @@ * @param array $mapping */ public function __construct( - public string $propertyName, + public ?string $propertyName = null, public ?array $mapping = null, + public ?string $defaultMapping = null, ) {} #[Override] public function jsonSerialize(): array { - $data = [ - 'propertyName' => $this->propertyName, - ]; + $data = []; - if ($this->mapping !== null) { + if (null !== $this->propertyName) { + $data['propertyName'] = $this->propertyName; + } + + if (null !== $this->mapping) { $data['mapping'] = $this->mapping; } + if (null !== $this->defaultMapping) { + $data['defaultMapping'] = $this->defaultMapping; + } + return $data; } } diff --git a/src/Schema/Model/Encoding.php b/src/Schema/Model/Encoding.php new file mode 100644 index 0000000..b588fc9 --- /dev/null +++ b/src/Schema/Model/Encoding.php @@ -0,0 +1,59 @@ +|null $encoding + * @param array|null $prefixEncoding + */ + public function __construct( + public ?string $contentType = null, + public ?Headers $headers = null, + public ?string $style = null, + public ?bool $explode = null, + public ?bool $allowReserved = null, + public ?array $encoding = null, + public ?array $prefixEncoding = null, + public ?Encoding $itemEncoding = null, + ) {} + + #[Override] + public function jsonSerialize(): array + { + $result = []; + + if (null !== $this->contentType) { + $result['contentType'] = $this->contentType; + } + if (null !== $this->headers) { + $result['headers'] = $this->headers; + } + if (null !== $this->style) { + $result['style'] = $this->style; + } + if (null !== $this->explode) { + $result['explode'] = $this->explode; + } + if (null !== $this->allowReserved) { + $result['allowReserved'] = $this->allowReserved; + } + if (null !== $this->encoding) { + $result['encoding'] = $this->encoding; + } + if (null !== $this->prefixEncoding) { + $result['prefixEncoding'] = $this->prefixEncoding; + } + if (null !== $this->itemEncoding) { + $result['itemEncoding'] = $this->itemEncoding; + } + + return $result; + } +} diff --git a/src/Schema/Model/Example.php b/src/Schema/Model/Example.php index 9617c0d..8b4780b 100644 --- a/src/Schema/Model/Example.php +++ b/src/Schema/Model/Example.php @@ -13,7 +13,10 @@ public function __construct( public ?string $summary = null, public ?string $description = null, public mixed $value = null, + public mixed $dataValue = null, + public mixed $serializedValue = null, public ?string $externalValue = null, + public ?string $serializedExample = null, ) {} #[Override] @@ -21,22 +24,34 @@ public function jsonSerialize(): array { $data = []; - if ($this->summary !== null) { + if (null !== $this->summary) { $data['summary'] = $this->summary; } - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->value !== null) { + if (null !== $this->value) { $data['value'] = $this->value; } - if ($this->externalValue !== null) { + if (null !== $this->dataValue) { + $data['dataValue'] = $this->dataValue; + } + + if (null !== $this->serializedValue) { + $data['serializedValue'] = $this->serializedValue; + } + + if (null !== $this->externalValue) { $data['externalValue'] = $this->externalValue; } + if (null !== $this->serializedExample) { + $data['serializedExample'] = $this->serializedExample; + } + return $data; } } diff --git a/src/Schema/Model/ExternalDocs.php b/src/Schema/Model/ExternalDocs.php index 5718f97..b70c3cd 100644 --- a/src/Schema/Model/ExternalDocs.php +++ b/src/Schema/Model/ExternalDocs.php @@ -21,7 +21,7 @@ public function jsonSerialize(): array 'url' => $this->url, ]; - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } diff --git a/src/Schema/Model/Header.php b/src/Schema/Model/Header.php index 4d79d4e..c44fc37 100644 --- a/src/Schema/Model/Header.php +++ b/src/Schema/Model/Header.php @@ -28,7 +28,7 @@ public function jsonSerialize(): array { $data = []; - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } @@ -44,19 +44,19 @@ public function jsonSerialize(): array $data['allowEmptyValue'] = $this->allowEmptyValue; } - if ($this->schema !== null) { + if (null !== $this->schema) { $data['schema'] = $this->schema; } - if ($this->example !== null) { + if (null !== $this->example) { $data['example'] = $this->example; } - if ($this->examples !== null) { + if (null !== $this->examples) { $data['examples'] = $this->examples; } - if ($this->content !== null) { + if (null !== $this->content) { $data['content'] = $this->content; } diff --git a/src/Schema/Model/InfoObject.php b/src/Schema/Model/InfoObject.php index aa5f1d7..36efff7 100644 --- a/src/Schema/Model/InfoObject.php +++ b/src/Schema/Model/InfoObject.php @@ -26,19 +26,19 @@ public function jsonSerialize(): array 'version' => $this->version, ]; - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->termsOfService !== null) { + if (null !== $this->termsOfService) { $data['termsOfService'] = $this->termsOfService; } - if ($this->contact !== null) { + if (null !== $this->contact) { $data['contact'] = $this->contact; } - if ($this->license !== null) { + if (null !== $this->license) { $data['license'] = $this->license; } diff --git a/src/Schema/Model/License.php b/src/Schema/Model/License.php index 832a2ec..5033646 100644 --- a/src/Schema/Model/License.php +++ b/src/Schema/Model/License.php @@ -22,11 +22,11 @@ public function jsonSerialize(): array 'name' => $this->name, ]; - if ($this->identifier !== null) { + if (null !== $this->identifier) { $data['identifier'] = $this->identifier; } - if ($this->url !== null) { + if (null !== $this->url) { $data['url'] = $this->url; } diff --git a/src/Schema/Model/Link.php b/src/Schema/Model/Link.php index d2184c2..afead47 100644 --- a/src/Schema/Model/Link.php +++ b/src/Schema/Model/Link.php @@ -27,31 +27,31 @@ public function jsonSerialize(): array { $data = []; - if ($this->operationRef !== null) { + if (null !== $this->operationRef) { $data['operationRef'] = $this->operationRef; } - if ($this->ref !== null) { + if (null !== $this->ref) { $data['$ref'] = $this->ref; } - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->operationId !== null) { + if (null !== $this->operationId) { $data['operationId'] = $this->operationId; } - if ($this->parameters !== null) { + if (null !== $this->parameters) { $data['parameters'] = $this->parameters; } - if ($this->requestBody !== null) { + if (null !== $this->requestBody) { $data['requestBody'] = $this->requestBody; } - if ($this->server !== null) { + if (null !== $this->server) { $data['server'] = $this->server; } diff --git a/src/Schema/Model/MediaType.php b/src/Schema/Model/MediaType.php index 4861144..c38daf0 100644 --- a/src/Schema/Model/MediaType.php +++ b/src/Schema/Model/MediaType.php @@ -11,10 +11,15 @@ { /** * @param array $examples + * @param array|null $encoding + * @param array|null $prefixEncoding */ public function __construct( public ?Schema $schema = null, - public ?string $encoding = null, + public ?Schema $itemSchema = null, + public ?array $encoding = null, + public ?Encoding $itemEncoding = null, + public ?array $prefixEncoding = null, public ?array $examples = null, public ?Example $example = null, ) {} @@ -24,19 +29,31 @@ public function jsonSerialize(): array { $data = []; - if ($this->schema !== null) { + if (null !== $this->schema) { $data['schema'] = $this->schema; } - if ($this->encoding !== null) { + if (null !== $this->itemSchema) { + $data['itemSchema'] = $this->itemSchema; + } + + if (null !== $this->encoding) { $data['encoding'] = $this->encoding; } - if ($this->example !== null) { + if (null !== $this->itemEncoding) { + $data['itemEncoding'] = $this->itemEncoding; + } + + if (null !== $this->prefixEncoding) { + $data['prefixEncoding'] = $this->prefixEncoding; + } + + if (null !== $this->example) { $data['example'] = $this->example; } - if ($this->examples !== null) { + if (null !== $this->examples) { $data['examples'] = $this->examples; } diff --git a/src/Schema/Model/OAuthFlow.php b/src/Schema/Model/OAuthFlow.php new file mode 100644 index 0000000..ed9e64e --- /dev/null +++ b/src/Schema/Model/OAuthFlow.php @@ -0,0 +1,52 @@ +authorizationUrl) { + $data['authorizationUrl'] = $this->authorizationUrl; + } + + if (null !== $this->tokenUrl) { + $data['tokenUrl'] = $this->tokenUrl; + } + + if (null !== $this->refreshUrl) { + $data['refreshUrl'] = $this->refreshUrl; + } + + if (null !== $this->scopes) { + $data['scopes'] = $this->scopes; + } + + if (null !== $this->deviceAuthorizationUrl) { + $data['deviceAuthorizationUrl'] = $this->deviceAuthorizationUrl; + } + + if (null !== $this->deprecated) { + $data['deprecated'] = $this->deprecated; + } + + return $data; + } +} diff --git a/src/Schema/Model/OAuthFlows.php b/src/Schema/Model/OAuthFlows.php new file mode 100644 index 0000000..2a17579 --- /dev/null +++ b/src/Schema/Model/OAuthFlows.php @@ -0,0 +1,47 @@ +implicit) { + $data['implicit'] = $this->implicit->jsonSerialize(); + } + + if (null !== $this->password) { + $data['password'] = $this->password->jsonSerialize(); + } + + if (null !== $this->clientCredentials) { + $data['clientCredentials'] = $this->clientCredentials->jsonSerialize(); + } + + if (null !== $this->authorizationCode) { + $data['authorizationCode'] = $this->authorizationCode->jsonSerialize(); + } + + if (null !== $this->deviceCode) { + $data['deviceCode'] = $this->deviceCode->jsonSerialize(); + } + + return $data; + } +} diff --git a/src/Schema/Model/Operation.php b/src/Schema/Model/Operation.php index 8c10748..67745df 100644 --- a/src/Schema/Model/Operation.php +++ b/src/Schema/Model/Operation.php @@ -35,39 +35,39 @@ public function jsonSerialize(): array { $data = []; - if ($this->tags !== null) { + if (null !== $this->tags) { $data['tags'] = $this->tags; } - if ($this->summary !== null) { + if (null !== $this->summary) { $data['summary'] = $this->summary; } - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->externalDocs !== null) { + if (null !== $this->externalDocs) { $data['externalDocs'] = $this->externalDocs; } - if ($this->operationId !== null) { + if (null !== $this->operationId) { $data['operationId'] = $this->operationId; } - if ($this->parameters !== null) { + if (null !== $this->parameters) { $data['parameters'] = $this->parameters; } - if ($this->requestBody !== null) { + if (null !== $this->requestBody) { $data['requestBody'] = $this->requestBody; } - if ($this->responses !== null) { + if (null !== $this->responses) { $data['responses'] = $this->responses; } - if ($this->callbacks !== null) { + if (null !== $this->callbacks) { $data['callbacks'] = $this->callbacks; } @@ -75,11 +75,11 @@ public function jsonSerialize(): array $data['deprecated'] = $this->deprecated; } - if ($this->security !== null) { + if (null !== $this->security) { $data['security'] = $this->security; } - if ($this->servers !== null) { + if (null !== $this->servers) { $data['servers'] = $this->servers; } diff --git a/src/Schema/Model/Parameter.php b/src/Schema/Model/Parameter.php index ffbac26..1e44a81 100644 --- a/src/Schema/Model/Parameter.php +++ b/src/Schema/Model/Parameter.php @@ -14,6 +14,8 @@ */ public function __construct( public ?string $ref = null, + public ?string $refSummary = null, + public ?string $refDescription = null, public ?string $name = null, public ?string $in = null, public ?string $description = null, @@ -32,21 +34,31 @@ public function __construct( #[Override] public function jsonSerialize(): array { - $data = []; + if (null !== $this->ref) { + $data = ['$ref' => $this->ref]; + + if (null !== $this->refSummary) { + $data['summary'] = $this->refSummary; + } + + if (null !== $this->refDescription) { + $data['description'] = $this->refDescription; + } - if ($this->ref !== null) { - $data['$ref'] = $this->ref; + return $data; } - if ($this->name !== null) { + $data = []; + + if (null !== $this->name) { $data['name'] = $this->name; } - if ($this->in !== null) { + if (null !== $this->in) { $data['in'] = $this->in; } - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } @@ -62,7 +74,7 @@ public function jsonSerialize(): array $data['allowEmptyValue'] = $this->allowEmptyValue; } - if ($this->style !== null) { + if (null !== $this->style) { $data['style'] = $this->style; } @@ -74,19 +86,19 @@ public function jsonSerialize(): array $data['allowReserved'] = $this->allowReserved; } - if ($this->schema !== null) { + if (null !== $this->schema) { $data['schema'] = $this->schema; } - if ($this->example !== null) { + if (null !== $this->example) { $data['example'] = $this->example; } - if ($this->examples !== null) { + if (null !== $this->examples) { $data['examples'] = $this->examples; } - if ($this->content !== null) { + if (null !== $this->content) { $data['content'] = $this->content; } diff --git a/src/Schema/Model/PathItem.php b/src/Schema/Model/PathItem.php index 98d4f59..40c0759 100644 --- a/src/Schema/Model/PathItem.php +++ b/src/Schema/Model/PathItem.php @@ -9,6 +9,9 @@ readonly class PathItem implements JsonSerializable { + /** + * @param array|null $additionalOperations + */ public function __construct( public ?string $ref = null, public ?string $summary = null, @@ -21,6 +24,8 @@ public function __construct( public ?Operation $head = null, public ?Operation $patch = null, public ?Operation $trace = null, + public ?Operation $query = null, + public ?array $additionalOperations = null, public ?Servers $servers = null, public ?Parameters $parameters = null, ) {} @@ -30,55 +35,63 @@ public function jsonSerialize(): array { $data = []; - if ($this->ref !== null) { + if (null !== $this->ref) { $data['$ref'] = $this->ref; } - if ($this->summary !== null) { + if (null !== $this->summary) { $data['summary'] = $this->summary; } - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->get !== null) { + if (null !== $this->get) { $data['get'] = $this->get; } - if ($this->put !== null) { + if (null !== $this->put) { $data['put'] = $this->put; } - if ($this->post !== null) { + if (null !== $this->post) { $data['post'] = $this->post; } - if ($this->delete !== null) { + if (null !== $this->delete) { $data['delete'] = $this->delete; } - if ($this->options !== null) { + if (null !== $this->options) { $data['options'] = $this->options; } - if ($this->head !== null) { + if (null !== $this->head) { $data['head'] = $this->head; } - if ($this->patch !== null) { + if (null !== $this->patch) { $data['patch'] = $this->patch; } - if ($this->trace !== null) { + if (null !== $this->trace) { $data['trace'] = $this->trace; } - if ($this->servers !== null) { + if (null !== $this->query) { + $data['query'] = $this->query; + } + + if (null !== $this->additionalOperations) { + $data['additionalOperations'] = $this->additionalOperations; + } + + if (null !== $this->servers) { $data['servers'] = $this->servers; } - if ($this->parameters !== null) { + if (null !== $this->parameters) { $data['parameters'] = $this->parameters; } diff --git a/src/Schema/Model/RequestBody.php b/src/Schema/Model/RequestBody.php index 2eeae60..62c3b5f 100644 --- a/src/Schema/Model/RequestBody.php +++ b/src/Schema/Model/RequestBody.php @@ -20,11 +20,11 @@ public function jsonSerialize(): array { $data = []; - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->content !== null) { + if (null !== $this->content) { $data['content'] = $this->content; } diff --git a/src/Schema/Model/Response.php b/src/Schema/Model/Response.php index 98702b1..084eb13 100644 --- a/src/Schema/Model/Response.php +++ b/src/Schema/Model/Response.php @@ -11,6 +11,9 @@ { public function __construct( public ?string $ref = null, + public ?string $refSummary = null, + public ?string $refDescription = null, + public ?string $summary = null, public ?string $description = null, public ?Headers $headers = null, public ?Content $content = null, @@ -20,25 +23,39 @@ public function __construct( #[Override] public function jsonSerialize(): array { + if (null !== $this->ref) { + $data = ['$ref' => $this->ref]; + + if (null !== $this->refSummary) { + $data['summary'] = $this->refSummary; + } + + if (null !== $this->refDescription) { + $data['description'] = $this->refDescription; + } + + return $data; + } + $data = []; - if ($this->ref !== null) { - $data['$ref'] = $this->ref; + if (null !== $this->summary) { + $data['summary'] = $this->summary; } - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->headers !== null) { + if (null !== $this->headers) { $data['headers'] = $this->headers; } - if ($this->content !== null) { + if (null !== $this->content) { $data['content'] = $this->content; } - if ($this->links !== null) { + if (null !== $this->links) { $data['links'] = $this->links; } diff --git a/src/Schema/Model/Schema.php b/src/Schema/Model/Schema.php index 4f1c196..19e0763 100644 --- a/src/Schema/Model/Schema.php +++ b/src/Schema/Model/Schema.php @@ -24,9 +24,12 @@ * @param Schema|bool|null $additionalProperties * @param list|null $enum * @param array|null $examples + * @param Xml|null $xml */ public function __construct( public ?string $ref = null, + public ?string $refSummary = null, + public ?string $refDescription = null, public ?string $format = null, public ?string $title = null, public ?string $description = null, @@ -76,26 +79,37 @@ public function __construct( public ?string $contentMediaType = null, public ?string $contentSchema = null, public ?string $jsonSchemaDialect = null, + public ?Xml $xml = null, ) {} #[Override] public function jsonSerialize(): array { - $data = []; + if (null !== $this->ref) { + $data = ['$ref' => $this->ref]; + + if (null !== $this->refSummary) { + $data['summary'] = $this->refSummary; + } - if ($this->ref !== null) { - $data['$ref'] = $this->ref; + if (null !== $this->refDescription) { + $data['description'] = $this->refDescription; + } + + return $data; } - if ($this->title !== null) { + $data = []; + + if (null !== $this->title) { $data['title'] = $this->title; } - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->default !== null) { + if (null !== $this->default) { $data['default'] = $this->default; } @@ -103,7 +117,7 @@ public function jsonSerialize(): array $data['deprecated'] = $this->deprecated; } - if ($this->type !== null) { + if (null !== $this->type) { $data['type'] = $this->type; } @@ -111,178 +125,182 @@ public function jsonSerialize(): array $data['nullable'] = $this->nullable; } - if ($this->const !== null) { + if (null !== $this->const) { $data['const'] = $this->const; } - if ($this->multipleOf !== null) { + if (null !== $this->multipleOf) { $data['multipleOf'] = $this->multipleOf; } - if ($this->maximum !== null) { + if (null !== $this->maximum) { $data['maximum'] = $this->maximum; } - if ($this->exclusiveMaximum !== null) { + if (null !== $this->exclusiveMaximum) { $data['exclusiveMaximum'] = $this->exclusiveMaximum; } - if ($this->minimum !== null) { + if (null !== $this->minimum) { $data['minimum'] = $this->minimum; } - if ($this->exclusiveMinimum !== null) { + if (null !== $this->exclusiveMinimum) { $data['exclusiveMinimum'] = $this->exclusiveMinimum; } - if ($this->maxLength !== null) { + if (null !== $this->maxLength) { $data['maxLength'] = $this->maxLength; } - if ($this->minLength !== null) { + if (null !== $this->minLength) { $data['minLength'] = $this->minLength; } - if ($this->pattern !== null) { + if (null !== $this->pattern) { $data['pattern'] = $this->pattern; } - if ($this->maxItems !== null) { + if (null !== $this->maxItems) { $data['maxItems'] = $this->maxItems; } - if ($this->minItems !== null) { + if (null !== $this->minItems) { $data['minItems'] = $this->minItems; } - if ($this->uniqueItems !== null) { + if (null !== $this->uniqueItems) { $data['uniqueItems'] = $this->uniqueItems; } - if ($this->maxProperties !== null) { + if (null !== $this->maxProperties) { $data['maxProperties'] = $this->maxProperties; } - if ($this->minProperties !== null) { + if (null !== $this->minProperties) { $data['minProperties'] = $this->minProperties; } - if ($this->required !== null) { + if (null !== $this->required) { $data['required'] = $this->required; } - if ($this->allOf !== null) { + if (null !== $this->allOf) { $data['allOf'] = $this->allOf; } - if ($this->anyOf !== null) { + if (null !== $this->anyOf) { $data['anyOf'] = $this->anyOf; } - if ($this->oneOf !== null) { + if (null !== $this->oneOf) { $data['oneOf'] = $this->oneOf; } - if ($this->not !== null) { + if (null !== $this->not) { $data['not'] = $this->not; } - if ($this->discriminator !== null) { + if (null !== $this->discriminator) { $data['discriminator'] = $this->discriminator; } - if ($this->properties !== null) { + if (null !== $this->properties) { $data['properties'] = $this->properties; } - if ($this->additionalProperties !== null) { + if (null !== $this->additionalProperties) { $data['additionalProperties'] = $this->additionalProperties; } - if ($this->unevaluatedProperties !== null) { + if (null !== $this->unevaluatedProperties) { $data['unevaluatedProperties'] = $this->unevaluatedProperties; } - if ($this->items !== null) { + if (null !== $this->items) { $data['items'] = $this->items; } - if ($this->prefixItems !== null) { + if (null !== $this->prefixItems) { $data['prefixItems'] = $this->prefixItems; } - if ($this->contains !== null) { + if (null !== $this->contains) { $data['contains'] = $this->contains; } - if ($this->minContains !== null) { + if (null !== $this->minContains) { $data['minContains'] = $this->minContains; } - if ($this->maxContains !== null) { + if (null !== $this->maxContains) { $data['maxContains'] = $this->maxContains; } - if ($this->patternProperties !== null) { + if (null !== $this->patternProperties) { $data['patternProperties'] = $this->patternProperties; } - if ($this->propertyNames !== null) { + if (null !== $this->propertyNames) { $data['propertyNames'] = $this->propertyNames; } - if ($this->dependentSchemas !== null) { + if (null !== $this->dependentSchemas) { $data['dependentSchemas'] = $this->dependentSchemas; } - if ($this->if !== null) { + if (null !== $this->if) { $data['if'] = $this->if; } - if ($this->then !== null) { + if (null !== $this->then) { $data['then'] = $this->then; } - if ($this->else !== null) { + if (null !== $this->else) { $data['else'] = $this->else; } - if ($this->unevaluatedItems !== null) { + if (null !== $this->unevaluatedItems) { $data['unevaluatedItems'] = $this->unevaluatedItems; } - if ($this->example !== null) { + if (null !== $this->example) { $data['example'] = $this->example; } - if ($this->examples !== null) { + if (null !== $this->examples) { $data['examples'] = $this->examples; } - if ($this->enum !== null) { + if (null !== $this->enum) { $data['enum'] = $this->enum; } - if ($this->format !== null) { + if (null !== $this->format) { $data['format'] = $this->format; } - if ($this->contentEncoding !== null) { + if (null !== $this->contentEncoding) { $data['contentEncoding'] = $this->contentEncoding; } - if ($this->contentMediaType !== null) { + if (null !== $this->contentMediaType) { $data['contentMediaType'] = $this->contentMediaType; } - if ($this->contentSchema !== null) { + if (null !== $this->contentSchema) { $data['contentSchema'] = $this->contentSchema; } - if ($this->jsonSchemaDialect !== null) { + if (null !== $this->jsonSchemaDialect) { $data['$schema'] = $this->jsonSchemaDialect; } + if (null !== $this->xml) { + $data['xml'] = $this->xml; + } + return $data; } } diff --git a/src/Schema/Model/SecurityScheme.php b/src/Schema/Model/SecurityScheme.php index a490b9d..0069bb3 100644 --- a/src/Schema/Model/SecurityScheme.php +++ b/src/Schema/Model/SecurityScheme.php @@ -9,6 +9,9 @@ readonly class SecurityScheme implements JsonSerializable { + /** + * @param array|null $scopes + */ public function __construct( public string $type, public ?string $description = null, @@ -16,12 +19,13 @@ public function __construct( public ?string $in = null, public ?string $scheme = null, public ?string $bearerFormat = null, - public ?string $flows = null, + public ?OAuthFlows $flows = null, + public ?string $openIdConnectUrl = null, + public ?string $oauth2MetadataUrl = null, public ?string $authorizationUrl = null, public ?string $tokenUrl = null, public ?string $refreshUrl = null, public ?array $scopes = null, - public ?string $openIdConnectUrl = null, ) {} #[Override] @@ -31,50 +35,54 @@ public function jsonSerialize(): array 'type' => $this->type, ]; - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->name !== null) { + if (null !== $this->name) { $data['name'] = $this->name; } - if ($this->in !== null) { + if (null !== $this->in) { $data['in'] = $this->in; } - if ($this->scheme !== null) { + if (null !== $this->scheme) { $data['scheme'] = $this->scheme; } - if ($this->bearerFormat !== null) { + if (null !== $this->bearerFormat) { $data['bearerFormat'] = $this->bearerFormat; } - if ($this->flows !== null) { - $data['flows'] = $this->flows; + if (null !== $this->flows) { + $data['flows'] = $this->flows->jsonSerialize(); + } + + if (null !== $this->openIdConnectUrl) { + $data['openIdConnectUrl'] = $this->openIdConnectUrl; + } + + if (null !== $this->oauth2MetadataUrl) { + $data['oauth2MetadataUrl'] = $this->oauth2MetadataUrl; } - if ($this->authorizationUrl !== null) { + if (null !== $this->authorizationUrl) { $data['authorizationUrl'] = $this->authorizationUrl; } - if ($this->tokenUrl !== null) { + if (null !== $this->tokenUrl) { $data['tokenUrl'] = $this->tokenUrl; } - if ($this->refreshUrl !== null) { + if (null !== $this->refreshUrl) { $data['refreshUrl'] = $this->refreshUrl; } - if ($this->scopes !== null) { + if (null !== $this->scopes) { $data['scopes'] = $this->scopes; } - if ($this->openIdConnectUrl !== null) { - $data['openIdConnectUrl'] = $this->openIdConnectUrl; - } - return $data; } } diff --git a/src/Schema/Model/Server.php b/src/Schema/Model/Server.php index 4d2ba3b..0da2526 100644 --- a/src/Schema/Model/Server.php +++ b/src/Schema/Model/Server.php @@ -16,6 +16,7 @@ public function __construct( public string $url, public ?string $description = null, public ?array $variables = null, + public ?string $name = null, ) {} #[Override] @@ -25,14 +26,18 @@ public function jsonSerialize(): array 'url' => $this->url, ]; - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->variables !== null) { + if (null !== $this->variables) { $data['variables'] = $this->variables; } + if (null !== $this->name) { + $data['name'] = $this->name; + } + return $data; } } diff --git a/src/Schema/Model/Tag.php b/src/Schema/Model/Tag.php index 506572b..6b56fd7 100644 --- a/src/Schema/Model/Tag.php +++ b/src/Schema/Model/Tag.php @@ -13,6 +13,9 @@ public function __construct( public string $name, public ?string $description = null, public ?ExternalDocs $externalDocs = null, + public ?string $summary = null, + public ?string $parent = null, + public ?string $kind = null, ) {} #[Override] @@ -22,14 +25,26 @@ public function jsonSerialize(): array 'name' => $this->name, ]; - if ($this->description !== null) { + if (null !== $this->description) { $data['description'] = $this->description; } - if ($this->externalDocs !== null) { + if (null !== $this->externalDocs) { $data['externalDocs'] = $this->externalDocs; } + if (null !== $this->summary) { + $data['summary'] = $this->summary; + } + + if (null !== $this->parent) { + $data['parent'] = $this->parent; + } + + if (null !== $this->kind) { + $data['kind'] = $this->kind; + } + return $data; } } diff --git a/src/Schema/Model/Xml.php b/src/Schema/Model/Xml.php new file mode 100644 index 0000000..1236af4 --- /dev/null +++ b/src/Schema/Model/Xml.php @@ -0,0 +1,61 @@ +name) { + $data['name'] = $this->name; + } + + if (null !== $this->namespace) { + $data['namespace'] = $this->namespace; + } + + if (null !== $this->prefix) { + $data['prefix'] = $this->prefix; + } + + if (null !== $this->attribute) { + $data['attribute'] = $this->attribute; + } + + if (null !== $this->wrapped) { + $data['wrapped'] = $this->wrapped; + } + + if (null !== $this->nodeType) { + $data['nodeType'] = $this->nodeType; + } + + return $data; + } +} diff --git a/src/Schema/OpenApiDocument.php b/src/Schema/OpenApiDocument.php index 110b817..b518c44 100644 --- a/src/Schema/OpenApiDocument.php +++ b/src/Schema/OpenApiDocument.php @@ -20,6 +20,7 @@ public function __construct( public ?Model\SecurityRequirement $security = null, public ?Model\Tags $tags = null, public ?Model\ExternalDocs $externalDocs = null, + public ?string $self = null, ) {} #[Override] @@ -30,38 +31,42 @@ public function jsonSerialize(): array 'info' => $this->info, ]; - if ($this->jsonSchemaDialect !== null) { + if (null !== $this->jsonSchemaDialect) { $data['jsonSchemaDialect'] = $this->jsonSchemaDialect; } - if ($this->servers !== null) { + if (null !== $this->servers) { $data['servers'] = $this->servers; } - if ($this->paths !== null) { + if (null !== $this->paths) { $data['paths'] = $this->paths; } - if ($this->webhooks !== null) { + if (null !== $this->webhooks) { $data['webhooks'] = $this->webhooks; } - if ($this->components !== null) { + if (null !== $this->components) { $data['components'] = $this->components; } - if ($this->security !== null) { + if (null !== $this->security) { $data['security'] = $this->security; } - if ($this->tags !== null) { + if (null !== $this->tags) { $data['tags'] = $this->tags; } - if ($this->externalDocs !== null) { + if (null !== $this->externalDocs) { $data['externalDocs'] = $this->externalDocs; } + if (null !== $this->self) { + $data['$self'] = $this->self; + } + return $data; } } diff --git a/src/Schema/Parser/DeprecationLogger.php b/src/Schema/Parser/DeprecationLogger.php new file mode 100644 index 0000000..be96ebb --- /dev/null +++ b/src/Schema/Parser/DeprecationLogger.php @@ -0,0 +1,35 @@ +enabled) { + return; + } + + $message = sprintf( + "Field '%s' in %s is deprecated since OpenAPI %s.", + $field, + $object, + $version, + ); + + if ('' !== $alternative) { + $message .= sprintf(" Use '%s' instead.", $alternative); + } + + $this->logger->warning($message); + } +} diff --git a/src/Schema/Parser/JsonParser.php b/src/Schema/Parser/JsonParser.php index 324fd24..ebb4800 100644 --- a/src/Schema/Parser/JsonParser.php +++ b/src/Schema/Parser/JsonParser.php @@ -8,7 +8,7 @@ use const JSON_THROW_ON_ERROR; -readonly class JsonParser extends OpenApiBuilder +final class JsonParser extends OpenApiBuilder { #[Override] protected function parseContent(string $content): mixed diff --git a/src/Schema/Parser/OpenApiBuilder.php b/src/Schema/Parser/OpenApiBuilder.php index 12d6046..ec54983 100644 --- a/src/Schema/Parser/OpenApiBuilder.php +++ b/src/Schema/Parser/OpenApiBuilder.php @@ -10,6 +10,7 @@ use Duyler\OpenApi\Schema\Model\Contact; use Duyler\OpenApi\Schema\Model\Content; use Duyler\OpenApi\Schema\Model\Discriminator; +use Duyler\OpenApi\Schema\Model\Encoding; use Duyler\OpenApi\Schema\Model\Example; use Duyler\OpenApi\Schema\Model\ExternalDocs; use Duyler\OpenApi\Schema\Model\Header; @@ -19,6 +20,8 @@ use Duyler\OpenApi\Schema\Model\Link; use Duyler\OpenApi\Schema\Model\Links; use Duyler\OpenApi\Schema\Model\MediaType; +use Duyler\OpenApi\Schema\Model\OAuthFlow; +use Duyler\OpenApi\Schema\Model\OAuthFlows; use Duyler\OpenApi\Schema\Model\Operation; use Duyler\OpenApi\Schema\Model\Parameter; use Duyler\OpenApi\Schema\Model\Parameters; @@ -35,6 +38,7 @@ use Duyler\OpenApi\Schema\Model\Tag; use Duyler\OpenApi\Schema\Model\Tags; use Duyler\OpenApi\Schema\Model\Webhooks; +use Duyler\OpenApi\Schema\Model\Xml; use Duyler\OpenApi\Schema\OpenApiDocument; use Duyler\OpenApi\Schema\SchemaParserInterface; use Override; @@ -42,9 +46,22 @@ use function is_array; use function is_string; +use function assert; +use function sprintf; -abstract readonly class OpenApiBuilder implements SchemaParserInterface +use const FILTER_VALIDATE_URL; + +abstract class OpenApiBuilder implements SchemaParserInterface { + protected string $documentVersion = ''; + protected DeprecationLogger $deprecationLogger; + + public function __construct( + ?DeprecationLogger $deprecationLogger = null, + ) { + $this->deprecationLogger = $deprecationLogger ?? new DeprecationLogger(); + } + #[Override] public function parse(string $content): OpenApiDocument { @@ -84,6 +101,10 @@ abstract protected function getFormatName(): string; protected function buildDocument(array $data): OpenApiDocument { $this->validateVersion($data); + $this->documentVersion = (string) $data['openapi']; + + $self = TypeHelper::asStringOrNull($data['$self'] ?? null); + $this->validateSelfUri($self); return new OpenApiDocument( openapi: (string) $data['openapi'], @@ -96,9 +117,23 @@ protected function buildDocument(array $data): OpenApiDocument 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, + self: $self, ); } + protected function validateSelfUri(?string $self): void + { + if (null === $self) { + return; + } + + if (false === filter_var($self, FILTER_VALIDATE_URL)) { + throw new InvalidSchemaException( + 'Invalid $self URI: ' . $self, + ); + } + } + protected function validateVersion(array $data): void { if (false === isset($data['openapi'])) { @@ -106,11 +141,16 @@ protected function validateVersion(array $data): void } $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.'); + if (false === is_string($version) || 1 !== preg_match('/^3\.[0-2]\.[0-9]+$/', $version)) { + throw new InvalidSchemaException('Unsupported OpenAPI version: ' . (string) $version . '. Only 3.0.x, 3.1.x and 3.2.x are supported.'); } } + protected function shouldWarnDeprecation(): bool + { + return version_compare($this->documentVersion, '3.2.0', '>='); + } + protected function buildInfo(array $data): InfoObject { if (false === isset($data['title']) || false === isset($data['version'])) { @@ -156,6 +196,7 @@ protected function buildServers(array $data): array variables: isset($server['variables']) && is_array($server['variables']) ? TypeHelper::asStringMixedMapOrNull(TypeHelper::asArray($server['variables'])) : null, + name: TypeHelper::asStringOrNull($server['name'] ?? null), ), array_values($data)); } @@ -170,6 +211,9 @@ protected function buildTags(array $data): array externalDocs: isset($tag['externalDocs']) && is_array($tag['externalDocs']) ? $this->buildExternalDocs(TypeHelper::asArray($tag['externalDocs'])) : null, + summary: TypeHelper::asStringOrNull($tag['summary'] ?? null), + parent: TypeHelper::asStringOrNull($tag['parent'] ?? null), + kind: TypeHelper::asStringOrNull($tag['kind'] ?? null), ), array_values($data)); } @@ -199,11 +243,31 @@ protected function buildPathItem(array $data): PathItem 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, + query: isset($data['query']) ? $this->buildOperation(TypeHelper::asArray($data['query'])) : null, + additionalOperations: isset($data['additionalOperations']) && is_array($data['additionalOperations']) + ? $this->buildAdditionalOperations(TypeHelper::asArray($data['additionalOperations'])) + : 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 array + */ + protected function buildAdditionalOperations(array $data): array + { + $operations = []; + + foreach ($data as $method => $operationData) { + if (is_string($method) && is_array($operationData)) { + $operations[$method] = $this->buildOperation(TypeHelper::asArray($operationData)); + } + } + + return $operations; + } + /** * @return list */ @@ -217,6 +281,8 @@ protected function buildParameter(array $data): Parameter if (isset($data['$ref'])) { return new Parameter( ref: TypeHelper::asString($data['$ref']), + refSummary: TypeHelper::asStringOrNull($data['summary'] ?? null), + refDescription: TypeHelper::asStringOrNull($data['description'] ?? null), ); } @@ -224,6 +290,14 @@ protected function buildParameter(array $data): Parameter throw new InvalidSchemaException('Parameter must have name and in fields'); } + if ($this->shouldWarnDeprecation() && isset($data['allowEmptyValue']) && $data['allowEmptyValue']) { + $this->deprecationLogger->warn( + 'allowEmptyValue', + 'Parameter Object', + '3.2.0', + ); + } + return new Parameter( name: TypeHelper::asString($data['name']), in: TypeHelper::asString($data['in']), @@ -245,8 +319,19 @@ protected function buildParameter(array $data): Parameter protected function buildSchema(array $data): Schema { + if ($this->shouldWarnDeprecation() && isset($data['example'])) { + $this->deprecationLogger->warn( + 'example', + 'Schema Object', + '3.2.0', + 'examples in MediaType Object', + ); + } + return new Schema( ref: TypeHelper::asStringOrNull($data['$ref'] ?? null), + refSummary: isset($data['$ref']) ? TypeHelper::asStringOrNull($data['summary'] ?? null) : null, + refDescription: isset($data['$ref']) ? TypeHelper::asStringOrNull($data['description'] ?? null) : null, format: TypeHelper::asStringOrNull($data['format'] ?? null), title: TypeHelper::asStringOrNull($data['title'] ?? null), description: TypeHelper::asStringOrNull($data['description'] ?? null), @@ -304,6 +389,9 @@ enum: TypeHelper::asEnumListOrNull($data['enum'] ?? null), contentMediaType: TypeHelper::asStringOrNull($data['contentMediaType'] ?? null), contentSchema: TypeHelper::asStringOrNull($data['contentSchema'] ?? null), jsonSchemaDialect: TypeHelper::asStringOrNull($data['$schema'] ?? null), + xml: isset($data['xml']) && is_array($data['xml']) + ? $this->buildXml(TypeHelper::asArray($data['xml'])) + : null, ); } @@ -325,16 +413,59 @@ protected function buildProperties(array $data): array 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']), + propertyName: TypeHelper::asStringOrNull($data['propertyName'] ?? null), mapping: TypeHelper::asStringMapOrNull($data['mapping'] ?? null), + defaultMapping: TypeHelper::asStringOrNull($data['defaultMapping'] ?? null), ); } + protected function buildXml(array $data): Xml + { + if ($this->shouldWarnDeprecation() && isset($data['attribute'])) { + $this->deprecationLogger->warn( + 'attribute', + 'XML Object', + '3.2.0', + 'nodeType: "attribute"', + ); + } + + if ($this->shouldWarnDeprecation() && isset($data['wrapped'])) { + $this->deprecationLogger->warn( + 'wrapped', + 'XML Object', + '3.2.0', + ); + } + + $xml = new Xml( + name: TypeHelper::asStringOrNull($data['name'] ?? null), + namespace: TypeHelper::asStringOrNull($data['namespace'] ?? null), + prefix: TypeHelper::asStringOrNull($data['prefix'] ?? null), + attribute: TypeHelper::asBoolOrNull($data['attribute'] ?? null), + wrapped: TypeHelper::asBoolOrNull($data['wrapped'] ?? null), + nodeType: TypeHelper::asStringOrNull($data['nodeType'] ?? null), + ); + + $this->validateXml($xml); + + return $xml; + } + + protected function validateXml(Xml $xml): void + { + if (null !== $xml->nodeType && !Xml::isValidNodeType($xml->nodeType)) { + throw new InvalidSchemaException( + sprintf( + 'Invalid XML nodeType "%s". Must be one of: %s', + $xml->nodeType, + implode(', ', Xml::VALID_NODE_TYPES), + ), + ); + } + } + protected function buildOperation(array $data): Operation { return new Operation( @@ -384,7 +515,18 @@ protected function buildMediaType(array $data): MediaType schema: isset($data['schema']) && is_array($data['schema']) ? $this->buildSchema(TypeHelper::asArray($data['schema'])) : null, - encoding: TypeHelper::asStringOrNull($data['encoding'] ?? null), + itemSchema: isset($data['itemSchema']) && is_array($data['itemSchema']) + ? $this->buildSchema(TypeHelper::asArray($data['itemSchema'])) + : null, + encoding: isset($data['encoding']) && is_array($data['encoding']) + ? $this->buildEncodingMap(TypeHelper::asArray($data['encoding'])) + : null, + itemEncoding: isset($data['itemEncoding']) && is_array($data['itemEncoding']) + ? $this->buildEncoding(TypeHelper::asArray($data['itemEncoding'])) + : null, + prefixEncoding: isset($data['prefixEncoding']) && is_array($data['prefixEncoding']) + ? $this->buildPrefixEncoding(TypeHelper::asArray($data['prefixEncoding'])) + : null, example: isset($data['example']) && false === is_array($data['example']) ? $this->buildExample(['value' => $data['example']]) : null, @@ -392,6 +534,59 @@ protected function buildMediaType(array $data): MediaType ); } + protected function buildEncoding(array $data): Encoding + { + return new Encoding( + contentType: TypeHelper::asStringOrNull($data['contentType'] ?? null), + headers: isset($data['headers']) && is_array($data['headers']) + ? $this->buildHeaders(TypeHelper::asArray($data['headers'])) + : null, + style: TypeHelper::asStringOrNull($data['style'] ?? null), + explode: TypeHelper::asBoolOrNull($data['explode'] ?? null), + allowReserved: TypeHelper::asBoolOrNull($data['allowReserved'] ?? null), + encoding: isset($data['encoding']) && is_array($data['encoding']) + ? $this->buildEncodingMap(TypeHelper::asArray($data['encoding'])) + : null, + prefixEncoding: isset($data['prefixEncoding']) && is_array($data['prefixEncoding']) + ? $this->buildPrefixEncoding(TypeHelper::asArray($data['prefixEncoding'])) + : null, + itemEncoding: isset($data['itemEncoding']) && is_array($data['itemEncoding']) + ? $this->buildEncoding(TypeHelper::asArray($data['itemEncoding'])) + : null, + ); + } + + /** + * @return array + */ + protected function buildEncodingMap(array $data): array + { + $encodings = []; + + foreach ($data as $name => $encoding) { + assert(is_string($name)); + assert(is_array($encoding)); + $encodings[$name] = $this->buildEncoding(TypeHelper::asArray($encoding)); + } + + return $encodings; + } + + /** + * @return array + */ + protected function buildPrefixEncoding(array $data): array + { + $encodings = []; + + foreach ($data as $encoding) { + assert(is_array($encoding)); + $encodings[] = $this->buildEncoding(TypeHelper::asArray($encoding)); + } + + return $encodings; + } + protected function buildResponses(array $data): Responses { $responses = []; @@ -411,10 +606,13 @@ protected function buildResponse(array $data): Response if (isset($data['$ref'])) { return new Response( ref: TypeHelper::asString($data['$ref']), + refSummary: TypeHelper::asStringOrNull($data['summary'] ?? null), + refDescription: TypeHelper::asStringOrNull($data['description'] ?? null), ); } return new Response( + summary: TypeHelper::asStringOrNull($data['summary'] ?? null), description: TypeHelper::asStringOrNull($data['description'] ?? null), headers: isset($data['headers']) && is_array($data['headers']) ? $this->buildHeaders(TypeHelper::asArray($data['headers'])) @@ -568,6 +766,9 @@ protected function buildComponents(array $data): Components pathItems: isset($data['pathItems']) && is_array($data['pathItems']) ? $this->buildPathItemsComponents(TypeHelper::asArray($data['pathItems'])) : null, + mediaTypes: isset($data['mediaTypes']) && is_array($data['mediaTypes']) + ? $this->buildMediaTypesComponents(TypeHelper::asArray($data['mediaTypes'])) + : null, ); } @@ -641,7 +842,10 @@ protected function buildExample(array $data): Example summary: TypeHelper::asStringOrNull($data['summary'] ?? null), description: TypeHelper::asStringOrNull($data['description'] ?? null), value: $data['value'] ?? null, + dataValue: $data['dataValue'] ?? null, + serializedValue: $data['serializedValue'] ?? null, externalValue: TypeHelper::asStringOrNull($data['externalValue'] ?? null), + serializedExample: TypeHelper::asStringOrNull($data['serializedExample'] ?? null), ); } @@ -706,11 +910,52 @@ protected function buildSecurityScheme(array $data): SecurityScheme in: TypeHelper::asStringOrNull($data['in'] ?? null), scheme: TypeHelper::asStringOrNull($data['scheme'] ?? null), bearerFormat: TypeHelper::asStringOrNull($data['bearerFormat'] ?? null), - flows: TypeHelper::asStringOrNull($data['flows'] ?? null), + flows: isset($data['flows']) && is_array($data['flows']) + ? $this->buildOAuthFlows(TypeHelper::asArray($data['flows'])) + : null, + openIdConnectUrl: TypeHelper::asStringOrNull($data['openIdConnectUrl'] ?? null), + oauth2MetadataUrl: TypeHelper::asStringOrNull($data['oauth2MetadataUrl'] ?? 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, + scopes: isset($data['scopes']) && is_array($data['scopes']) + ? TypeHelper::asStringMap($data['scopes']) + : null, + ); + } + + protected function buildOAuthFlows(array $data): OAuthFlows + { + return new OAuthFlows( + implicit: isset($data['implicit']) && is_array($data['implicit']) + ? $this->buildOAuthFlow(TypeHelper::asArray($data['implicit'])) + : null, + password: isset($data['password']) && is_array($data['password']) + ? $this->buildOAuthFlow(TypeHelper::asArray($data['password'])) + : null, + clientCredentials: isset($data['clientCredentials']) && is_array($data['clientCredentials']) + ? $this->buildOAuthFlow(TypeHelper::asArray($data['clientCredentials'])) + : null, + authorizationCode: isset($data['authorizationCode']) && is_array($data['authorizationCode']) + ? $this->buildOAuthFlow(TypeHelper::asArray($data['authorizationCode'])) + : null, + deviceCode: isset($data['deviceCode']) && is_array($data['deviceCode']) + ? $this->buildOAuthFlow(TypeHelper::asArray($data['deviceCode'])) + : null, + ); + } + + protected function buildOAuthFlow(array $data): OAuthFlow + { + return new OAuthFlow( + authorizationUrl: TypeHelper::asStringOrNull($data['authorizationUrl'] ?? null), + tokenUrl: TypeHelper::asStringOrNull($data['tokenUrl'] ?? null), + refreshUrl: TypeHelper::asStringOrNull($data['refreshUrl'] ?? null), + scopes: isset($data['scopes']) && is_array($data['scopes']) + ? TypeHelper::asStringMap($data['scopes']) + : null, + deviceAuthorizationUrl: TypeHelper::asStringOrNull($data['deviceAuthorizationUrl'] ?? null), + deprecated: TypeHelper::asBoolOrNull($data['deprecated'] ?? null), ); } @@ -762,6 +1007,22 @@ protected function buildPathItemsComponents(array $data): array return $pathItems; } + /** + * @return array + */ + protected function buildMediaTypesComponents(array $data): array + { + $mediaTypes = []; + + foreach ($data as $name => $mediaType) { + if (is_string($name) && is_array($mediaType)) { + $mediaTypes[$name] = $this->buildMediaType(TypeHelper::asArray($mediaType)); + } + } + + return $mediaTypes; + } + protected function buildServer(array $data): Server { return new Server( @@ -770,6 +1031,7 @@ protected function buildServer(array $data): Server variables: isset($data['variables']) && is_array($data['variables']) ? TypeHelper::asStringMixedMapOrNull($data['variables']) : null, + name: TypeHelper::asStringOrNull($data['name'] ?? null), ); } } diff --git a/src/Schema/Parser/TypeHelper.php b/src/Schema/Parser/TypeHelper.php index 857bf1f..f4a383c 100644 --- a/src/Schema/Parser/TypeHelper.php +++ b/src/Schema/Parser/TypeHelper.php @@ -35,7 +35,7 @@ public static function asArray(mixed $value): array */ public static function asArrayOrNull(mixed $value): ?array { - if ($value === null) { + if (null === $value) { return null; } return self::asArray($value); @@ -61,7 +61,7 @@ public static function asString(mixed $value): string */ public static function asStringOrNull(mixed $value): ?string { - if ($value === null) { + if (null === $value) { return null; } return self::asString($value); @@ -121,7 +121,7 @@ public static function asList(mixed $value): array */ public static function asListOrNull(mixed $value): ?array { - if ($value === null) { + if (null === $value) { return null; } return self::asList($value); @@ -157,7 +157,7 @@ public static function asStringList(mixed $value): array */ public static function asStringListOrNull(mixed $value): ?array { - if ($value === null) { + if (null === $value) { return null; } return self::asStringList($value); @@ -194,7 +194,7 @@ public static function asStringMap(mixed $value): array */ public static function asStringMapOrNull(mixed $value): ?array { - if ($value === null) { + if (null === $value) { return null; } return self::asStringMap($value); @@ -207,7 +207,7 @@ public static function asStringMapOrNull(mixed $value): ?array */ public static function asStringMixedMapOrNull(mixed $value): ?array { - if ($value === null) { + if (null === $value) { return null; } if (false === is_array($value)) { @@ -244,7 +244,7 @@ public static function asEnumList(mixed $value): array */ public static function asEnumListOrNull(mixed $value): ?array { - if ($value === null) { + if (null === $value) { return null; } return self::asEnumList($value); @@ -270,7 +270,7 @@ public static function asInt(mixed $value): int */ public static function asIntOrNull(mixed $value): ?int { - if ($value === null) { + if (null === $value) { return null; } return self::asInt($value); @@ -296,7 +296,7 @@ public static function asFloat(mixed $value): float */ public static function asFloatOrNull(mixed $value): ?float { - if ($value === null) { + if (null === $value) { return null; } return self::asFloat($value); @@ -322,7 +322,7 @@ public static function asBool(mixed $value): bool */ public static function asBoolOrNull(mixed $value): ?bool { - if ($value === null) { + if (null === $value) { return null; } return self::asBool($value); @@ -369,7 +369,7 @@ public static function asSecurityListMap(mixed $value): array */ public static function asSecurityListMapOrNull(mixed $value): ?array { - if ($value === null) { + if (null === $value) { return null; } return self::asSecurityListMap($value); diff --git a/src/Schema/Parser/YamlParser.php b/src/Schema/Parser/YamlParser.php index 0ad29e2..efe8d9d 100644 --- a/src/Schema/Parser/YamlParser.php +++ b/src/Schema/Parser/YamlParser.php @@ -7,7 +7,7 @@ use Override; use Symfony\Component\Yaml\Yaml; -readonly class YamlParser extends OpenApiBuilder +final class YamlParser extends OpenApiBuilder { #[Override] protected function parseContent(string $content): mixed diff --git a/src/Validator/Exception/InvalidParameterException.php b/src/Validator/Exception/InvalidParameterException.php new file mode 100644 index 0000000..5cc9dfd --- /dev/null +++ b/src/Validator/Exception/InvalidParameterException.php @@ -0,0 +1,41 @@ + $pathItem->get, 'post' => $pathItem->post, 'put' => $pathItem->put, @@ -195,8 +198,23 @@ private function getOperationFromPathItem(PathItem $pathItem, string $method): ? 'options' => $pathItem->options, 'head' => $pathItem->head, 'trace' => $pathItem->trace, + 'query' => $pathItem->query, default => null, }; + + if (null !== $standardOperation) { + return $standardOperation; + } + + if (null !== $pathItem->additionalOperations) { + foreach ($pathItem->additionalOperations as $opMethod => $operation) { + if (strtolower($opMethod) === $method) { + return $operation; + } + } + } + + return null; } private function createRequestValidator(): RequestValidator @@ -211,6 +229,8 @@ private function createRequestValidator(): RequestValidator xmlParser: new XmlBodyParser(), ); + $queryParser = new QueryParser(); + return new RequestValidator( pathParser: new PathParser(), pathParamsValidator: new PathParametersValidator( @@ -219,13 +239,17 @@ private function createRequestValidator(): RequestValidator coercer: $coercer, coercion: $this->coercion, ), - queryParser: new QueryParser(), + queryParser: $queryParser, queryParamsValidator: new QueryParametersValidator( schemaValidator: new SchemaValidator($this->pool, $this->formatRegistry), deserializer: $deserializer, coercer: $coercer, coercion: $this->coercion, ), + queryStringValidator: new QueryStringValidator( + queryParser: $queryParser, + schemaValidator: new SchemaValidator($this->pool, $this->formatRegistry), + ), headersValidator: new HeadersValidator( schemaValidator: new SchemaValidator($this->pool, $this->formatRegistry), deserializer: $deserializer, diff --git a/src/Validator/PathFinder.php b/src/Validator/PathFinder.php index 0a018de..ec8e598 100644 --- a/src/Validator/PathFinder.php +++ b/src/Validator/PathFinder.php @@ -90,7 +90,9 @@ private function prioritizeCandidates(array $candidates): Operation private function getOperation(PathItem $pathItem, string $method, string $pathPattern): ?Operation { - $op = match (strtolower($method)) { + $normalizedMethod = strtolower($method); + + $op = match ($normalizedMethod) { 'get' => $pathItem->get, 'post' => $pathItem->post, 'put' => $pathItem->put, @@ -99,6 +101,7 @@ private function getOperation(PathItem $pathItem, string $method, string $pathPa 'options' => $pathItem->options, 'head' => $pathItem->head, 'trace' => $pathItem->trace, + 'query' => $pathItem->query, default => null, }; @@ -106,6 +109,14 @@ private function getOperation(PathItem $pathItem, string $method, string $pathPa return new Operation($pathPattern, $method); } + if (null !== $pathItem->additionalOperations) { + foreach ($pathItem->additionalOperations as $opMethod => $operation) { + if (strtolower($opMethod) === $normalizedMethod) { + return new Operation($pathPattern, $method); + } + } + } + return null; } } diff --git a/src/Validator/Request/AbstractParameterValidator.php b/src/Validator/Request/AbstractParameterValidator.php index 88ebe38..7000e0b 100644 --- a/src/Validator/Request/AbstractParameterValidator.php +++ b/src/Validator/Request/AbstractParameterValidator.php @@ -22,7 +22,7 @@ public function validate(array $data, array $parameterSchemas): void $location = $this->getLocation(); foreach ($parameterSchemas as $param) { - if (!$param instanceof Parameter) { + if (false === $param instanceof Parameter) { continue; } diff --git a/src/Validator/Request/BodyParser/MultipartBodyParser.php b/src/Validator/Request/BodyParser/MultipartBodyParser.php index bc723f4..61de290 100644 --- a/src/Validator/Request/BodyParser/MultipartBodyParser.php +++ b/src/Validator/Request/BodyParser/MultipartBodyParser.php @@ -17,9 +17,6 @@ public function parse(string $body): array return []; } - // Note: Full multipart parsing is complex and typically handled by web frameworks - // This is a simplified version for basic cases - // In real-world scenarios, PSR-7 implementations provide parsed body $parts = []; $boundary = $this->extractBoundary($body); @@ -27,11 +24,10 @@ public function parse(string $body): array return $parts; } - // Split by boundary $sections = explode('--' . $boundary, $body); foreach ($sections as $section) { - if (empty(trim($section)) || '--' === trim($section)) { + if ('' === trim($section) || '--' === trim($section)) { continue; } @@ -46,8 +42,6 @@ public function parse(string $body): array private function extractBoundary(string $body): ?string { - // Try to extract boundary from Content-Type header format - // This is a simplified extraction if (preg_match('/boundary="?([^"\s]+)"?/i', substr($body, 0, 500), $matches)) { return $matches[1]; } @@ -57,7 +51,6 @@ private function extractBoundary(string $body): ?string private function parsePart(string $section): ?array { - // Split headers from body $parts = explode("\r\n\r\n", $section, 2); if (2 !== count($parts)) { diff --git a/src/Validator/Request/CookieValidator.php b/src/Validator/Request/CookieValidator.php index af03a49..4262ae6 100644 --- a/src/Validator/Request/CookieValidator.php +++ b/src/Validator/Request/CookieValidator.php @@ -4,9 +4,13 @@ namespace Duyler\OpenApi\Validator\Request; +use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Validator\Exception\InvalidParameterException; +use Duyler\OpenApi\Validator\Exception\MissingParameterException; use Override; use function count; +use function is_string; readonly class CookieValidator extends AbstractParameterValidator { @@ -29,6 +33,87 @@ public function parseCookies(string $cookieHeader): array return $cookies; } + public function parseCookieStyle(string $cookieHeader, Parameter $parameter): array|string|null + { + $name = $parameter->name ?? ''; + $style = $parameter->style ?? 'form'; + $explode = $parameter->explode; + + if ('cookie' !== $style && 'form' !== $style) { + throw new InvalidParameterException( + $name, + "Cookie parameter style must be 'form' or 'cookie', got '{$style}'", + ); + } + + if ('' === trim($cookieHeader)) { + return null; + } + + if ($explode && $this->hasMultipleCookies($cookieHeader, $name)) { + return $this->parseExplodedValues($cookieHeader, $name); + } + + $cookies = $this->parseCookies($cookieHeader); + /** @var string|null $value */ + $value = $cookies[$name] ?? null; + + if (null === $value) { + return null; + } + + $decodedValue = $this->decodeValue($value); + + $schemaType = $parameter->schema?->type; + if ('array' === $schemaType && str_contains($decodedValue, ',')) { + return explode(',', $decodedValue); + } + + return $decodedValue; + } + + public function validateWithHeader(array $data, string $cookieHeader, array $parameterSchemas): void + { + foreach ($parameterSchemas as $param) { + if (false === $param instanceof Parameter) { + continue; + } + + if ('cookie' !== $param->in) { + continue; + } + + $name = $param->name; + if (null === $name) { + continue; + } + + if ('' !== trim($cookieHeader)) { + $value = $this->parseCookieStyle($cookieHeader, $param); + } else { + /** @var mixed $value */ + $value = $this->findParameter($data, $name); + if (is_string($value)) { + $value = $this->decodeValue($value); + } + } + + if (null === $value) { + if ($this->isRequired($param, $value)) { + throw new MissingParameterException('cookie', $name); + } + continue; + } + + $value = $this->deserializer->deserialize($value, $param); + $value = $this->coercer->coerce($value, $param, $this->coercion, $this->coercion); + + if (null !== $param->schema) { + $this->schemaValidator->validate($value, $param->schema); + } + } + } + #[Override] protected function getLocation(): string { @@ -40,4 +125,42 @@ protected function findParameter(array $data, string $name): mixed { return $data[$name] ?? null; } + + private function parseExplodedValues(string $cookieHeader, string $name): array + { + $values = []; + $pairs = explode(';', $cookieHeader); + + foreach ($pairs as $pair) { + $parts = explode('=', trim($pair), 2); + if (2 === count($parts) && $parts[0] === $name) { + $values[] = $this->decodeValue($parts[1]); + } + } + + return $values; + } + + private function decodeValue(string $value): string + { + return rawurldecode($value); + } + + private function hasMultipleCookies(string $cookieHeader, string $name): bool + { + $count = 0; + $pairs = explode(';', $cookieHeader); + + foreach ($pairs as $pair) { + $parts = explode('=', trim($pair), 2); + if (2 === count($parts) && $parts[0] === $name) { + ++$count; + if ($count > 1) { + return true; + } + } + } + + return false; + } } diff --git a/src/Validator/Request/ParameterDeserializer.php b/src/Validator/Request/ParameterDeserializer.php index 082c5fc..a2e7e3a 100644 --- a/src/Validator/Request/ParameterDeserializer.php +++ b/src/Validator/Request/ParameterDeserializer.php @@ -24,7 +24,6 @@ public function deserialize(mixed $value, Parameter $param): array|int|string|fl $style = $param->style ?? $this->getDefaultStyle($param->in); - // Arrays are only valid for form style if (is_array($normalized)) { return 'form' === $style ? $this->deserializeForm($normalized, $param->explode) @@ -39,6 +38,7 @@ public function deserialize(mixed $value, Parameter $param): array|int|string|fl 'form' => $this->deserializeForm($normalized, $param->explode), 'pipeDelimited' => $this->deserializePipeDelimited($normalized), 'spaceDelimited' => $this->deserializeSpaceDelimited($normalized), + 'cookie' => $this->deserializeCookie($normalized), default => $normalized, }; } @@ -56,7 +56,6 @@ private function getDefaultStyle(string $in): string private function deserializeMatrix(string $value, string $name): string { - // Remove the leading ;name= if present $prefix = ';' . $name . '='; if (str_starts_with($value, $prefix)) { return substr($value, strlen($prefix)); @@ -67,7 +66,6 @@ private function deserializeMatrix(string $value, string $name): string private function deserializeLabel(string $value): string { - // Remove the leading . if present if (str_starts_with($value, '.')) { return substr($value, 1); } @@ -77,8 +75,6 @@ private function deserializeLabel(string $value): string private function deserializeSimple(string $value): string { - // For path parameters with simple style, keep as is - // The simple style is used for path parameters and doesn't need deserialization return $value; } @@ -108,4 +104,9 @@ private function deserializeSpaceDelimited(string $value): array { return explode(' ', $value); } + + private function deserializeCookie(string $value): string + { + return $value; + } } diff --git a/src/Validator/Request/QueryParser.php b/src/Validator/Request/QueryParser.php index 4f14cb1..81266cf 100644 --- a/src/Validator/Request/QueryParser.php +++ b/src/Validator/Request/QueryParser.php @@ -4,6 +4,14 @@ namespace Duyler\OpenApi\Validator\Request; +use Duyler\OpenApi\Schema\Model\MediaType; +use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Validator\Exception\InvalidParameterException; +use Duyler\OpenApi\Validator\Exception\UnsupportedMediaTypeException; +use JsonException; + +use const JSON_THROW_ON_ERROR; + readonly class QueryParser { /** @@ -37,4 +45,45 @@ public function handleExplode(array $values, bool $explode): array|string return $values; } + + public function parseQueryString(string $rawQueryString, Parameter $parameter): mixed + { + if ('querystring' !== $parameter->in) { + return null; + } + + $content = $parameter->content; + if (null === $content) { + return null; + } + + foreach ($content->mediaTypes as $mediaType => $mediaTypeObject) { + return $this->parseByMediaType($rawQueryString, $mediaType, $mediaTypeObject, $parameter->name ?? 'unknown'); + } + + return null; + } + + private function parseByMediaType(string $raw, string $mediaType, MediaType $mediaTypeObject, string $parameterName): mixed + { + if ('application/json' === $mediaType) { + try { + $decoded = rawurldecode($raw); + return json_decode($decoded, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw InvalidParameterException::malformedValue( + $parameterName, + 'Invalid JSON: ' . $e->getMessage(), + 0, + $e, + ); + } + } + + if ('text/plain' === $mediaType) { + return $raw; + } + + throw new UnsupportedMediaTypeException($mediaType, ['application/json', 'text/plain']); + } } diff --git a/src/Validator/Request/QueryStringValidator.php b/src/Validator/Request/QueryStringValidator.php new file mode 100644 index 0000000..87c174a --- /dev/null +++ b/src/Validator/Request/QueryStringValidator.php @@ -0,0 +1,109 @@ +in) { + continue; + } + + $this->validateParameter($param); + $this->validateQueryStringParameter($queryString, $param); + } + } + + public function validateParameter(Parameter $parameter): void + { + $name = $parameter->name ?? 'unknown'; + + if (null !== $parameter->schema) { + throw new InvalidParameterException( + $name, + "Parameter with 'querystring' location must use 'content' field, not 'schema'", + ); + } + + if (null === $parameter->content || [] === $parameter->content->mediaTypes) { + throw new InvalidParameterException( + $name, + "Parameter with 'querystring' location requires 'content' field", + ); + } + } + + private function validateQueryStringParameter(string $queryString, Parameter $parameter): void + { + $name = $parameter->name ?? 'unknown'; + + if ('' === $queryString && $parameter->required) { + throw new MissingParameterException('querystring', $name); + } + + if ('' === $queryString) { + return; + } + + /** @var array|scalar|null $parsedValue */ + $parsedValue = $this->queryParser->parseQueryString($queryString, $parameter); + + if (null === $parsedValue && $parameter->required) { + throw new MissingParameterException('querystring', $name); + } + + if (null === $parsedValue) { + return; + } + + $this->validateValueAgainstSchema($parsedValue, $parameter); + } + + private function validateValueAgainstSchema(mixed $value, Parameter $parameter): void + { + $content = $parameter->content; + if (null === $content) { + return; + } + + foreach ($content->mediaTypes as $mediaTypeObject) { + if (null !== $mediaTypeObject->schema) { + assert( + is_array($value) + || is_int($value) + || is_string($value) + || is_float($value) + || is_bool($value) + || null === $value, + ); + $this->schemaValidator->validate($value, $mediaTypeObject->schema); + } + return; + } + } +} diff --git a/src/Validator/Request/RequestBodyValidator.php b/src/Validator/Request/RequestBodyValidator.php index 9e930e0..ddf200b 100644 --- a/src/Validator/Request/RequestBodyValidator.php +++ b/src/Validator/Request/RequestBodyValidator.php @@ -47,10 +47,8 @@ public function validate( throw new UnsupportedMediaTypeException($mediaType, array_keys($requestBody->content->mediaTypes)); } - // Parse body $parsedBody = $this->parseBody($body, $mediaType); - // Validate against schema if (null !== $content->schema) { $this->schemaValidator->validate($parsedBody, $content->schema); } diff --git a/src/Validator/Request/RequestValidator.php b/src/Validator/Request/RequestValidator.php index b161b16..9444d97 100644 --- a/src/Validator/Request/RequestValidator.php +++ b/src/Validator/Request/RequestValidator.php @@ -17,6 +17,7 @@ public function __construct( private readonly PathParametersValidator $pathParamsValidator, private readonly QueryParser $queryParser, private readonly QueryParametersValidator $queryParamsValidator, + private readonly QueryStringValidator $queryStringValidator, private readonly HeadersValidator $headersValidator, private readonly CookieValidator $cookieValidator, private readonly RequestBodyValidatorInterface $bodyValidator, @@ -42,6 +43,8 @@ public function validate( $queryParams = $this->queryParser->parse($queryString); $this->queryParamsValidator->validate($queryParams, $parameterSchemas); + $this->queryStringValidator->validate($queryString, $parameterSchemas); + $headers = $request->getHeaders(); $normalizedHeaders = []; foreach ($headers as $key => $value) { @@ -52,12 +55,12 @@ public function validate( $this->headersValidator->validate($normalizedHeaders, $parameterSchemas); $cookies = $request->getCookieParams(); + $cookieHeader = $request->getHeaderLine('Cookie'); if ([] === $cookies) { - $cookieHeader = $request->getHeaderLine('Cookie'); $cookies = $this->cookieValidator->parseCookies($cookieHeader); } /** @var array $cookies */ - $this->cookieValidator->validate($cookies, $parameterSchemas); + $this->cookieValidator->validateWithHeader($cookies, $cookieHeader, $parameterSchemas); $contentType = $request->getHeaderLine('Content-Type'); $body = (string) $request->getBody(); diff --git a/src/Validator/Response/ResponseBodyValidator.php b/src/Validator/Response/ResponseBodyValidator.php index d2395e2..8ee2387 100644 --- a/src/Validator/Response/ResponseBodyValidator.php +++ b/src/Validator/Response/ResponseBodyValidator.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Validator\Response; use Duyler\OpenApi\Schema\Model\Content; +use Duyler\OpenApi\Schema\Model\MediaType; use Duyler\OpenApi\Validator\Request\BodyParser\BodyParser; use Duyler\OpenApi\Validator\Request\ContentTypeNegotiator; use Duyler\OpenApi\Validator\SchemaValidator\SchemaValidatorInterface; @@ -17,6 +18,7 @@ public function __construct( private readonly BodyParser $bodyParser, private readonly ContentTypeNegotiator $negotiator, private readonly ResponseTypeCoercer $typeCoercer, + private readonly StreamingContentParser $streamingParser = new StreamingContentParser(), private readonly bool $coercion = false, ) {} @@ -36,6 +38,18 @@ public function validate( return; } + if (StreamingMediaTypeDetector::isStreaming($contentType)) { + $this->validateStreamingContent($body, $mediaTypeSchema, $contentType); + } else { + $this->validateRegularContent($body, $mediaTypeSchema, $mediaType); + } + } + + private function validateRegularContent( + string $body, + MediaType $mediaTypeSchema, + string $mediaType, + ): void { $parsedBody = $this->bodyParser->parse($body, $mediaType); if ($this->coercion && null !== $mediaTypeSchema->schema) { @@ -47,4 +61,25 @@ public function validate( $this->schemaValidator->validate($parsedBody, $mediaTypeSchema->schema); } } + + private function validateStreamingContent( + string $body, + MediaType $mediaType, + string $contentType, + ): void { + $schema = $mediaType->itemSchema ?? $mediaType->schema; + + if (null === $schema) { + return; + } + + $effectiveContentType = $mediaType->itemEncoding->contentType ?? $contentType; + $items = $this->streamingParser->parse($body, $effectiveContentType); + + foreach ($items as $item) { + if (null !== $item) { + $this->schemaValidator->validate($item, $schema); + } + } + } } diff --git a/src/Validator/Response/ResponseBodyValidatorWithContext.php b/src/Validator/Response/ResponseBodyValidatorWithContext.php index 76782da..4d42532 100644 --- a/src/Validator/Response/ResponseBodyValidatorWithContext.php +++ b/src/Validator/Response/ResponseBodyValidatorWithContext.php @@ -5,6 +5,7 @@ namespace Duyler\OpenApi\Validator\Response; use Duyler\OpenApi\Schema\Model\Content; +use Duyler\OpenApi\Schema\Model\MediaType; use Duyler\OpenApi\Schema\OpenApiDocument; use Duyler\OpenApi\Validator\EmptyArrayStrategy; use Duyler\OpenApi\Validator\Request\BodyParser\BodyParser; @@ -29,6 +30,7 @@ public function __construct( private readonly BodyParser $bodyParser, private readonly ContentTypeNegotiator $negotiator = new ContentTypeNegotiator(), private readonly ResponseTypeCoercer $typeCoercer = new ResponseTypeCoercer(), + private readonly StreamingContentParser $streamingParser = new StreamingContentParser(), private readonly bool $coercion = false, private readonly bool $nullableAsType = true, private readonly EmptyArrayStrategy $emptyArrayStrategy = EmptyArrayStrategy::AllowBoth, @@ -56,6 +58,18 @@ public function validate( return; } + if (StreamingMediaTypeDetector::isStreaming($contentType)) { + $this->validateStreamingContent($body, $mediaTypeSchema, $contentType); + } else { + $this->validateRegularContent($body, $mediaTypeSchema, $mediaType); + } + } + + private function validateRegularContent( + string $body, + MediaType $mediaTypeSchema, + string $mediaType, + ): void { $parsedBody = $this->bodyParser->parse($body, $mediaType); if ($this->coercion && null !== $mediaTypeSchema->schema) { @@ -76,4 +90,32 @@ public function validate( } } } + + private function validateStreamingContent( + string $body, + MediaType $mediaType, + string $contentType, + ): void { + $schema = $mediaType->itemSchema ?? $mediaType->schema; + + if (null === $schema) { + return; + } + + $effectiveContentType = $mediaType->itemEncoding->contentType ?? $contentType; + $items = $this->streamingParser->parse($body, $effectiveContentType); + + $context = ValidationContext::create($this->pool, $this->nullableAsType, $this->emptyArrayStrategy); + $hasDiscriminator = null !== $schema->discriminator || $this->refResolver->schemaHasDiscriminator($schema, $this->document); + + foreach ($items as $item) { + if (null !== $item) { + if ($hasDiscriminator) { + $this->contextSchemaValidator->validate($item, $schema); + } else { + $this->regularSchemaValidator->validate($item, $schema, $context); + } + } + } + } } diff --git a/src/Validator/Response/StreamingContentParser.php b/src/Validator/Response/StreamingContentParser.php new file mode 100644 index 0000000..dd744de --- /dev/null +++ b/src/Validator/Response/StreamingContentParser.php @@ -0,0 +1,170 @@ +|null> + */ + public function parse(string $body, string $contentType): array + { + return match (true) { + str_contains($contentType, 'application/jsonl'), + str_contains($contentType, 'application/x-ndjson') => $this->parseJsonLines($body), + str_contains($contentType, 'text/event-stream') => $this->parseServerSentEvents($body), + str_contains($contentType, 'application/json-seq') => $this->parseJsonSeq($body), + default => [], + }; + } + + /** + * Parse JSON Lines (NDJSON) format + * + * @return list|null> + */ + public function parseJsonLines(string $body): array + { + $lines = explode("\n", trim($body)); + /** @var list|null> $items */ + $items = []; + + foreach ($lines as $line) { + if ('' !== trim($line)) { + try { + /** @var array $decoded */ + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + $items[] = $decoded; + } catch (JsonException) { + $items[] = null; + } + } + } + + return $items; + } + + /** + * Parse Server-Sent Events format + * + * @return list|null> + */ + public function parseServerSentEvents(string $body): array + { + /** @var list|null> $events */ + $events = []; + $currentEvent = []; + + foreach (explode("\n", $body) as $line) { + if ('' === $line) { + if ([] !== $currentEvent) { + $events[] = $this->formatSseEvent($currentEvent); + $currentEvent = []; + } + continue; + } + + if (str_starts_with($line, ':')) { + continue; + } + + $colonPos = strpos($line, ':'); + if (false !== $colonPos) { + $field = substr($line, 0, $colonPos); + $value = ltrim(substr($line, $colonPos + 1)); + $currentEvent[$field] = $value; + } + } + + if ([] !== $currentEvent) { + $events[] = $this->formatSseEvent($currentEvent); + } + + return $events; + } + + /** + * Parse JSON Text Sequences (RFC 7464) + * + * @return list|null> + */ + public function parseJsonSeq(string $body): array + { + /** @var list|null> $items */ + $items = []; + $pos = 0; + $length = strlen($body); + + while ($pos < $length) { + if (substr($body, $pos, 1) === "\x1E") { + $pos++; + } + + $endPos = strpos($body, "\x1E", $pos); + if (false === $endPos) { + $endPos = $length; + } + + $json = substr($body, $pos, $endPos - $pos); + $json = trim($json); + + if ('' !== $json) { + try { + /** @var array $decoded */ + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + $items[] = $decoded; + } catch (JsonException) { + $items[] = null; + } + } + + $pos = $endPos; + } + + return $items; + } + + /** + * Format SSE event data + * + * @param array $event + * @return array + */ + private function formatSseEvent(array $event): array + { + $result = []; + + if (isset($event['event'])) { + $result['event'] = $event['event']; + } + if (isset($event['data'])) { + $dataValue = $event['data']; + try { + $decoded = json_decode($event['data'], true, 512, JSON_THROW_ON_ERROR); + assert(is_array($decoded) || is_null($decoded) || is_scalar($decoded)); + $dataValue = $decoded; + } catch (JsonException) { + } + $result['data'] = $dataValue; + } + if (isset($event['id'])) { + $result['id'] = $event['id']; + } + + return $result; + } +} diff --git a/src/Validator/Response/StreamingMediaTypeDetector.php b/src/Validator/Response/StreamingMediaTypeDetector.php new file mode 100644 index 0000000..fea7d59 --- /dev/null +++ b/src/Validator/Response/StreamingMediaTypeDetector.php @@ -0,0 +1,20 @@ + str_starts_with($mediaType, $type)); + } +} diff --git a/src/Validator/Schema/DiscriminatorValidator.php b/src/Validator/Schema/DiscriminatorValidator.php index ef60bf5..c81878c 100644 --- a/src/Validator/Schema/DiscriminatorValidator.php +++ b/src/Validator/Schema/DiscriminatorValidator.php @@ -16,6 +16,7 @@ use function array_key_exists; use function is_array; use function is_string; +use function assert; readonly class DiscriminatorValidator { @@ -36,7 +37,22 @@ public function validate( return; } + if (null !== $discriminator->propertyName) { + $this->validateWithPropertyName($data, $discriminator, $schema, $document, $dataPath); + } else { + $this->validateWithoutPropertyName($data, $discriminator, $document, $dataPath); + } + } + + private function validateWithPropertyName( + array|int|string|float|bool $data, + Discriminator $discriminator, + Schema $schema, + OpenApiDocument $document, + string $dataPath, + ): void { $propertyName = $discriminator->propertyName; + assert(null !== $propertyName); $value = $this->extractValue($data, $propertyName, $dataPath); $targetSchema = $this->resolveSchema($value, $discriminator, $schema, $document, $dataPath); @@ -44,6 +60,20 @@ public function validate( $this->validateAgainstSchema($data, $targetSchema, $document, $propertyPath); } + private function validateWithoutPropertyName( + array|int|string|float|bool $data, + Discriminator $discriminator, + OpenApiDocument $document, + string $dataPath, + ): void { + if (null === $discriminator->defaultMapping) { + return; + } + + $targetSchema = $this->refResolver->resolve($discriminator->defaultMapping, $document); + $this->validateAgainstSchema($data, $targetSchema, $document, $dataPath); + } + private function buildPath(string $basePath, string $segment): string { if ($basePath === '/') { @@ -98,11 +128,12 @@ private function resolveSchema( return $this->refResolver->resolve($mapping[$value], $document); } - return $this->findMatchingSchema($value, $schema, $document, $dataPath); + return $this->findMatchingSchema($value, $discriminator, $schema, $document, $dataPath); } private function findMatchingSchema( string $value, + Discriminator $discriminator, Schema $schema, OpenApiDocument $document, string $dataPath, @@ -123,6 +154,10 @@ private function findMatchingSchema( } } + if (null !== $discriminator->defaultMapping) { + return $this->refResolver->resolve($discriminator->defaultMapping, $document); + } + throw new UnknownDiscriminatorValueException( value: $value, schema: $schema, diff --git a/src/Validator/Schema/OneOfValidatorWithContext.php b/src/Validator/Schema/OneOfValidatorWithContext.php index ed01808..605c6b2 100644 --- a/src/Validator/Schema/OneOfValidatorWithContext.php +++ b/src/Validator/Schema/OneOfValidatorWithContext.php @@ -47,7 +47,7 @@ public function validateWithContext( private function validateWithDiscriminator(mixed $data, Schema $schema, ValidationContext $context): void { if (null === $data) { - assert($schema->oneOf !== null); + assert(null !== $schema->oneOf); if ($this->hasNullableSchema($schema->oneOf) && $context->nullableAsType) { return; } @@ -86,7 +86,7 @@ private function validateWithoutDiscriminator(mixed $data, array $oneOf, Validat $abstractErrors = []; foreach ($oneOf as $subSchema) { - if (!$subSchema instanceof Schema) { + if (false === $subSchema instanceof Schema) { continue; } diff --git a/src/Validator/Schema/RefResolver.php b/src/Validator/Schema/RefResolver.php index a8221c5..e704e2c 100644 --- a/src/Validator/Schema/RefResolver.php +++ b/src/Validator/Schema/RefResolver.php @@ -4,6 +4,7 @@ namespace Duyler\OpenApi\Validator\Schema; +use Duyler\OpenApi\Exception\RefResolutionException; use Duyler\OpenApi\Schema\Model\Parameter; use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Schema\Model\Schema; @@ -13,8 +14,10 @@ use WeakMap; use function array_key_exists; +use function dirname; use function is_array; use function is_object; +use function str_starts_with; final class RefResolver implements RefResolverInterface { @@ -25,6 +28,34 @@ public function __construct() $this->cache = new WeakMap(); } + #[Override] + public function getBaseUri(OpenApiDocument $document): ?string + { + return $document->self; + } + + #[Override] + public function resolveRelativeRef(string $ref, OpenApiDocument $document): string + { + $baseUri = $this->getBaseUri($document); + + if (null === $baseUri) { + throw new RefResolutionException( + "Cannot resolve relative reference '{$ref}' without document \$self or base URI", + ); + } + + return $this->combineUris($baseUri, $ref); + } + + #[Override] + public function combineUris(string $baseUri, string $relativeRef): string + { + $basePath = dirname($baseUri); + + return $basePath . '/' . $relativeRef; + } + #[Override] public function resolve(string $ref, OpenApiDocument $document): Schema { @@ -131,6 +162,147 @@ public function schemaHasDiscriminator(Schema $schema, OpenApiDocument $document return false; } + #[Override] + public function resolveSchemaWithOverride(Schema $schema, OpenApiDocument $document): Schema + { + if (null === $schema->ref) { + return $schema; + } + + $resolved = $this->resolve($schema->ref, $document); + + $description = $resolved->description; + if (null !== $schema->refDescription) { + $description = $schema->refDescription; + } + + $title = $resolved->title; + if (null !== $schema->refSummary) { + $title = $schema->refSummary; + } + + return new Schema( + ref: null, + refSummary: null, + refDescription: null, + format: $resolved->format, + title: $title, + description: $description, + default: $resolved->default, + deprecated: $resolved->deprecated, + type: $resolved->type, + nullable: $resolved->nullable, + const: $resolved->const, + multipleOf: $resolved->multipleOf, + maximum: $resolved->maximum, + exclusiveMaximum: $resolved->exclusiveMaximum, + minimum: $resolved->minimum, + exclusiveMinimum: $resolved->exclusiveMinimum, + maxLength: $resolved->maxLength, + minLength: $resolved->minLength, + pattern: $resolved->pattern, + maxItems: $resolved->maxItems, + minItems: $resolved->minItems, + uniqueItems: $resolved->uniqueItems, + maxProperties: $resolved->maxProperties, + minProperties: $resolved->minProperties, + required: $resolved->required, + allOf: $resolved->allOf, + anyOf: $resolved->anyOf, + oneOf: $resolved->oneOf, + not: $resolved->not, + discriminator: $resolved->discriminator, + properties: $resolved->properties, + additionalProperties: $resolved->additionalProperties, + unevaluatedProperties: $resolved->unevaluatedProperties, + items: $resolved->items, + prefixItems: $resolved->prefixItems, + contains: $resolved->contains, + minContains: $resolved->minContains, + maxContains: $resolved->maxContains, + patternProperties: $resolved->patternProperties, + propertyNames: $resolved->propertyNames, + dependentSchemas: $resolved->dependentSchemas, + if: $resolved->if, + then: $resolved->then, + else: $resolved->else, + unevaluatedItems: $resolved->unevaluatedItems, + example: $resolved->example, + examples: $resolved->examples, + enum: $resolved->enum, + contentEncoding: $resolved->contentEncoding, + contentMediaType: $resolved->contentMediaType, + contentSchema: $resolved->contentSchema, + jsonSchemaDialect: $resolved->jsonSchemaDialect, + xml: $resolved->xml, + ); + } + + #[Override] + public function resolveParameterWithOverride(Parameter $parameter, OpenApiDocument $document): Parameter + { + if (null === $parameter->ref) { + return $parameter; + } + + $resolved = $this->resolveParameter($parameter->ref, $document); + + $description = $resolved->description; + if (null !== $parameter->refDescription) { + $description = $parameter->refDescription; + } + + return new Parameter( + ref: null, + refSummary: null, + refDescription: null, + name: $resolved->name, + in: $resolved->in, + description: $description, + required: $resolved->required, + deprecated: $resolved->deprecated, + allowEmptyValue: $resolved->allowEmptyValue, + style: $resolved->style, + explode: $resolved->explode, + allowReserved: $resolved->allowReserved, + schema: $resolved->schema, + examples: $resolved->examples, + example: $resolved->example, + content: $resolved->content, + ); + } + + #[Override] + public function resolveResponseWithOverride(Response $response, OpenApiDocument $document): Response + { + if (null === $response->ref) { + return $response; + } + + $resolved = $this->resolveResponse($response->ref, $document); + + $summary = $resolved->summary; + if (null !== $response->refSummary) { + $summary = $response->refSummary; + } + + $description = $resolved->description; + if (null !== $response->refDescription) { + $description = $response->refDescription; + } + + return new Response( + ref: null, + refSummary: null, + refDescription: null, + summary: $summary, + description: $description, + headers: $resolved->headers, + content: $resolved->content, + links: $resolved->links, + ); + } + /** * @param array $visited */ diff --git a/src/Validator/Schema/RefResolverInterface.php b/src/Validator/Schema/RefResolverInterface.php index 5734cf1..2340197 100644 --- a/src/Validator/Schema/RefResolverInterface.php +++ b/src/Validator/Schema/RefResolverInterface.php @@ -4,6 +4,7 @@ namespace Duyler\OpenApi\Validator\Schema; +use Duyler\OpenApi\Exception\RefResolutionException; use Duyler\OpenApi\Schema\Model\Parameter; use Duyler\OpenApi\Schema\Model\Response; use Duyler\OpenApi\Schema\Model\Schema; @@ -50,4 +51,61 @@ public function resolveResponse(string $ref, OpenApiDocument $document): Respons * @return bool True if discriminator found, false otherwise */ public function schemaHasDiscriminator(Schema $schema, OpenApiDocument $document, array &$visited = []): bool; + + /** + * Get base URI from document's $self field + * + * @param OpenApiDocument $document Root document + * @return string|null Base URI or null if not set + */ + public function getBaseUri(OpenApiDocument $document): ?string; + + /** + * Resolve relative reference using document's $self as base URI + * + * @param string $ref Relative reference (e.g., 'schemas/user.yaml') + * @param OpenApiDocument $document Root document with $self field + * @return string Absolute URI + * @throws RefResolutionException If document has no $self field + */ + public function resolveRelativeRef(string $ref, OpenApiDocument $document): string; + + /** + * Combine base URI with relative reference + * + * @param string $baseUri Base URI (e.g., 'https://api.example.com/schemas/main.json') + * @param string $relativeRef Relative reference (e.g., 'schemas/user.yaml') + * @return string Combined absolute URI + */ + public function combineUris(string $baseUri, string $relativeRef): string; + + /** + * Resolve schema reference with summary/description override + * + * @param Schema $schema Schema with potential $ref and override values + * @param OpenApiDocument $document Root document + * @return Schema Resolved schema with overrides applied + * @throws Exception\UnresolvableRefException + */ + public function resolveSchemaWithOverride(Schema $schema, OpenApiDocument $document): Schema; + + /** + * Resolve parameter reference with summary/description override + * + * @param Parameter $parameter Parameter with potential $ref and override values + * @param OpenApiDocument $document Root document + * @return Parameter Resolved parameter with overrides applied + * @throws Exception\UnresolvableRefException + */ + public function resolveParameterWithOverride(Parameter $parameter, OpenApiDocument $document): Parameter; + + /** + * Resolve response reference with summary/description override + * + * @param Response $response Response with potential $ref and override values + * @param OpenApiDocument $document Root document + * @return Response Resolved response with overrides applied + * @throws Exception\UnresolvableRefException + */ + public function resolveResponseWithOverride(Response $response, OpenApiDocument $document): Response; } diff --git a/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php b/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php index c7dbd18..4336c28 100644 --- a/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php +++ b/src/Validator/SchemaValidator/AdditionalPropertiesValidator.php @@ -39,18 +39,16 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex ); } - if (true === $schema->additionalProperties) { - return; - } - - $nullableAsType = $context?->nullableAsType ?? true; - $validator = new SchemaValidator($this->pool); - - foreach ($additionalKeys as $key) { - /** @var array-key|array $value */ - $value = $data[$key]; - $keyContext = $context?->withBreadcrumb((string) $key) ?? ValidationContext::create($this->pool, $nullableAsType); - $validator->validate($value, $schema->additionalProperties, $keyContext); + if ($schema->additionalProperties instanceof Schema) { + $nullableAsType = $context?->nullableAsType ?? true; + $validator = new SchemaValidator($this->pool); + + foreach ($additionalKeys as $key) { + /** @var array-key|array $value */ + $value = $data[$key]; + $keyContext = $context?->withBreadcrumb((string) $key) ?? ValidationContext::create($this->pool, $nullableAsType); + $validator->validate($value, $schema->additionalProperties, $keyContext); + } } } } diff --git a/src/Validator/SchemaValidator/ArrayLengthValidator.php b/src/Validator/SchemaValidator/ArrayLengthValidator.php index fe04d0e..b6d35bc 100644 --- a/src/Validator/SchemaValidator/ArrayLengthValidator.php +++ b/src/Validator/SchemaValidator/ArrayLengthValidator.php @@ -39,7 +39,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex maxErrorFactory: static fn(int $max, int $actual) => new MaxItemsError($max, $actual, $dataPath, '/maxItems'), ); - if (true === $schema->uniqueItems) { + if ($schema->uniqueItems) { $unique = array_unique($data, SORT_REGULAR); if (count($unique) !== $count) { diff --git a/src/Validator/SchemaValidator/IfThenElseValidator.php b/src/Validator/SchemaValidator/IfThenElseValidator.php index be602de..45db48e 100644 --- a/src/Validator/SchemaValidator/IfThenElseValidator.php +++ b/src/Validator/SchemaValidator/IfThenElseValidator.php @@ -32,7 +32,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex $ifValid = false; } - if (true === $ifValid && null !== $schema->then) { + if ($ifValid && null !== $schema->then) { $validator = new SchemaValidator($this->pool); $validator->validate($normalizedData, $schema->then, $context); return; diff --git a/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php b/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php index db428fa..e97afdc 100644 --- a/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php +++ b/src/Validator/SchemaValidator/UnevaluatedPropertiesValidator.php @@ -31,7 +31,7 @@ public function validate(mixed $data, Schema $schema, ?ValidationContext $contex /** @var array $stringUnevaluatedProperties */ $stringUnevaluatedProperties = array_filter($unevaluatedProperties, is_string(...)); - if (true === $schema->unevaluatedProperties) { + if ($schema->unevaluatedProperties) { return; } diff --git a/tests/Functional/Response/StreamingResponseValidationTest.php b/tests/Functional/Response/StreamingResponseValidationTest.php new file mode 100644 index 0000000..fb2703e --- /dev/null +++ b/tests/Functional/Response/StreamingResponseValidationTest.php @@ -0,0 +1,250 @@ +psrFactory = new Psr17Factory(); + } + + #[Test] + public function jsonl_valid_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/logs'); + $operation = $validator->validateRequest($request); + + $body = '{"timestamp":"2024-01-01T00:00:00Z","level":"info","message":"Test message"}' . "\n" + . '{"timestamp":"2024-01-01T00:00:01Z","level":"error","message":"Error occurred"}'; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function jsonl_with_charset_valid_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/logs'); + $operation = $validator->validateRequest($request); + + $body = '{"timestamp":"2024-01-01T00:00:00Z","level":"debug","message":"Debug message"}'; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl; charset=utf-8') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function ndjson_valid_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/ndjson'); + $operation = $validator->validateRequest($request); + + $body = '{"name":"item1","count":10}' . "\n" + . '{"name":"item2","count":20}'; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/x-ndjson') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function sse_valid_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/events'); + $operation = $validator->validateRequest($request); + + $body = "event: message\n" + . "data: {\"message\":\"hello\",\"count\":1}\n\n" + . "event: update\n" + . "data: {\"message\":\"world\",\"count\":2}\n"; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function sse_with_id_valid_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/events'); + $operation = $validator->validateRequest($request); + + $body = "id: 123\n" + . "event: message\n" + . "data: {\"message\":\"test\",\"count\":1}\n\n"; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function sse_ignores_comments(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/events'); + $operation = $validator->validateRequest($request); + + $body = ": this is a comment\n" + . "event: message\n" + . "data: {\"message\":\"hello\",\"count\":1}\n\n"; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function json_seq_valid_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/records'); + $operation = $validator->validateRequest($request); + + $body = "\x1E" . '{"id":"1","value":"first"}' . "\x1E" . '{"id":"2","value":"second"}'; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json-seq') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function jsonl_empty_lines_ignored(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/logs'); + $operation = $validator->validateRequest($request); + + $body = '{"timestamp":"2024-01-01T00:00:00Z","level":"info","message":"Test"}' . "\n\n" + . '{"timestamp":"2024-01-01T00:00:01Z","level":"warn","message":"Warning"}'; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_uses_schema_when_item_schema_not_defined(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../../fixtures/response-validation-specs/streaming.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/stream-with-schema'); + $operation = $validator->validateRequest($request); + + $body = '{"fallback":true}'; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function non_streaming_content_type_unchanged(): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/regular'); + $operation = $validator->validateRequest($request); + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream(json_encode([ + 'message' => 'Hello', + ]))); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/Integration/EdgeCasesV32Test.php b/tests/Integration/EdgeCasesV32Test.php new file mode 100644 index 0000000..d42ed7b --- /dev/null +++ b/tests/Integration/EdgeCasesV32Test.php @@ -0,0 +1,344 @@ +psrFactory = new Psr17Factory(); + } + + #[Test] + public function rejects_invalid_version(): void + { + $parser = new YamlParser(); + + $yaml = <<expectException(InvalidSchemaException::class); + + $parser->parse($yaml); + } + + #[Test] + public function rejects_unsupported_major_version(): void + { + $parser = new YamlParser(); + + $yaml = <<expectException(InvalidSchemaException::class); + + $parser->parse($yaml); + } + + #[Test] + public function handles_empty_additional_operations(): void + { + $parser = new YamlParser(); + + $yaml = <<parse($yaml); + + $testPath = $document->paths?->paths['/test'] ?? null; + self::assertNotNull($testPath); + self::assertSame([], $testPath->additionalOperations); + } + + #[Test] + public function handles_empty_streaming_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/streaming-events.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/logs'); + $operation = $validator->validateRequest($request); + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl') + ->withBody($this->psrFactory->createStream('')); + + $validator->validateResponse($response, $operation); + + self::expectNotToPerformAssertions(); + } + + #[Test] + public function invalid_self_uri_rejected(): void + { + $parser = new YamlParser(); + + $yaml = <<expectException(InvalidSchemaException::class); + + $parser->parse($yaml); + } + + #[Test] + public function accepts_valid_self_uri(): void + { + $parser = new YamlParser(); + + $yaml = <<parse($yaml); + + self::assertSame('https://api.example.com/openapi.json', $document->self); + } + + #[Test] + public function handles_missing_optional_fields(): void + { + $parser = new YamlParser(); + + $yaml = <<parse($yaml); + + self::assertNull($document->servers); + self::assertNull($document->components); + self::assertNull($document->tags); + self::assertNull($document->security); + self::assertNull($document->externalDocs); + self::assertNull($document->self); + self::assertNull($document->jsonSchemaDialect); + self::assertNull($document->webhooks); + } + + #[Test] + public function handles_missing_info_fields(): void + { + $parser = new YamlParser(); + + $yaml = <<parse($yaml); + + self::assertNull($document->info->description); + self::assertNull($document->info->termsOfService); + self::assertNull($document->info->contact); + self::assertNull($document->info->license); + } + + #[Test] + public function info_requires_title_and_version(): void + { + $parser = new YamlParser(); + + $yaml = <<expectException(InvalidSchemaException::class); + + $parser->parse($yaml); + } + + #[Test] + public function parameter_requires_name_and_in(): void + { + $parser = new YamlParser(); + + $yaml = <<expectException(InvalidSchemaException::class); + + $parser->parse($yaml); + } + + #[Test] + public function handles_multiple_streaming_items(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/streaming-events.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/logs'); + $operation = $validator->validateRequest($request); + + $body = ''; + for ($i = 0; $i < 100; $i++) { + $body .= sprintf('{"level":"info","message":"Message %d"}', $i) . "\n"; + } + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + + self::expectNotToPerformAssertions(); + } + + #[Test] + public function handles_special_characters_in_jsonl(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/streaming-events.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/logs'); + $operation = $validator->validateRequest($request); + + $body = '{"level":"info","message":"Test with unicode: \u0440\u0443\u0441\u0441\u043a\u0438\u0439"}'; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + + self::expectNotToPerformAssertions(); + } + + #[Test] + public function handles_nested_discriminator_mapping(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + $schemas = $document->components?->schemas; + $userSchema = $schemas['User'] ?? null; + self::assertNotNull($userSchema); + self::assertNotNull($userSchema->discriminator); + self::assertSame('type', $userSchema->discriminator->propertyName); + + $adminSchema = $schemas['AdminUser'] ?? null; + self::assertNotNull($adminSchema); + self::assertNotNull($adminSchema->allOf); + } + + #[Test] + public function handles_empty_required_array(): void + { + $parser = new YamlParser(); + + $yaml = <<parse($yaml); + + self::assertNotNull($document->paths); + $response = $document->paths->paths['/test']->get?->responses?->responses['200']; + self::assertNotNull($response); + $schema = $response->content?->mediaTypes['application/json']->schema; + self::assertNotNull($schema); + self::assertSame([], $schema->required); + } + + #[Test] + public function handles_complex_query_request_body(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/query-method.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('QUERY', '/search') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream(json_encode([ + 'query' => 'complex search', + 'filters' => ['active', 'verified', 'premium'], + ]))); + + $operation = $validator->validateRequest($request); + + self::assertSame('/search', $operation->path); + } +} diff --git a/tests/Integration/OpenApiV32Test.php b/tests/Integration/OpenApiV32Test.php new file mode 100644 index 0000000..9cca740 --- /dev/null +++ b/tests/Integration/OpenApiV32Test.php @@ -0,0 +1,302 @@ +psrFactory = new Psr17Factory(); + } + + #[Test] + public function parses_full_v3_2_spec(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + self::assertSame('3.2.0', $document->openapi); + self::assertSame('https://api.example.com/openapi.json', $document->self); + self::assertNotNull($document->servers); + self::assertSame('production', $document->servers->servers[0]->name); + } + + #[Test] + public function validates_query_method(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/query-method.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('QUERY', '/search') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream(json_encode([ + 'query' => 'test', + 'filters' => ['active'], + ]))); + + $operation = $validator->validateRequest($request); + + self::assertSame('/search', $operation->path); + self::assertSame('QUERY', $operation->method); + } + + #[Test] + public function validates_streaming_sse_response(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/streaming-events.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/events'); + $operation = $validator->validateRequest($request); + + $body = "event: message\ndata: hello world\n\n"; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + + self::expectNotToPerformAssertions(); + } + + #[Test] + public function validates_jsonl_streaming(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/streaming-events.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/logs'); + $operation = $validator->validateRequest($request); + + $body = "{\"level\":\"info\",\"message\":\"Test\"}\n{\"level\":\"error\",\"message\":\"Error\"}"; + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/jsonl') + ->withBody($this->psrFactory->createStream($body)); + + $validator->validateResponse($response, $operation); + + self::expectNotToPerformAssertions(); + } + + #[Test] + public function validates_additional_operations(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/full-spec.yaml') + ->build(); + + $copyRequest = $this->psrFactory->createServerRequest('COPY', '/resource'); + $moveRequest = $this->psrFactory->createServerRequest('MOVE', '/resource'); + + $copyOperation = $validator->validateRequest($copyRequest); + $moveOperation = $validator->validateRequest($moveRequest); + + self::assertSame('COPY', $copyOperation->method); + self::assertSame('MOVE', $moveOperation->method); + } + + #[Test] + public function backward_compatible_with_v3_1(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/petstore.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + self::assertSame('3.1.0', $document->openapi); + self::assertNotNull($document->paths); + } + + #[Test] + public function backward_compatible_with_v3_0(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.0/simple-api.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + self::assertSame('3.0.3', $document->openapi); + self::assertNotNull($document->paths); + } + + #[Test] + public function validates_querystring_parameter(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/full-spec.yaml') + ->build(); + + $filter = ['query' => 'test', 'limit' => 10]; + $encodedFilter = rawurlencode(json_encode($filter)); + + $request = $this->psrFactory->createServerRequest('GET', '/search?' . $encodedFilter); + $operation = $validator->validateRequest($request); + + self::assertSame('/search', $operation->path); + self::assertSame('GET', $operation->method); + } + + #[Test] + public function parses_discriminator_with_default_mapping(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + $schemas = $document->components?->schemas; + $userSchema = $schemas['User'] ?? null; + self::assertNotNull($userSchema); + self::assertNotNull($userSchema->discriminator); + self::assertSame('type', $userSchema->discriminator->propertyName); + self::assertSame('#/components/schemas/User', $userSchema->discriminator->defaultMapping); + self::assertSame(['admin' => '#/components/schemas/AdminUser'], $userSchema->discriminator->mapping); + } + + #[Test] + public function validates_response_with_discriminator_default_mapping(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlFile(__DIR__ . '/../fixtures/v3.2/full-spec.yaml') + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', '/users'); + $operation = $validator->validateRequest($request); + + $response = $this->psrFactory->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->psrFactory->createStream(json_encode([ + ['id' => 1, 'name' => 'Test', 'type' => 'unknown'], + ]))); + + $validator->validateResponse($response, $operation); + + self::expectNotToPerformAssertions(); + } + + #[Test] + public function parses_server_name(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + self::assertNotNull($document->servers); + self::assertCount(1, $document->servers->servers); + self::assertSame('production', $document->servers->servers[0]->name); + self::assertSame('https://api.example.com', $document->servers->servers[0]->url); + } + + #[Test] + public function parses_tags_with_parent_and_kind(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + self::assertNotNull($document->tags); + self::assertCount(3, $document->tags->tags); + + $operationsTag = array_find($document->tags->tags, fn($tag) => $tag->name === 'Operations'); + + self::assertNotNull($operationsTag); + self::assertSame('Admin', $operationsTag->parent); + self::assertNull($operationsTag->kind); + + $usersTag = array_find($document->tags->tags, fn($tag) => $tag->name === 'Users'); + + self::assertNotNull($usersTag); + self::assertSame('nav', $usersTag->kind); + } + + #[Test] + public function parses_oauth2_device_code_flow(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + $securitySchemes = $document->components?->securitySchemes; + $oauth2Scheme = $securitySchemes['oauth2'] ?? null; + self::assertNotNull($oauth2Scheme); + self::assertSame('https://auth.example.com/.well-known/oauth-authorization-server', $oauth2Scheme->oauth2MetadataUrl); + + $deviceCodeFlow = $oauth2Scheme->flows?->deviceCode; + self::assertNotNull($deviceCodeFlow); + self::assertSame('https://auth.example.com/token', $deviceCodeFlow->tokenUrl); + self::assertSame('https://auth.example.com/device/code', $deviceCodeFlow->deviceAuthorizationUrl); + } + + #[Test] + public function parses_media_types_component(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + $mediaTypes = $document->components?->mediaTypes; + self::assertNotNull($mediaTypes); + self::assertArrayHasKey('ProblemJson', $mediaTypes); + } + + #[Test] + public function parses_path_item_summary(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + $usersPath = $document->paths?->paths['/users'] ?? null; + self::assertNotNull($usersPath); + self::assertSame('User collection', $usersPath->summary); + } + + #[Test] + public function parses_response_summary(): void + { + $parser = new YamlParser(); + $content = file_get_contents(__DIR__ . '/../fixtures/v3.2/full-spec.yaml'); + assert($content !== false); + + $document = $parser->parse($content); + + $usersPath = $document->paths?->paths['/users'] ?? null; + self::assertNotNull($usersPath); + $getResponse = $usersPath->get?->responses?->responses['200'] ?? null; + self::assertNotNull($getResponse); + self::assertSame('Successful response', $getResponse->summary); + } +} diff --git a/tests/Schema/Model/ComponentsTest.php b/tests/Schema/Model/ComponentsTest.php index 0d458ce..c6e27d5 100644 --- a/tests/Schema/Model/ComponentsTest.php +++ b/tests/Schema/Model/ComponentsTest.php @@ -493,4 +493,56 @@ public function json_serialize_includes_responses_when_not_null(): void self::assertArrayNotHasKey('schemas', $serialized); self::assertSame(['TestResponse' => $response], $serialized['responses']); } + + #[Test] + public function components_has_media_types(): void + { + $components = new Components( + mediaTypes: [ + 'ProblemJson' => new MediaType( + schema: new Schema(type: 'object'), + ), + ], + ); + + self::assertNotNull($components->mediaTypes); + self::assertArrayHasKey('ProblemJson', $components->mediaTypes); + } + + #[Test] + public function json_serialize_includes_media_types(): void + { + $mediaType = new MediaType( + schema: new Schema(type: 'object'), + ); + + $components = new Components( + mediaTypes: ['ProblemJson' => $mediaType], + ); + + $serialized = $components->jsonSerialize(); + + self::assertArrayHasKey('mediaTypes', $serialized); + self::assertSame(['ProblemJson' => $mediaType], $serialized['mediaTypes']); + } + + #[Test] + public function json_serialize_includes_media_types_with_all_fields(): void + { + $schema = new Schema(type: 'object'); + $mediaType = new MediaType( + schema: $schema, + example: new Example(value: ['type' => 'about:blank']), + ); + + $components = new Components( + schemas: ['Problem' => $schema], + mediaTypes: ['ProblemJson' => $mediaType], + ); + + $serialized = $components->jsonSerialize(); + + self::assertArrayHasKey('schemas', $serialized); + self::assertArrayHasKey('mediaTypes', $serialized); + } } diff --git a/tests/Schema/Model/DiscriminatorTest.php b/tests/Schema/Model/DiscriminatorTest.php index 62d20ea..8d8836c 100644 --- a/tests/Schema/Model/DiscriminatorTest.php +++ b/tests/Schema/Model/DiscriminatorTest.php @@ -66,4 +66,103 @@ public function json_serialize_excludes_null_mapping(): void self::assertArrayHasKey('propertyName', $serialized); self::assertArrayNotHasKey('mapping', $serialized); } + + #[Test] + public function discriminator_has_default_mapping(): void + { + $discriminator = new Discriminator( + propertyName: 'type', + mapping: ['dog' => '#/components/schemas/Dog'], + defaultMapping: '#/components/schemas/Pet', + ); + + self::assertSame('#/components/schemas/Pet', $discriminator->defaultMapping); + } + + #[Test] + public function json_serialize_includes_default_mapping(): void + { + $discriminator = new Discriminator( + propertyName: 'type', + mapping: ['dog' => '#/components/schemas/Dog'], + defaultMapping: '#/components/schemas/Pet', + ); + + $serialized = $discriminator->jsonSerialize(); + + self::assertArrayHasKey('defaultMapping', $serialized); + self::assertSame('#/components/schemas/Pet', $serialized['defaultMapping']); + } + + #[Test] + public function json_serialize_excludes_null_default_mapping(): void + { + $discriminator = new Discriminator( + propertyName: 'type', + mapping: null, + defaultMapping: null, + ); + + $serialized = $discriminator->jsonSerialize(); + + self::assertArrayNotHasKey('defaultMapping', $serialized); + } + + #[Test] + public function can_create_discriminator_without_property_name(): void + { + $discriminator = new Discriminator( + defaultMapping: '#/components/schemas/Fallback', + ); + + self::assertNull($discriminator->propertyName); + self::assertSame('#/components/schemas/Fallback', $discriminator->defaultMapping); + } + + #[Test] + public function can_create_discriminator_with_only_default_mapping(): void + { + $discriminator = new Discriminator( + propertyName: null, + mapping: null, + defaultMapping: '#/components/schemas/Fallback', + ); + + self::assertNull($discriminator->propertyName); + self::assertNull($discriminator->mapping); + self::assertSame('#/components/schemas/Fallback', $discriminator->defaultMapping); + } + + #[Test] + public function json_serialize_excludes_null_property_name(): void + { + $discriminator = new Discriminator( + defaultMapping: '#/components/schemas/Fallback', + ); + + $serialized = $discriminator->jsonSerialize(); + + self::assertArrayNotHasKey('propertyName', $serialized); + self::assertArrayHasKey('defaultMapping', $serialized); + } + + #[Test] + public function can_create_empty_discriminator(): void + { + $discriminator = new Discriminator(); + + self::assertNull($discriminator->propertyName); + self::assertNull($discriminator->mapping); + self::assertNull($discriminator->defaultMapping); + } + + #[Test] + public function json_serialize_empty_discriminator_returns_empty_array(): void + { + $discriminator = new Discriminator(); + + $serialized = $discriminator->jsonSerialize(); + + self::assertSame([], $serialized); + } } diff --git a/tests/Schema/Model/EncodingTest.php b/tests/Schema/Model/EncodingTest.php new file mode 100644 index 0000000..25a2263 --- /dev/null +++ b/tests/Schema/Model/EncodingTest.php @@ -0,0 +1,226 @@ + new Header(description: 'Custom header')]); + $encoding = new Encoding( + contentType: 'application/json', + headers: $headers, + style: 'form', + explode: true, + allowReserved: false, + ); + + self::assertSame('application/json', $encoding->contentType); + self::assertInstanceOf(Headers::class, $encoding->headers); + self::assertSame('form', $encoding->style); + self::assertTrue($encoding->explode); + self::assertFalse($encoding->allowReserved); + } + + #[Test] + public function can_create_encoding_with_null_fields(): void + { + $encoding = new Encoding(); + + self::assertNull($encoding->contentType); + self::assertNull($encoding->headers); + self::assertNull($encoding->style); + self::assertNull($encoding->explode); + self::assertNull($encoding->allowReserved); + self::assertNull($encoding->encoding); + self::assertNull($encoding->prefixEncoding); + self::assertNull($encoding->itemEncoding); + } + + #[Test] + public function json_serialize_includes_all_fields(): void + { + $encoding = new Encoding( + contentType: 'application/json', + style: 'form', + explode: true, + ); + + $serialized = $encoding->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('contentType', $serialized); + self::assertArrayHasKey('style', $serialized); + self::assertArrayHasKey('explode', $serialized); + } + + #[Test] + public function json_serialize_excludes_null_fields(): void + { + $encoding = new Encoding(); + + $serialized = $encoding->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertEmpty($serialized); + } + + #[Test] + public function supports_nested_encoding(): void + { + $nestedEncoding = new Encoding( + contentType: 'text/plain', + ); + + $encoding = new Encoding( + contentType: 'multipart/form-data', + encoding: ['field1' => $nestedEncoding], + ); + + self::assertNotNull($encoding->encoding); + self::assertArrayHasKey('field1', $encoding->encoding); + self::assertSame('text/plain', $encoding->encoding['field1']->contentType); + } + + #[Test] + public function supports_prefix_encoding(): void + { + $prefixEncoding1 = new Encoding(contentType: 'application/json'); + $prefixEncoding2 = new Encoding(contentType: 'text/plain'); + + $encoding = new Encoding( + contentType: 'multipart/mixed', + prefixEncoding: [$prefixEncoding1, $prefixEncoding2], + ); + + self::assertNotNull($encoding->prefixEncoding); + self::assertCount(2, $encoding->prefixEncoding); + self::assertSame('application/json', $encoding->prefixEncoding[0]->contentType); + self::assertSame('text/plain', $encoding->prefixEncoding[1]->contentType); + } + + #[Test] + public function supports_item_encoding(): void + { + $itemEncoding = new Encoding( + contentType: 'application/json', + ); + + $encoding = new Encoding( + contentType: 'application/jsonl', + itemEncoding: $itemEncoding, + ); + + self::assertNotNull($encoding->itemEncoding); + self::assertSame('application/json', $encoding->itemEncoding->contentType); + } + + #[Test] + public function json_serialize_includes_nested_encoding(): void + { + $nestedEncoding = new Encoding(contentType: 'text/plain'); + $encoding = new Encoding( + contentType: 'multipart/form-data', + encoding: ['field1' => $nestedEncoding], + ); + + $serialized = $encoding->jsonSerialize(); + + self::assertArrayHasKey('encoding', $serialized); + self::assertArrayHasKey('field1', $serialized['encoding']); + } + + #[Test] + public function json_serialize_includes_prefix_encoding(): void + { + $encoding = new Encoding( + contentType: 'multipart/mixed', + prefixEncoding: [new Encoding(contentType: 'application/json')], + ); + + $serialized = $encoding->jsonSerialize(); + + self::assertArrayHasKey('prefixEncoding', $serialized); + } + + #[Test] + public function json_serialize_includes_item_encoding(): void + { + $encoding = new Encoding( + contentType: 'application/jsonl', + itemEncoding: new Encoding(contentType: 'application/json'), + ); + + $serialized = $encoding->jsonSerialize(); + + self::assertArrayHasKey('itemEncoding', $serialized); + } + + #[Test] + public function json_serialize_includes_headers(): void + { + $headers = new Headers(['X-Custom' => new Header(description: 'Test')]); + $encoding = new Encoding( + contentType: 'application/json', + headers: $headers, + ); + + $serialized = $encoding->jsonSerialize(); + + self::assertArrayHasKey('headers', $serialized); + } + + #[Test] + public function json_serialize_includes_allow_reserved(): void + { + $encoding = new Encoding( + contentType: 'text/plain', + allowReserved: true, + ); + + $serialized = $encoding->jsonSerialize(); + + self::assertArrayHasKey('allowReserved', $serialized); + self::assertTrue($serialized['allowReserved']); + } + + #[Test] + public function json_serialize_with_all_fields(): void + { + $nestedEncoding = new Encoding(contentType: 'text/plain'); + $headers = new Headers(['X-Test' => new Header(description: 'Test')]); + + $encoding = new Encoding( + contentType: 'multipart/form-data', + headers: $headers, + style: 'form', + explode: true, + allowReserved: false, + encoding: ['field1' => $nestedEncoding], + prefixEncoding: [new Encoding(contentType: 'application/json')], + itemEncoding: new Encoding(contentType: 'application/json'), + ); + + $serialized = $encoding->jsonSerialize(); + + self::assertArrayHasKey('contentType', $serialized); + self::assertArrayHasKey('headers', $serialized); + self::assertArrayHasKey('style', $serialized); + self::assertArrayHasKey('explode', $serialized); + self::assertArrayHasKey('allowReserved', $serialized); + self::assertArrayHasKey('encoding', $serialized); + self::assertArrayHasKey('prefixEncoding', $serialized); + self::assertArrayHasKey('itemEncoding', $serialized); + } +} diff --git a/tests/Schema/Model/ExampleTest.php b/tests/Schema/Model/ExampleTest.php index 3c09f0f..f189318 100644 --- a/tests/Schema/Model/ExampleTest.php +++ b/tests/Schema/Model/ExampleTest.php @@ -19,13 +19,19 @@ public function can_create_example_with_all_fields(): void summary: 'Example', description: 'Description', value: ['test' => 'value'], + dataValue: ['decoded' => 'data'], + serializedValue: 'SGVsbG8gV29ybGQ=', externalValue: 'https://example.com/example', + serializedExample: 'https://example.com/serialized', ); self::assertSame('Example', $example->summary); self::assertSame('Description', $example->description); self::assertSame(['test' => 'value'], $example->value); + self::assertSame(['decoded' => 'data'], $example->dataValue); + self::assertSame('SGVsbG8gV29ybGQ=', $example->serializedValue); self::assertSame('https://example.com/example', $example->externalValue); + self::assertSame('https://example.com/serialized', $example->serializedExample); } #[Test] @@ -35,13 +41,19 @@ public function can_create_example_with_null_fields(): void summary: null, description: null, value: null, + dataValue: null, + serializedValue: null, externalValue: null, + serializedExample: null, ); self::assertNull($example->summary); self::assertNull($example->description); self::assertNull($example->value); + self::assertNull($example->dataValue); + self::assertNull($example->serializedValue); self::assertNull($example->externalValue); + self::assertNull($example->serializedExample); } #[Test] @@ -51,7 +63,10 @@ public function json_serialize_includes_all_fields(): void summary: 'Example', description: 'Description', value: ['test' => 'value'], + dataValue: ['decoded' => 'data'], + serializedValue: 'SGVsbG8gV29ybGQ=', externalValue: null, + serializedExample: null, ); $serialized = $example->jsonSerialize(); @@ -60,6 +75,8 @@ public function json_serialize_includes_all_fields(): void self::assertArrayHasKey('summary', $serialized); self::assertArrayHasKey('description', $serialized); self::assertArrayHasKey('value', $serialized); + self::assertArrayHasKey('dataValue', $serialized); + self::assertArrayHasKey('serializedValue', $serialized); self::assertSame('Example', $serialized['summary']); } @@ -70,7 +87,10 @@ public function json_serialize_excludes_null_fields(): void summary: null, description: null, value: null, + dataValue: null, + serializedValue: null, externalValue: null, + serializedExample: null, ); $serialized = $example->jsonSerialize(); @@ -79,6 +99,9 @@ public function json_serialize_excludes_null_fields(): void self::assertArrayNotHasKey('summary', $serialized); self::assertArrayNotHasKey('description', $serialized); self::assertArrayNotHasKey('value', $serialized); + self::assertArrayNotHasKey('dataValue', $serialized); + self::assertArrayNotHasKey('serializedValue', $serialized); + self::assertArrayNotHasKey('serializedExample', $serialized); } #[Test] @@ -88,7 +111,10 @@ public function json_serialize_includes_all_optional_fields(): void summary: 'Example', description: 'Description', value: ['test' => 'value'], + dataValue: ['key' => 'value'], + serializedValue: 'encoded', externalValue: null, + serializedExample: null, ); $serialized = $example->jsonSerialize(); @@ -97,6 +123,8 @@ public function json_serialize_includes_all_optional_fields(): void self::assertArrayHasKey('summary', $serialized); self::assertArrayHasKey('description', $serialized); self::assertArrayHasKey('value', $serialized); + self::assertArrayHasKey('dataValue', $serialized); + self::assertArrayHasKey('serializedValue', $serialized); } #[Test] @@ -111,4 +139,50 @@ public function json_serialize_includes_externalValue(): void self::assertIsArray($serialized); self::assertArrayHasKey('externalValue', $serialized); } + + #[Test] + public function example_has_data_value(): void + { + $example = new Example( + summary: 'Test example', + dataValue: ['name' => 'John', 'age' => 30], + ); + + self::assertSame(['name' => 'John', 'age' => 30], $example->dataValue); + } + + #[Test] + public function example_has_serialized_value(): void + { + $example = new Example( + summary: 'Binary example', + serializedValue: 'SGVsbG8gV29ybGQ=', + ); + + self::assertSame('SGVsbG8gV29ybGQ=', $example->serializedValue); + } + + #[Test] + public function example_has_serialized_example(): void + { + $example = new Example( + summary: 'External serialized', + serializedExample: 'https://example.com/serialized.json', + ); + + self::assertSame('https://example.com/serialized.json', $example->serializedExample); + } + + #[Test] + public function json_serialize_includes_serialized_example(): void + { + $example = new Example( + serializedExample: 'https://example.com/serialized.json', + ); + + $serialized = $example->jsonSerialize(); + + self::assertArrayHasKey('serializedExample', $serialized); + self::assertSame('https://example.com/serialized.json', $serialized['serializedExample']); + } } diff --git a/tests/Schema/Model/MediaTypeTest.php b/tests/Schema/Model/MediaTypeTest.php index 6dd546b..0cd53ed 100644 --- a/tests/Schema/Model/MediaTypeTest.php +++ b/tests/Schema/Model/MediaTypeTest.php @@ -4,6 +4,7 @@ namespace Duyler\OpenApi\Test\Schema\Model; +use Duyler\OpenApi\Schema\Model\Encoding; use Duyler\OpenApi\Schema\Model\Example; use Duyler\OpenApi\Schema\Model\MediaType; use PHPUnit\Framework\Attributes\CoversClass; @@ -87,7 +88,6 @@ public function json_serialize_includes_all_optional_fields(): void $mediaType = new MediaType( schema: $schema, - encoding: 'utf-8', examples: ['example1' => ['test' => 'value']], example: null, ); @@ -96,7 +96,6 @@ public function json_serialize_includes_all_optional_fields(): void self::assertIsArray($serialized); self::assertArrayHasKey('schema', $serialized); - self::assertArrayHasKey('encoding', $serialized); self::assertArrayHasKey('examples', $serialized); } @@ -117,4 +116,123 @@ public function json_serialize_includes_example(): void self::assertIsArray($serialized); self::assertArrayHasKey('example', $serialized); } + + #[Test] + public function supports_item_schema_for_streaming(): void + { + $itemSchema = new Schema( + type: 'object', + properties: null, + ); + + $mediaType = new MediaType( + itemSchema: $itemSchema, + ); + + self::assertSame($itemSchema, $mediaType->itemSchema); + self::assertNull($mediaType->schema); + } + + #[Test] + public function supports_item_encoding_for_streaming(): void + { + $itemEncoding = new Encoding( + contentType: 'application/json', + ); + + $mediaType = new MediaType( + itemEncoding: $itemEncoding, + ); + + self::assertSame($itemEncoding, $mediaType->itemEncoding); + } + + #[Test] + public function supports_prefix_encoding(): void + { + $prefixEncoding1 = new Encoding(contentType: 'application/json'); + $prefixEncoding2 = new Encoding(contentType: 'text/plain'); + + $mediaType = new MediaType( + prefixEncoding: [$prefixEncoding1, $prefixEncoding2], + ); + + self::assertNotNull($mediaType->prefixEncoding); + self::assertCount(2, $mediaType->prefixEncoding); + } + + #[Test] + public function supports_encoding_map(): void + { + $encoding1 = new Encoding(contentType: 'application/json'); + + $mediaType = new MediaType( + encoding: ['field1' => $encoding1], + ); + + self::assertNotNull($mediaType->encoding); + self::assertArrayHasKey('field1', $mediaType->encoding); + } + + #[Test] + public function json_serialize_includes_item_schema(): void + { + $itemSchema = new Schema(type: 'object', properties: null); + $mediaType = new MediaType(itemSchema: $itemSchema); + + $serialized = $mediaType->jsonSerialize(); + + self::assertArrayHasKey('itemSchema', $serialized); + } + + #[Test] + public function json_serialize_includes_item_encoding(): void + { + $mediaType = new MediaType( + itemEncoding: new Encoding(contentType: 'application/json'), + ); + + $serialized = $mediaType->jsonSerialize(); + + self::assertArrayHasKey('itemEncoding', $serialized); + } + + #[Test] + public function json_serialize_includes_prefix_encoding(): void + { + $mediaType = new MediaType( + prefixEncoding: [new Encoding(contentType: 'application/json')], + ); + + $serialized = $mediaType->jsonSerialize(); + + self::assertArrayHasKey('prefixEncoding', $serialized); + } + + #[Test] + public function json_serialize_includes_encoding(): void + { + $mediaType = new MediaType( + encoding: ['field1' => new Encoding(contentType: 'application/json')], + ); + + $serialized = $mediaType->jsonSerialize(); + + self::assertArrayHasKey('encoding', $serialized); + } + + #[Test] + public function can_have_both_schema_and_item_schema(): void + { + $schema = new Schema(type: 'array', properties: null); + $itemSchema = new Schema(type: 'object', properties: null); + + $mediaType = new MediaType( + schema: $schema, + itemSchema: $itemSchema, + ); + + self::assertSame($schema, $mediaType->schema); + self::assertSame($itemSchema, $mediaType->itemSchema); + } } diff --git a/tests/Schema/Model/OAuthFlowTest.php b/tests/Schema/Model/OAuthFlowTest.php new file mode 100644 index 0000000..b38b88d --- /dev/null +++ b/tests/Schema/Model/OAuthFlowTest.php @@ -0,0 +1,193 @@ + 'Read access', 'write' => 'Write access'], + ); + + self::assertSame('https://example.com/oauth/authorize', $flow->authorizationUrl); + self::assertSame('https://example.com/oauth/token', $flow->tokenUrl); + self::assertSame(['read' => 'Read access', 'write' => 'Write access'], $flow->scopes); + } + + #[Test] + public function can_create_oauth_flow_with_device_authorization_url(): void + { + $flow = new OAuthFlow( + tokenUrl: 'https://auth.example.com/token', + deviceAuthorizationUrl: 'https://auth.example.com/device/code', + scopes: ['read' => 'Read access'], + ); + + self::assertSame('https://auth.example.com/device/code', $flow->deviceAuthorizationUrl); + } + + #[Test] + public function can_create_oauth_flow_with_deprecated_flag(): void + { + $flow = new OAuthFlow( + tokenUrl: 'https://auth.example.com/token', + scopes: ['read' => 'Read'], + deprecated: true, + ); + + self::assertTrue($flow->deprecated); + } + + #[Test] + public function can_create_oauth_flow_with_refresh_url(): void + { + $flow = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + refreshUrl: 'https://example.com/oauth/refresh', + scopes: ['read' => 'Read access'], + ); + + self::assertSame('https://example.com/oauth/refresh', $flow->refreshUrl); + } + + #[Test] + public function json_serialize_includes_authorization_url(): void + { + $flow = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: ['read' => 'Read access'], + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('authorizationUrl', $serialized); + self::assertSame('https://example.com/oauth/authorize', $serialized['authorizationUrl']); + } + + #[Test] + public function json_serialize_includes_token_url(): void + { + $flow = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('tokenUrl', $serialized); + self::assertSame('https://example.com/oauth/token', $serialized['tokenUrl']); + } + + #[Test] + public function json_serialize_includes_refresh_url(): void + { + $flow = new OAuthFlow( + refreshUrl: 'https://example.com/oauth/refresh', + scopes: ['read' => 'Read access'], + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('refreshUrl', $serialized); + } + + #[Test] + public function json_serialize_includes_scopes(): void + { + $flow = new OAuthFlow( + scopes: ['read' => 'Read access', 'write' => 'Write access'], + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('scopes', $serialized); + self::assertSame(['read' => 'Read access', 'write' => 'Write access'], $serialized['scopes']); + } + + #[Test] + public function json_serialize_includes_device_authorization_url(): void + { + $flow = new OAuthFlow( + tokenUrl: 'https://auth.example.com/token', + deviceAuthorizationUrl: 'https://auth.example.com/device/code', + scopes: ['read' => 'Read access'], + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('deviceAuthorizationUrl', $serialized); + self::assertSame('https://auth.example.com/device/code', $serialized['deviceAuthorizationUrl']); + } + + #[Test] + public function json_serialize_includes_deprecated(): void + { + $flow = new OAuthFlow( + scopes: ['read' => 'Read access'], + deprecated: true, + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('deprecated', $serialized); + self::assertTrue($serialized['deprecated']); + } + + #[Test] + public function json_serialize_excludes_null_fields(): void + { + $flow = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayNotHasKey('authorizationUrl', $serialized); + self::assertArrayNotHasKey('refreshUrl', $serialized); + self::assertArrayNotHasKey('deviceAuthorizationUrl', $serialized); + self::assertArrayNotHasKey('deprecated', $serialized); + } + + #[Test] + public function json_serialize_includes_all_fields(): void + { + $flow = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + refreshUrl: 'https://example.com/oauth/refresh', + scopes: ['read' => 'Read access'], + deviceAuthorizationUrl: 'https://example.com/device/code', + deprecated: false, + ); + + $serialized = $flow->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('authorizationUrl', $serialized); + self::assertArrayHasKey('tokenUrl', $serialized); + self::assertArrayHasKey('refreshUrl', $serialized); + self::assertArrayHasKey('scopes', $serialized); + self::assertArrayHasKey('deviceAuthorizationUrl', $serialized); + self::assertArrayHasKey('deprecated', $serialized); + } +} diff --git a/tests/Schema/Model/OAuthFlowsTest.php b/tests/Schema/Model/OAuthFlowsTest.php new file mode 100644 index 0000000..b8a4b10 --- /dev/null +++ b/tests/Schema/Model/OAuthFlowsTest.php @@ -0,0 +1,232 @@ + 'Read access'], + ); + + $flows = new OAuthFlows(implicit: $implicit); + + self::assertNotNull($flows->implicit); + self::assertSame('https://example.com/oauth/authorize', $flows->implicit->authorizationUrl); + } + + #[Test] + public function can_create_oauth_flows_with_password(): void + { + $password = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(password: $password); + + self::assertNotNull($flows->password); + self::assertSame('https://example.com/oauth/token', $flows->password->tokenUrl); + } + + #[Test] + public function can_create_oauth_flows_with_client_credentials(): void + { + $clientCredentials = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(clientCredentials: $clientCredentials); + + self::assertNotNull($flows->clientCredentials); + self::assertSame('https://example.com/oauth/token', $flows->clientCredentials->tokenUrl); + } + + #[Test] + public function can_create_oauth_flows_with_authorization_code(): void + { + $authorizationCode = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(authorizationCode: $authorizationCode); + + self::assertNotNull($flows->authorizationCode); + self::assertSame('https://example.com/oauth/authorize', $flows->authorizationCode->authorizationUrl); + self::assertSame('https://example.com/oauth/token', $flows->authorizationCode->tokenUrl); + } + + #[Test] + public function can_create_oauth_flows_with_device_code(): void + { + $deviceCode = new OAuthFlow( + tokenUrl: 'https://auth.example.com/token', + deviceAuthorizationUrl: 'https://auth.example.com/device/code', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(deviceCode: $deviceCode); + + self::assertNotNull($flows->deviceCode); + self::assertSame('https://auth.example.com/device/code', $flows->deviceCode->deviceAuthorizationUrl); + } + + #[Test] + public function json_serialize_includes_implicit(): void + { + $implicit = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(implicit: $implicit); + + $serialized = $flows->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('implicit', $serialized); + self::assertArrayHasKey('authorizationUrl', $serialized['implicit']); + } + + #[Test] + public function json_serialize_includes_password(): void + { + $password = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(password: $password); + + $serialized = $flows->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('password', $serialized); + self::assertArrayHasKey('tokenUrl', $serialized['password']); + } + + #[Test] + public function json_serialize_includes_client_credentials(): void + { + $clientCredentials = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(clientCredentials: $clientCredentials); + + $serialized = $flows->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('clientCredentials', $serialized); + } + + #[Test] + public function json_serialize_includes_authorization_code(): void + { + $authorizationCode = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(authorizationCode: $authorizationCode); + + $serialized = $flows->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('authorizationCode', $serialized); + } + + #[Test] + public function json_serialize_includes_device_code(): void + { + $deviceCode = new OAuthFlow( + tokenUrl: 'https://auth.example.com/token', + deviceAuthorizationUrl: 'https://auth.example.com/device/code', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows(deviceCode: $deviceCode); + + $serialized = $flows->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('deviceCode', $serialized); + self::assertArrayHasKey('deviceAuthorizationUrl', $serialized['deviceCode']); + } + + #[Test] + public function json_serialize_excludes_null_flows(): void + { + $flows = new OAuthFlows(); + + $serialized = $flows->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayNotHasKey('implicit', $serialized); + self::assertArrayNotHasKey('password', $serialized); + self::assertArrayNotHasKey('clientCredentials', $serialized); + self::assertArrayNotHasKey('authorizationCode', $serialized); + self::assertArrayNotHasKey('deviceCode', $serialized); + } + + #[Test] + public function json_serialize_includes_all_flows(): void + { + $implicit = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: ['read' => 'Read access'], + ); + $password = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + $clientCredentials = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + $authorizationCode = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access'], + ); + $deviceCode = new OAuthFlow( + tokenUrl: 'https://auth.example.com/token', + deviceAuthorizationUrl: 'https://auth.example.com/device/code', + scopes: ['read' => 'Read access'], + ); + + $flows = new OAuthFlows( + implicit: $implicit, + password: $password, + clientCredentials: $clientCredentials, + authorizationCode: $authorizationCode, + deviceCode: $deviceCode, + ); + + $serialized = $flows->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('implicit', $serialized); + self::assertArrayHasKey('password', $serialized); + self::assertArrayHasKey('clientCredentials', $serialized); + self::assertArrayHasKey('authorizationCode', $serialized); + self::assertArrayHasKey('deviceCode', $serialized); + } +} diff --git a/tests/Schema/Model/PathItemTest.php b/tests/Schema/Model/PathItemTest.php index a427002..d841fa7 100644 --- a/tests/Schema/Model/PathItemTest.php +++ b/tests/Schema/Model/PathItemTest.php @@ -339,4 +339,83 @@ public function json_serialize_includes_trace(): void self::assertIsArray($serialized); self::assertArrayHasKey('trace', $serialized); } + + #[Test] + public function json_serialize_includes_query(): void + { + $operation = new Operation( + responses: new Responses( + responses: ['200' => new Response( + description: 'Success', + headers: null, + content: null, + )], + ), + ); + + $pathItem = new PathItem( + query: $operation, + ); + + $serialized = $pathItem->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('query', $serialized); + } + + #[Test] + public function json_serialize_includes_additional_operations(): void + { + $operation = new Operation( + responses: new Responses( + responses: ['200' => new Response( + description: 'Success', + headers: null, + content: null, + )], + ), + ); + + $pathItem = new PathItem( + additionalOperations: [ + 'COPY' => $operation, + 'MOVE' => $operation, + ], + ); + + $serialized = $pathItem->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('additionalOperations', $serialized); + self::assertArrayHasKey('COPY', $serialized['additionalOperations']); + self::assertArrayHasKey('MOVE', $serialized['additionalOperations']); + } + + #[Test] + public function can_create_with_query_and_additional_operations(): void + { + $operation = new Operation( + responses: new Responses( + responses: ['200' => new Response( + description: 'Success', + headers: null, + content: null, + )], + ), + ); + + $pathItem = new PathItem( + query: $operation, + additionalOperations: [ + 'COPY' => $operation, + 'PURGE' => $operation, + ], + ); + + self::assertInstanceOf(Operation::class, $pathItem->query); + self::assertIsArray($pathItem->additionalOperations); + self::assertCount(2, $pathItem->additionalOperations); + self::assertArrayHasKey('COPY', $pathItem->additionalOperations); + self::assertArrayHasKey('PURGE', $pathItem->additionalOperations); + } } diff --git a/tests/Schema/Model/ResponseTest.php b/tests/Schema/Model/ResponseTest.php index 69dcdd9..e59cb55 100644 --- a/tests/Schema/Model/ResponseTest.php +++ b/tests/Schema/Model/ResponseTest.php @@ -144,4 +144,41 @@ public function json_serialize_includes_links(): void self::assertIsArray($serialized); self::assertArrayHasKey('links', $serialized); } + + #[Test] + public function response_has_summary_field(): void + { + $response = new Response( + summary: 'Successful response', + description: 'Success', + ); + + self::assertSame('Successful response', $response->summary); + } + + #[Test] + public function json_serialize_includes_summary(): void + { + $response = new Response( + summary: 'Successful response', + description: 'Success', + ); + + $serialized = $response->jsonSerialize(); + + self::assertArrayHasKey('summary', $serialized); + self::assertSame('Successful response', $serialized['summary']); + } + + #[Test] + public function json_serialize_excludes_null_summary(): void + { + $response = new Response( + description: 'Success', + ); + + $serialized = $response->jsonSerialize(); + + self::assertArrayNotHasKey('summary', $serialized); + } } diff --git a/tests/Schema/Model/SchemaTest.php b/tests/Schema/Model/SchemaTest.php index 720d306..32090c9 100644 --- a/tests/Schema/Model/SchemaTest.php +++ b/tests/Schema/Model/SchemaTest.php @@ -4,11 +4,12 @@ namespace Duyler\OpenApi\Test\Schema\Model; +use Duyler\OpenApi\Schema\Model\Discriminator; +use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Schema\Model\Xml; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use Duyler\OpenApi\Schema\Model\Discriminator; -use Duyler\OpenApi\Schema\Model\Schema; #[CoversClass(Schema::class)] final class SchemaTest extends TestCase @@ -778,4 +779,40 @@ public function json_serialize_includes_discriminator(): void self::assertIsArray($serialized); self::assertArrayHasKey('discriminator', $serialized); } + + #[Test] + public function schema_has_xml_property(): void + { + $schema = new Schema( + type: 'string', + xml: new Xml( + name: 'value', + nodeType: 'attribute', + ), + ); + + self::assertNotNull($schema->xml); + self::assertSame('value', $schema->xml->name); + self::assertSame('attribute', $schema->xml->nodeType); + } + + #[Test] + public function json_serialize_includes_xml(): void + { + $schema = new Schema( + type: 'string', + xml: new Xml( + name: 'item', + nodeType: 'element', + ), + ); + + $serialized = $schema->jsonSerialize(); + + self::assertIsArray($serialized); + self::assertArrayHasKey('xml', $serialized); + self::assertInstanceOf(Xml::class, $serialized['xml']); + self::assertSame('item', $serialized['xml']->name); + self::assertSame('element', $serialized['xml']->nodeType); + } } diff --git a/tests/Schema/Model/SecuritySchemeTest.php b/tests/Schema/Model/SecuritySchemeTest.php index 9c350cf..00b2a24 100644 --- a/tests/Schema/Model/SecuritySchemeTest.php +++ b/tests/Schema/Model/SecuritySchemeTest.php @@ -7,6 +7,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Duyler\OpenApi\Schema\Model\OAuthFlow; +use Duyler\OpenApi\Schema\Model\OAuthFlows; use Duyler\OpenApi\Schema\Model\SecurityScheme; #[CoversClass(SecurityScheme::class)] @@ -90,11 +92,8 @@ public function json_serialize_includes_all_optional_fields(): void name: null, in: null, flows: null, - authorizationUrl: null, - tokenUrl: null, - refreshUrl: null, - scopes: null, openIdConnectUrl: null, + oauth2MetadataUrl: null, ); $serialized = $scheme->jsonSerialize(); @@ -137,84 +136,221 @@ public function json_serialize_includes_in(): void #[Test] public function json_serialize_includes_flows(): void { + $implicit = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: ['read' => 'Read access'], + ); + $flows = new OAuthFlows(implicit: $implicit); + $scheme = new SecurityScheme( type: 'oauth2', - flows: 'implicit', + flows: $flows, ); $serialized = $scheme->jsonSerialize(); self::assertIsArray($serialized); self::assertArrayHasKey('flows', $serialized); + self::assertArrayHasKey('implicit', $serialized['flows']); } #[Test] - public function json_serialize_includes_authorizationUrl(): void + public function json_serialize_includes_open_id_connect_url(): void { $scheme = new SecurityScheme( - type: 'oauth2', - authorizationUrl: 'https://example.com/oauth/authorize', + type: 'openIdConnect', + openIdConnectUrl: 'https://example.com/.well-known/openid-configuration', ); $serialized = $scheme->jsonSerialize(); self::assertIsArray($serialized); - self::assertArrayHasKey('authorizationUrl', $serialized); + self::assertArrayHasKey('openIdConnectUrl', $serialized); } #[Test] - public function json_serialize_includes_tokenUrl(): void + public function security_scheme_has_oauth2_metadata_url(): void { $scheme = new SecurityScheme( type: 'oauth2', - tokenUrl: 'https://example.com/oauth/token', + oauth2MetadataUrl: 'https://auth.example.com/.well-known/oauth-authorization-server', + ); + + self::assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server', + $scheme->oauth2MetadataUrl, + ); + } + + #[Test] + public function json_serialize_includes_oauth2_metadata_url(): void + { + $scheme = new SecurityScheme( + type: 'oauth2', + oauth2MetadataUrl: 'https://auth.example.com/.well-known/oauth-authorization-server', ); $serialized = $scheme->jsonSerialize(); self::assertIsArray($serialized); - self::assertArrayHasKey('tokenUrl', $serialized); + self::assertArrayHasKey('oauth2MetadataUrl', $serialized); + self::assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server', + $serialized['oauth2MetadataUrl'], + ); + } + + #[Test] + public function security_scheme_with_full_oauth_flows(): void + { + $authorizationCode = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + tokenUrl: 'https://example.com/oauth/token', + scopes: ['read' => 'Read access', 'write' => 'Write access'], + ); + $deviceCode = new OAuthFlow( + tokenUrl: 'https://example.com/oauth/token', + deviceAuthorizationUrl: 'https://example.com/device/code', + scopes: ['read' => 'Read access'], + deprecated: false, + ); + $flows = new OAuthFlows( + authorizationCode: $authorizationCode, + deviceCode: $deviceCode, + ); + + $scheme = new SecurityScheme( + type: 'oauth2', + description: 'OAuth 2.0 with Device Authorization Flow', + flows: $flows, + oauth2MetadataUrl: 'https://auth.example.com/.well-known/oauth-authorization-server', + ); + + self::assertSame('oauth2', $scheme->type); + self::assertSame('OAuth 2.0 with Device Authorization Flow', $scheme->description); + self::assertNotNull($scheme->flows); + self::assertNotNull($scheme->flows->authorizationCode); + self::assertNotNull($scheme->flows->deviceCode); + self::assertSame( + 'https://auth.example.com/.well-known/oauth-authorization-server', + $scheme->oauth2MetadataUrl, + ); + } + + #[Test] + public function security_scheme_with_deprecated_flow(): void + { + $implicit = new OAuthFlow( + authorizationUrl: 'https://example.com/oauth/authorize', + scopes: ['read' => 'Read access'], + deprecated: true, + ); + $flows = new OAuthFlows(implicit: $implicit); + + $scheme = new SecurityScheme( + type: 'oauth2', + flows: $flows, + ); + + self::assertTrue($scheme->flows->implicit->deprecated); + } + + #[Test] + public function backward_compatible_flat_authorization_url(): void + { + $scheme = new SecurityScheme( + type: 'oauth2', + authorizationUrl: 'https://example.com/oauth/authorize', + ); + + self::assertSame('https://example.com/oauth/authorize', $scheme->authorizationUrl); + } + + #[Test] + public function backward_compatible_flat_token_url(): void + { + $scheme = new SecurityScheme( + type: 'oauth2', + tokenUrl: 'https://example.com/oauth/token', + ); + + self::assertSame('https://example.com/oauth/token', $scheme->tokenUrl); } #[Test] - public function json_serialize_includes_refreshUrl(): void + public function backward_compatible_flat_refresh_url(): void { $scheme = new SecurityScheme( type: 'oauth2', refreshUrl: 'https://example.com/oauth/refresh', ); + self::assertSame('https://example.com/oauth/refresh', $scheme->refreshUrl); + } + + #[Test] + public function backward_compatible_flat_scopes(): void + { + $scheme = new SecurityScheme( + type: 'oauth2', + scopes: ['read' => 'Read access', 'write' => 'Write access'], + ); + + self::assertSame(['read' => 'Read access', 'write' => 'Write access'], $scheme->scopes); + } + + #[Test] + public function json_serialize_includes_flat_authorization_url(): void + { + $scheme = new SecurityScheme( + type: 'oauth2', + authorizationUrl: 'https://example.com/oauth/authorize', + ); + $serialized = $scheme->jsonSerialize(); - self::assertIsArray($serialized); - self::assertArrayHasKey('refreshUrl', $serialized); + self::assertArrayHasKey('authorizationUrl', $serialized); + self::assertSame('https://example.com/oauth/authorize', $serialized['authorizationUrl']); } #[Test] - public function json_serialize_includes_scopes(): void + public function json_serialize_includes_flat_token_url(): void { $scheme = new SecurityScheme( type: 'oauth2', - scopes: ['read', 'write'], + tokenUrl: 'https://example.com/oauth/token', ); $serialized = $scheme->jsonSerialize(); - self::assertIsArray($serialized); - self::assertArrayHasKey('scopes', $serialized); + self::assertArrayHasKey('tokenUrl', $serialized); + self::assertSame('https://example.com/oauth/token', $serialized['tokenUrl']); } #[Test] - public function json_serialize_includes_openIdConnectUrl(): void + public function json_serialize_includes_flat_refresh_url(): void { $scheme = new SecurityScheme( - type: 'openIdConnect', - openIdConnectUrl: 'https://example.com/.well-known/openid-configuration', + type: 'oauth2', + refreshUrl: 'https://example.com/oauth/refresh', ); $serialized = $scheme->jsonSerialize(); - self::assertIsArray($serialized); - self::assertArrayHasKey('openIdConnectUrl', $serialized); + self::assertArrayHasKey('refreshUrl', $serialized); + } + + #[Test] + public function json_serialize_includes_flat_scopes(): void + { + $scheme = new SecurityScheme( + type: 'oauth2', + scopes: ['read' => 'Read access'], + ); + + $serialized = $scheme->jsonSerialize(); + + self::assertArrayHasKey('scopes', $serialized); + self::assertSame(['read' => 'Read access'], $serialized['scopes']); } } diff --git a/tests/Schema/Model/ServerTest.php b/tests/Schema/Model/ServerTest.php index cb147d7..57e32ce 100644 --- a/tests/Schema/Model/ServerTest.php +++ b/tests/Schema/Model/ServerTest.php @@ -59,4 +59,42 @@ public function json_serialize_excludes_null_fields(): void self::assertArrayNotHasKey('description', $serialized); self::assertArrayNotHasKey('variables', $serialized); } + + #[Test] + public function server_has_name_field(): void + { + $server = new Server( + url: 'https://api.example.com', + name: 'production', + ); + + self::assertSame('production', $server->name); + } + + #[Test] + public function json_serialize_includes_name(): void + { + $server = new Server( + url: 'https://api.example.com', + description: 'Production API server', + name: 'production', + ); + + $serialized = $server->jsonSerialize(); + + self::assertArrayHasKey('name', $serialized); + self::assertSame('production', $serialized['name']); + } + + #[Test] + public function json_serialize_excludes_null_name(): void + { + $server = new Server( + url: 'https://api.example.com', + ); + + $serialized = $server->jsonSerialize(); + + self::assertArrayNotHasKey('name', $serialized); + } } diff --git a/tests/Schema/Model/TagTest.php b/tests/Schema/Model/TagTest.php index 2f49295..0988f24 100644 --- a/tests/Schema/Model/TagTest.php +++ b/tests/Schema/Model/TagTest.php @@ -59,4 +59,108 @@ public function json_serialize_excludes_null_fields(): void self::assertArrayNotHasKey('description', $serialized); self::assertArrayNotHasKey('externalDocs', $serialized); } + + #[Test] + public function tag_has_summary_field(): void + { + $tag = new Tag( + name: 'Users', + summary: 'User management', + ); + + self::assertSame('User management', $tag->summary); + } + + #[Test] + public function tag_has_parent_field(): void + { + $tag = new Tag( + name: 'Users', + parent: 'Administration', + ); + + self::assertSame('Administration', $tag->parent); + } + + #[Test] + public function tag_has_kind_field(): void + { + $tag = new Tag( + name: 'Users', + kind: 'nav', + ); + + self::assertSame('nav', $tag->kind); + } + + #[Test] + public function tag_has_hierarchy_fields(): void + { + $tag = new Tag( + name: 'Users', + summary: 'User management', + parent: 'Administration', + kind: 'nav', + ); + + self::assertSame('User management', $tag->summary); + self::assertSame('Administration', $tag->parent); + self::assertSame('nav', $tag->kind); + } + + #[Test] + public function json_serialize_includes_summary(): void + { + $tag = new Tag( + name: 'Users', + summary: 'User management', + ); + + $serialized = $tag->jsonSerialize(); + + self::assertArrayHasKey('summary', $serialized); + self::assertSame('User management', $serialized['summary']); + } + + #[Test] + public function json_serialize_includes_parent(): void + { + $tag = new Tag( + name: 'Users', + parent: 'Administration', + ); + + $serialized = $tag->jsonSerialize(); + + self::assertArrayHasKey('parent', $serialized); + self::assertSame('Administration', $serialized['parent']); + } + + #[Test] + public function json_serialize_includes_kind(): void + { + $tag = new Tag( + name: 'Users', + kind: 'nav', + ); + + $serialized = $tag->jsonSerialize(); + + self::assertArrayHasKey('kind', $serialized); + self::assertSame('nav', $serialized['kind']); + } + + #[Test] + public function json_serialize_excludes_null_hierarchy_fields(): void + { + $tag = new Tag( + name: 'Users', + ); + + $serialized = $tag->jsonSerialize(); + + self::assertArrayNotHasKey('summary', $serialized); + self::assertArrayNotHasKey('parent', $serialized); + self::assertArrayNotHasKey('kind', $serialized); + } } diff --git a/tests/Schema/Model/XmlTest.php b/tests/Schema/Model/XmlTest.php new file mode 100644 index 0000000..0056c98 --- /dev/null +++ b/tests/Schema/Model/XmlTest.php @@ -0,0 +1,191 @@ +name); + self::assertSame('https://example.com/ns', $xml->namespace); + self::assertSame('ex', $xml->prefix); + self::assertFalse($xml->attribute); + self::assertTrue($xml->wrapped); + self::assertSame('element', $xml->nodeType); + } + + #[Test] + public function can_create_xml_with_null_properties(): void + { + $xml = new Xml( + name: null, + namespace: null, + prefix: null, + attribute: null, + wrapped: null, + nodeType: null, + ); + + self::assertNull($xml->name); + self::assertNull($xml->namespace); + self::assertNull($xml->prefix); + self::assertNull($xml->attribute); + self::assertNull($xml->wrapped); + self::assertNull($xml->nodeType); + } + + #[Test] + public function node_type_defaults_to_null(): void + { + $xml = new Xml(name: 'test'); + + self::assertNull($xml->nodeType); + } + + #[Test] + public function accepts_valid_node_types(): void + { + $validTypes = ['element', 'attribute', 'text', 'cdata', 'none']; + + foreach ($validTypes as $type) { + $xml = new Xml(nodeType: $type); + self::assertSame($type, $xml->nodeType); + } + } + + #[Test] + public function json_serialize_includes_all_fields(): void + { + $xml = new Xml( + name: 'item', + namespace: 'https://example.com/ns', + prefix: 'ex', + attribute: true, + wrapped: false, + nodeType: 'element', + ); + + $result = $xml->jsonSerialize(); + + self::assertIsArray($result); + self::assertArrayHasKey('name', $result); + self::assertArrayHasKey('namespace', $result); + self::assertArrayHasKey('prefix', $result); + self::assertArrayHasKey('attribute', $result); + self::assertArrayHasKey('wrapped', $result); + self::assertArrayHasKey('nodeType', $result); + self::assertSame('item', $result['name']); + self::assertSame('element', $result['nodeType']); + } + + #[Test] + public function json_serialize_excludes_null_fields(): void + { + $xml = new Xml( + name: 'item', + namespace: null, + prefix: null, + attribute: null, + wrapped: null, + nodeType: null, + ); + + $result = $xml->jsonSerialize(); + + self::assertIsArray($result); + self::assertArrayHasKey('name', $result); + self::assertArrayNotHasKey('namespace', $result); + self::assertArrayNotHasKey('prefix', $result); + self::assertArrayNotHasKey('attribute', $result); + self::assertArrayNotHasKey('wrapped', $result); + self::assertArrayNotHasKey('nodeType', $result); + } + + #[Test] + public function json_serialize_includes_node_type(): void + { + $xml = new Xml( + name: 'value', + nodeType: 'attribute', + ); + + $result = $xml->jsonSerialize(); + + self::assertArrayHasKey('nodeType', $result); + self::assertSame('attribute', $result['nodeType']); + } + + #[Test] + public function json_serialize_includes_deprecated_attribute(): void + { + $xml = new Xml( + name: 'value', + attribute: true, + ); + + $result = $xml->jsonSerialize(); + + self::assertArrayHasKey('attribute', $result); + self::assertTrue($result['attribute']); + } + + #[Test] + public function json_serialize_includes_deprecated_wrapped(): void + { + $xml = new Xml( + name: 'items', + wrapped: true, + ); + + $result = $xml->jsonSerialize(); + + self::assertArrayHasKey('wrapped', $result); + self::assertTrue($result['wrapped']); + } + + #[Test] + public function valid_node_types_constant_contains_expected_values(): void + { + self::assertSame( + ['element', 'attribute', 'text', 'cdata', 'none'], + Xml::VALID_NODE_TYPES, + ); + } + + #[Test] + public function is_valid_node_type_returns_true_for_valid_types(): void + { + self::assertTrue(Xml::isValidNodeType('element')); + self::assertTrue(Xml::isValidNodeType('attribute')); + self::assertTrue(Xml::isValidNodeType('text')); + self::assertTrue(Xml::isValidNodeType('cdata')); + self::assertTrue(Xml::isValidNodeType('none')); + } + + #[Test] + public function is_valid_node_type_returns_false_for_invalid_types(): void + { + self::assertFalse(Xml::isValidNodeType('invalid')); + self::assertFalse(Xml::isValidNodeType('ELEMENT')); + self::assertFalse(Xml::isValidNodeType('')); + self::assertFalse(Xml::isValidNodeType('Element')); + } +} diff --git a/tests/Schema/OpenApiDocumentTest.php b/tests/Schema/OpenApiDocumentTest.php index 70a090b..46c4c52 100644 --- a/tests/Schema/OpenApiDocumentTest.php +++ b/tests/Schema/OpenApiDocumentTest.php @@ -152,4 +152,45 @@ public function json_serialize_with_tags(): void self::assertArrayHasKey('info', $serialized); self::assertArrayHasKey('tags', $serialized); } + + #[Test] + public function openapi_document_has_self_field(): void + { + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test API', version: '1.0.0'), + self: 'https://api.example.com/openapi.json', + ); + + self::assertSame('https://api.example.com/openapi.json', $document->self); + } + + #[Test] + public function json_serialize_includes_self_field(): void + { + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test API', version: '1.0.0'), + self: 'https://api.example.com/openapi.json', + ); + + $serialized = $document->jsonSerialize(); + + self::assertArrayHasKey('$self', $serialized); + self::assertSame('https://api.example.com/openapi.json', $serialized['$self']); + } + + #[Test] + public function json_serialize_excludes_null_self_field(): void + { + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject(title: 'Test API', version: '1.0.0'), + self: null, + ); + + $serialized = $document->jsonSerialize(); + + self::assertArrayNotHasKey('$self', $serialized); + } } diff --git a/tests/Schema/Parser/DeprecationLoggerTest.php b/tests/Schema/Parser/DeprecationLoggerTest.php new file mode 100644 index 0000000..4b456d1 --- /dev/null +++ b/tests/Schema/Parser/DeprecationLoggerTest.php @@ -0,0 +1,110 @@ +createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('allowEmptyValue')); + + $deprecationLogger = new DeprecationLogger($logger, true); + $deprecationLogger->warn('allowEmptyValue', 'Parameter Object', '3.2.0'); + } + + #[Test] + public function does_not_log_when_disabled(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, false); + $deprecationLogger->warn('allowEmptyValue', 'Parameter Object', '3.2.0'); + } + + #[Test] + public function warning_includes_alternative(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains("Use 'nodeType: \"attribute\"' instead")); + + $deprecationLogger = new DeprecationLogger($logger, true); + $deprecationLogger->warn( + 'attribute', + 'XML Object', + '3.2.0', + 'nodeType: "attribute"', + ); + } + + #[Test] + public function warning_without_alternative(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->logicalAnd( + $this->stringContains('allowEmptyValue'), + $this->logicalNot($this->stringContains('Use')), + )); + + $deprecationLogger = new DeprecationLogger($logger, true); + $deprecationLogger->warn('allowEmptyValue', 'Parameter Object', '3.2.0'); + } + + #[Test] + public function uses_null_logger_by_default(): void + { + $deprecationLogger = new DeprecationLogger(); + + $this->expectNotToPerformAssertions(); + $deprecationLogger->warn('allowEmptyValue', 'Parameter Object', '3.2.0'); + } + + #[Test] + public function warning_contains_all_info(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->logicalAnd( + $this->stringContains("'example'"), + $this->stringContains('Schema Object'), + $this->stringContains('3.2.0'), + $this->stringContains("'examples in MediaType Object' instead"), + )); + + $deprecationLogger = new DeprecationLogger($logger, true); + $deprecationLogger->warn( + 'example', + 'Schema Object', + '3.2.0', + 'examples in MediaType Object', + ); + } + + #[Test] + public function multiple_warnings_are_logged(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(3))->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, true); + $deprecationLogger->warn('allowEmptyValue', 'Parameter Object', '3.2.0'); + $deprecationLogger->warn('example', 'Schema Object', '3.2.0', 'examples in MediaType Object'); + $deprecationLogger->warn('wrapped', 'XML Object', '3.2.0'); + } +} diff --git a/tests/Schema/Parser/OpenApiBuilderTest.php b/tests/Schema/Parser/OpenApiBuilderTest.php index 717b05f..3d66034 100644 --- a/tests/Schema/Parser/OpenApiBuilderTest.php +++ b/tests/Schema/Parser/OpenApiBuilderTest.php @@ -26,9 +26,12 @@ use Duyler\OpenApi\Schema\Model\Servers; use Duyler\OpenApi\Schema\Model\Tags; use Duyler\OpenApi\Schema\Model\Webhooks; +use Duyler\OpenApi\Schema\Parser\DeprecationLogger; use Duyler\OpenApi\Schema\Parser\JsonParser; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Duyler\OpenApi\Schema\Model\MediaType; final class OpenApiBuilderTest extends TestCase { @@ -637,35 +640,48 @@ public function build_security_scheme_with_all_fields(): void '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'], + 'oauth2Auth' => [ + 'type' => 'oauth2', + 'description' => 'OAuth 2.0 authentication', + 'flows' => [ + 'authorizationCode' => [ + 'authorizationUrl' => 'https://example.com/auth', + 'tokenUrl' => 'https://example.com/token', + 'refreshUrl' => 'https://example.com/refresh', + 'scopes' => ['read' => 'Read access', 'write' => 'Write access'], + ], + 'deviceCode' => [ + 'tokenUrl' => 'https://example.com/token', + 'deviceAuthorizationUrl' => 'https://example.com/device/code', + 'scopes' => ['read' => 'Read access'], + 'deprecated' => false, + ], + ], + 'oauth2MetadataUrl' => 'https://example.com/.well-known/oauth-authorization-server', ], ], ], ]); $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); + $scheme = $document->components->securitySchemes['oauth2Auth']; + + $this->assertSame('oauth2', $scheme->type); + $this->assertSame('OAuth 2.0 authentication', $scheme->description); + $this->assertSame('https://example.com/.well-known/oauth-authorization-server', $scheme->oauth2MetadataUrl); + + $this->assertNotNull($scheme->flows); + $this->assertNotNull($scheme->flows->authorizationCode); + $this->assertSame('https://example.com/auth', $scheme->flows->authorizationCode->authorizationUrl); + $this->assertSame('https://example.com/token', $scheme->flows->authorizationCode->tokenUrl); + $this->assertSame('https://example.com/refresh', $scheme->flows->authorizationCode->refreshUrl); + $this->assertSame(['read' => 'Read access', 'write' => 'Write access'], $scheme->flows->authorizationCode->scopes); + + $this->assertNotNull($scheme->flows->deviceCode); + $this->assertSame('https://example.com/token', $scheme->flows->deviceCode->tokenUrl); + $this->assertSame('https://example.com/device/code', $scheme->flows->deviceCode->deviceAuthorizationUrl); + $this->assertSame(['read' => 'Read access'], $scheme->flows->deviceCode->scopes); + $this->assertFalse($scheme->flows->deviceCode->deprecated); } #[Test] @@ -839,25 +855,32 @@ public function build_response_with_ref(): void } #[Test] - public function invalid_discriminator_throws_exception(): void + public function invalid_discriminator_is_now_valid(): void { $json = json_encode([ - 'openapi' => '3.1.0', + 'openapi' => '3.2.0', 'info' => ['title' => 'Test', 'version' => '1.0.0'], 'paths' => [], 'components' => [ 'schemas' => [ 'Pet' => [ 'type' => 'object', - 'discriminator' => [], + 'discriminator' => [ + 'defaultMapping' => '#/components/schemas/Fallback', + ], + ], + 'Fallback' => [ + 'type' => 'object', ], ], ], ]); - $this->expectException(InvalidSchemaException::class); - $this->expectExceptionMessage('Discriminator must have propertyName'); - $this->parser->parse($json); + $document = $this->parser->parse($json); + $discriminator = $document->components->schemas['Pet']->discriminator; + + $this->assertNull($discriminator->propertyName); + $this->assertSame('#/components/schemas/Fallback', $discriminator->defaultMapping); } #[Test] @@ -953,4 +976,1239 @@ public function media_type_with_example_string(): void $this->assertInstanceOf(Example::class, $mediaType->example); $this->assertSame('{"id": 1}', $mediaType->example->value); } + + #[Test] + public function parses_openapi_3_2_version(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertSame('3.2.0', $document->openapi); + } + + #[Test] + public function parses_self_field(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + '$self' => 'https://api.example.com/openapi.json', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertSame('https://api.example.com/openapi.json', $document->self); + } + + #[Test] + public function parses_server_name(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'servers' => [ + [ + 'url' => 'https://api.example.com', + 'name' => 'production', + 'description' => 'Production server', + ], + ], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + $server = $document->servers->servers[0]; + + $this->assertSame('production', $server->name); + } + + #[Test] + public function parses_response_summary(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/users' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'summary' => 'Successful response', + 'description' => 'List of users', + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $response = $document->paths->paths['/users']->get->responses->responses['200']; + + $this->assertSame('Successful response', $response->summary); + } + + #[Test] + public function parses_tag_v32_fields(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'tags' => [ + [ + 'name' => 'Users', + 'summary' => 'User management endpoints', + 'parent' => 'Administration', + 'kind' => 'nav', + ], + ], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + $tag = $document->tags->tags[0]; + + $this->assertSame('Users', $tag->name); + $this->assertSame('User management endpoints', $tag->summary); + $this->assertSame('Administration', $tag->parent); + $this->assertSame('nav', $tag->kind); + } + + #[Test] + public function parses_discriminator_default_mapping(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'Pet' => [ + 'type' => 'object', + 'discriminator' => [ + 'propertyName' => 'type', + 'mapping' => ['dog' => '#/components/schemas/Dog'], + 'defaultMapping' => '#/components/schemas/Pet', + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $discriminator = $document->components->schemas['Pet']->discriminator; + + $this->assertSame('type', $discriminator->propertyName); + $this->assertSame(['dog' => '#/components/schemas/Dog'], $discriminator->mapping); + $this->assertSame('#/components/schemas/Pet', $discriminator->defaultMapping); + } + + #[Test] + public function rejects_invalid_version_4(): void + { + $json = json_encode([ + 'openapi' => '4.0.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Unsupported OpenAPI version: 4.0.0. Only 3.0.x, 3.1.x and 3.2.x are supported.'); + $this->parser->parse($json); + } + + #[Test] + public function self_field_is_optional(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertNull($document->self); + } + + #[Test] + public function parses_query_operation(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/search' => [ + 'query' => [ + 'summary' => 'Search with body', + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object'], + ], + ], + ], + 'responses' => [ + '200' => ['description' => 'Search results'], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + + $this->assertNotNull($document->paths->paths['/search']->query); + $this->assertSame('Search with body', $document->paths->paths['/search']->query->summary); + $this->assertNotNull($document->paths->paths['/search']->query->requestBody); + } + + #[Test] + public function parses_additional_operations(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/resource' => [ + 'additionalOperations' => [ + 'COPY' => [ + 'summary' => 'Copy resource', + 'responses' => ['201' => ['description' => 'Copied']], + ], + 'MOVE' => [ + 'summary' => 'Move resource', + 'responses' => ['201' => ['description' => 'Moved']], + ], + 'PURGE' => [ + 'summary' => 'Purge cache', + 'responses' => ['200' => ['description' => 'Purged']], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + + $this->assertNotNull($document->paths->paths['/resource']->additionalOperations); + $this->assertArrayHasKey('COPY', $document->paths->paths['/resource']->additionalOperations); + $this->assertArrayHasKey('MOVE', $document->paths->paths['/resource']->additionalOperations); + $this->assertArrayHasKey('PURGE', $document->paths->paths['/resource']->additionalOperations); + $this->assertSame('Copy resource', $document->paths->paths['/resource']->additionalOperations['COPY']->summary); + $this->assertSame('Move resource', $document->paths->paths['/resource']->additionalOperations['MOVE']->summary); + $this->assertSame('Purge cache', $document->paths->paths['/resource']->additionalOperations['PURGE']->summary); + } + + #[Test] + public function parses_path_item_with_query_and_additional_operations(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/resource' => [ + 'get' => [ + 'summary' => 'Get resource', + 'responses' => ['200' => ['description' => 'OK']], + ], + 'query' => [ + 'summary' => 'Query resource', + 'requestBody' => [ + 'content' => [ + 'application/json' => [ + 'schema' => ['type' => 'object'], + ], + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + 'additionalOperations' => [ + 'COPY' => [ + 'summary' => 'Copy resource', + 'responses' => ['201' => ['description' => 'Copied']], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $pathItem = $document->paths->paths['/resource']; + + $this->assertNotNull($pathItem->get); + $this->assertNotNull($pathItem->query); + $this->assertNotNull($pathItem->additionalOperations); + $this->assertSame('Get resource', $pathItem->get->summary); + $this->assertSame('Query resource', $pathItem->query->summary); + $this->assertSame('Copy resource', $pathItem->additionalOperations['COPY']->summary); + } + + #[Test] + public function parses_media_type_with_item_schema(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/logs' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Log stream', + 'content' => [ + 'application/jsonl' => [ + 'itemSchema' => [ + 'type' => 'object', + 'properties' => [ + 'timestamp' => ['type' => 'string'], + 'message' => ['type' => 'string'], + ], + ], + ], + ], + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $mediaType = $document->paths->paths['/logs']->get->responses->responses['200']->content->mediaTypes['application/jsonl']; + + $this->assertNotNull($mediaType->itemSchema); + $this->assertSame('object', $mediaType->itemSchema->type); + $this->assertArrayHasKey('timestamp', $mediaType->itemSchema->properties); + } + + #[Test] + public function parses_media_type_with_encoding_map(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/upload' => [ + 'post' => [ + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => ['type' => 'object'], + 'encoding' => [ + 'file' => [ + 'contentType' => 'application/octet-stream', + 'headers' => [ + 'X-Custom' => ['description' => 'Custom header'], + ], + ], + ], + ], + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $mediaType = $document->paths->paths['/upload']->post->requestBody->content->mediaTypes['multipart/form-data']; + + $this->assertNotNull($mediaType->encoding); + $this->assertArrayHasKey('file', $mediaType->encoding); + $this->assertSame('application/octet-stream', $mediaType->encoding['file']->contentType); + } + + #[Test] + public function parses_media_type_with_item_encoding(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/stream' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Stream', + 'content' => [ + 'application/jsonl' => [ + 'itemSchema' => ['type' => 'object'], + 'itemEncoding' => [ + 'contentType' => 'application/json', + ], + ], + ], + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $mediaType = $document->paths->paths['/stream']->get->responses->responses['200']->content->mediaTypes['application/jsonl']; + + $this->assertNotNull($mediaType->itemEncoding); + $this->assertSame('application/json', $mediaType->itemEncoding->contentType); + } + + #[Test] + public function parses_media_type_with_prefix_encoding(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/stream' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Stream', + 'content' => [ + 'multipart/mixed' => [ + 'schema' => ['type' => 'object'], + 'prefixEncoding' => [ + ['contentType' => 'application/json'], + ['contentType' => 'text/plain'], + ], + ], + ], + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $mediaType = $document->paths->paths['/stream']->get->responses->responses['200']->content->mediaTypes['multipart/mixed']; + + $this->assertNotNull($mediaType->prefixEncoding); + $this->assertCount(2, $mediaType->prefixEncoding); + $this->assertSame('application/json', $mediaType->prefixEncoding[0]->contentType); + $this->assertSame('text/plain', $mediaType->prefixEncoding[1]->contentType); + } + + #[Test] + public function parses_encoding_with_nested_encoding(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/upload' => [ + 'post' => [ + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => ['type' => 'object'], + 'encoding' => [ + 'nested' => [ + 'contentType' => 'multipart/mixed', + 'encoding' => [ + 'inner' => [ + 'contentType' => 'application/json', + ], + ], + ], + ], + ], + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $mediaType = $document->paths->paths['/upload']->post->requestBody->content->mediaTypes['multipart/form-data']; + + $this->assertNotNull($mediaType->encoding); + $this->assertArrayHasKey('nested', $mediaType->encoding); + $this->assertSame('multipart/mixed', $mediaType->encoding['nested']->contentType); + $this->assertNotNull($mediaType->encoding['nested']->encoding); + $this->assertArrayHasKey('inner', $mediaType->encoding['nested']->encoding); + $this->assertSame('application/json', $mediaType->encoding['nested']->encoding['inner']->contentType); + } + + #[Test] + public function parses_encoding_all_fields(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/upload' => [ + 'post' => [ + 'requestBody' => [ + 'content' => [ + 'multipart/form-data' => [ + 'schema' => ['type' => 'object'], + 'encoding' => [ + 'field1' => [ + 'contentType' => 'application/json', + 'style' => 'form', + 'explode' => true, + 'allowReserved' => true, + ], + ], + ], + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $encoding = $document->paths->paths['/upload']->post->requestBody->content->mediaTypes['multipart/form-data']->encoding['field1']; + + $this->assertSame('application/json', $encoding->contentType); + $this->assertSame('form', $encoding->style); + $this->assertTrue($encoding->explode); + $this->assertTrue($encoding->allowReserved); + } + + #[Test] + public function media_type_with_both_schema_and_item_schema(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [ + '/stream' => [ + 'get' => [ + 'responses' => [ + '200' => [ + 'description' => 'Stream', + 'content' => [ + 'application/jsonl' => [ + 'schema' => ['type' => 'array'], + 'itemSchema' => ['type' => 'object'], + ], + ], + ], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $mediaType = $document->paths->paths['/stream']->get->responses->responses['200']->content->mediaTypes['application/jsonl']; + + $this->assertNotNull($mediaType->schema); + $this->assertSame('array', $mediaType->schema->type); + $this->assertNotNull($mediaType->itemSchema); + $this->assertSame('object', $mediaType->itemSchema->type); + } + + #[Test] + public function parses_example_with_data_value(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'examples' => [ + 'decodedExample' => [ + 'summary' => 'Decoded data', + 'dataValue' => ['name' => 'John', 'age' => 30], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $example = $document->components->examples['decodedExample']; + + $this->assertSame('Decoded data', $example->summary); + $this->assertSame(['name' => 'John', 'age' => 30], $example->dataValue); + } + + #[Test] + public function parses_example_with_serialized_value(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'examples' => [ + 'binaryExample' => [ + 'summary' => 'Binary example', + 'serializedValue' => 'SGVsbG8gV29ybGQ=', + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $example = $document->components->examples['binaryExample']; + + $this->assertSame('Binary example', $example->summary); + $this->assertSame('SGVsbG8gV29ybGQ=', $example->serializedValue); + } + + #[Test] + public function parses_example_with_serialized_example(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'examples' => [ + 'externalSerialized' => [ + 'summary' => 'External serialized', + 'serializedExample' => 'https://example.com/serialized.json', + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $example = $document->components->examples['externalSerialized']; + + $this->assertSame('External serialized', $example->summary); + $this->assertSame('https://example.com/serialized.json', $example->serializedExample); + } + + #[Test] + public function parses_example_with_all_v32_fields(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'examples' => [ + 'fullExample' => [ + 'summary' => 'Full example', + 'description' => 'Example with all fields', + 'value' => ['raw' => 'value'], + 'dataValue' => ['decoded' => 'data'], + 'serializedValue' => 'base64encoded', + 'externalValue' => 'https://example.com/value.json', + 'serializedExample' => 'https://example.com/serialized.json', + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $example = $document->components->examples['fullExample']; + + $this->assertSame('Full example', $example->summary); + $this->assertSame('Example with all fields', $example->description); + $this->assertSame(['raw' => 'value'], $example->value); + $this->assertSame(['decoded' => 'data'], $example->dataValue); + $this->assertSame('base64encoded', $example->serializedValue); + $this->assertSame('https://example.com/value.json', $example->externalValue); + $this->assertSame('https://example.com/serialized.json', $example->serializedExample); + } + + #[Test] + public function parses_media_types_components(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'mediaTypes' => [ + 'ProblemJson' => [ + 'schema' => ['type' => 'object'], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + + $this->assertNotNull($document->components->mediaTypes); + $this->assertArrayHasKey('ProblemJson', $document->components->mediaTypes); + } + + #[Test] + public function media_type_in_components_is_full_object(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'Problem' => [ + 'type' => 'object', + 'properties' => [ + 'type' => ['type' => 'string'], + 'title' => ['type' => 'string'], + 'status' => ['type' => 'integer'], + ], + ], + ], + 'mediaTypes' => [ + 'ProblemJson' => [ + 'schema' => ['$ref' => '#/components/schemas/Problem'], + ], + 'CsvExport' => [ + 'schema' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $problemJson = $document->components->mediaTypes['ProblemJson']; + + $this->assertInstanceOf(MediaType::class, $problemJson); + $this->assertNotNull($problemJson->schema); + + $csvExport = $document->components->mediaTypes['CsvExport']; + $this->assertInstanceOf(MediaType::class, $csvExport); + $this->assertNotNull($csvExport->schema); + $this->assertSame('array', $csvExport->schema->type); + } + + #[Test] + public function parses_schema_with_xml(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'xml' => [ + 'name' => 'user', + 'namespace' => 'https://example.com/ns', + 'prefix' => 'ex', + 'attribute' => false, + 'wrapped' => true, + 'nodeType' => 'element', + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $schema = $document->components->schemas['User']; + + $this->assertNotNull($schema->xml); + $this->assertSame('user', $schema->xml->name); + $this->assertSame('https://example.com/ns', $schema->xml->namespace); + $this->assertSame('ex', $schema->xml->prefix); + $this->assertFalse($schema->xml->attribute); + $this->assertTrue($schema->xml->wrapped); + $this->assertSame('element', $schema->xml->nodeType); + } + + #[Test] + public function parses_xml_with_node_type_attribute(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'Id' => [ + 'type' => 'string', + 'xml' => [ + 'name' => 'id', + 'nodeType' => 'attribute', + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $schema = $document->components->schemas['Id']; + + $this->assertNotNull($schema->xml); + $this->assertSame('id', $schema->xml->name); + $this->assertSame('attribute', $schema->xml->nodeType); + } + + #[Test] + public function parses_xml_with_all_valid_node_types(): void + { + $validTypes = ['element', 'attribute', 'text', 'cdata', 'none']; + + foreach ($validTypes as $type) { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'Test' => [ + 'type' => 'string', + 'xml' => [ + 'nodeType' => $type, + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $schema = $document->components->schemas['Test']; + + $this->assertNotNull($schema->xml); + $this->assertSame($type, $schema->xml->nodeType); + } + } + + #[Test] + public function invalid_xml_node_type_throws_exception(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'xml' => [ + 'nodeType' => 'invalid', + ], + ], + ], + ], + ]); + + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Invalid XML nodeType "invalid". Must be one of: element, attribute, text, cdata, none'); + $this->parser->parse($json); + } + + #[Test] + public function parses_xml_with_deprecated_attribute_field(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'xml' => [ + 'name' => 'id', + 'attribute' => true, + ], + ], + ], + ], + ]); + + $document = $this->parser->parse($json); + $schema = $document->components->schemas['User']; + + $this->assertNotNull($schema->xml); + $this->assertTrue($schema->xml->attribute); + } + + #[Test] + public function warns_on_allow_empty_value_in_v3_2(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('allowEmptyValue')); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'q', + 'in' => 'query', + 'allowEmptyValue' => true, + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function no_warning_on_allow_empty_value_in_v3_1(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'q', + 'in' => 'query', + 'allowEmptyValue' => true, + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function no_warning_on_allow_empty_value_in_v3_0(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.0.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'q', + 'in' => 'query', + 'allowEmptyValue' => true, + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function warns_on_schema_example_in_v3_2(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('example')); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'object', + 'example' => ['name' => 'John'], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function no_warning_on_schema_example_in_v3_1(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'object', + 'example' => ['name' => 'John'], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function warns_on_xml_attribute_in_v3_2(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->logicalAnd( + $this->stringContains('attribute'), + $this->stringContains('nodeType: "attribute"'), + )); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'xml' => [ + 'attribute' => true, + ], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function warns_on_xml_wrapped_in_v3_2(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('wrapped')); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'xml' => [ + 'wrapped' => true, + ], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function no_warning_on_xml_deprecated_fields_in_v3_1(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'xml' => [ + 'attribute' => true, + 'wrapped' => true, + ], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function no_warning_when_deprecation_logger_disabled(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, false); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'q', + 'in' => 'query', + 'allowEmptyValue' => true, + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'example' => 'test', + 'xml' => [ + 'attribute' => true, + 'wrapped' => true, + ], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function multiple_deprecation_warnings_in_v3_2(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->exactly(4))->method('warning'); + + $deprecationLogger = new DeprecationLogger($logger, true); + $parser = new JsonParser($deprecationLogger); + + $json = json_encode([ + 'openapi' => '3.2.0', + 'info' => ['title' => 'Test', 'version' => '1.0'], + 'paths' => [ + '/test' => [ + 'get' => [ + 'parameters' => [ + [ + 'name' => 'q', + 'in' => 'query', + 'allowEmptyValue' => true, + ], + ], + 'responses' => ['200' => ['description' => 'OK']], + ], + ], + ], + 'components' => [ + 'schemas' => [ + 'User' => [ + 'type' => 'string', + 'example' => 'test', + 'xml' => [ + 'attribute' => true, + 'wrapped' => true, + ], + ], + ], + ], + ]); + + $parser->parse($json); + } + + #[Test] + public function validates_self_uri(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + '$self' => 'not-a-valid-uri', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $this->expectException(InvalidSchemaException::class); + $this->expectExceptionMessage('Invalid $self URI: not-a-valid-uri'); + $this->parser->parse($json); + } + + #[Test] + public function accepts_valid_self_uri_http(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + '$self' => 'http://api.example.com/openapi.json', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertSame('http://api.example.com/openapi.json', $document->self); + } + + #[Test] + public function accepts_valid_self_uri_https(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + '$self' => 'https://api.example.com/schemas/main.json', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertSame('https://api.example.com/schemas/main.json', $document->self); + } + + #[Test] + public function accepts_valid_self_uri_file(): void + { + $json = json_encode([ + 'openapi' => '3.2.0', + '$self' => 'file:///path/to/openapi.json', + 'info' => ['title' => 'Test', 'version' => '1.0.0'], + 'paths' => [], + ]); + + $document = $this->parser->parse($json); + + $this->assertSame('file:///path/to/openapi.json', $document->self); + } } diff --git a/tests/Schema/Parser/ReferenceOverrideTest.php b/tests/Schema/Parser/ReferenceOverrideTest.php new file mode 100644 index 0000000..65d3d6c --- /dev/null +++ b/tests/Schema/Parser/ReferenceOverrideTest.php @@ -0,0 +1,454 @@ +parser = new JsonParser(); + $this->resolver = new RefResolver(); + } + + #[Test] + public function schema_reference_can_override_description(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"schemas":{"User":{"type":"object","description":"Original description"}}},"paths":{"/test":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User","description":"Override description"}}}}}}}}}'; + + $document = $this->parser->parse($json); + + $schema = $document->paths?->paths['/test']->get?->responses?->responses['200']->content?->mediaTypes['application/json']->schema; + + self::assertNotNull($schema); + self::assertSame('#/components/schemas/User', $schema->ref); + self::assertSame('Override description', $schema->refDescription); + } + + #[Test] + public function schema_reference_can_override_summary(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"schemas":{"User":{"type":"object","title":"Original title"}}},"paths":{"/test":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User","summary":"Override summary"}}}}}}}}}'; + + $document = $this->parser->parse($json); + + $schema = $document->paths?->paths['/test']->get?->responses?->responses['200']->content?->mediaTypes['application/json']->schema; + + self::assertNotNull($schema); + self::assertSame('#/components/schemas/User', $schema->ref); + self::assertSame('Override summary', $schema->refSummary); + } + + #[Test] + public function parameter_reference_can_override_description(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"parameters":{"Limit":{"name":"limit","in":"query","description":"Original description","schema":{"type":"integer"}}}},"paths":{"/test":{"get":{"parameters":[{"$ref":"#/components/parameters/Limit","description":"Override description"}],"responses":{"200":{"description":"OK"}}}}}}'; + + $document = $this->parser->parse($json); + + $param = $document->paths?->paths['/test']->get?->parameters?->parameters[0]; + + self::assertNotNull($param); + self::assertSame('#/components/parameters/Limit', $param->ref); + self::assertSame('Override description', $param->refDescription); + } + + #[Test] + public function parameter_reference_can_override_summary(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"parameters":{"Limit":{"name":"limit","in":"query","schema":{"type":"integer"}}}},"paths":{"/test":{"get":{"parameters":[{"$ref":"#/components/parameters/Limit","summary":"Override summary"}],"responses":{"200":{"description":"OK"}}}}}}'; + + $document = $this->parser->parse($json); + + $param = $document->paths?->paths['/test']->get?->parameters?->parameters[0]; + + self::assertNotNull($param); + self::assertSame('#/components/parameters/Limit', $param->ref); + self::assertSame('Override summary', $param->refSummary); + } + + #[Test] + public function response_reference_can_override_description(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"responses":{"Success":{"description":"Original description"}}},"paths":{"/test":{"get":{"responses":{"200":{"$ref":"#/components/responses/Success","description":"Override description"}}}}}}'; + + $document = $this->parser->parse($json); + + $response = $document->paths?->paths['/test']->get?->responses?->responses['200']; + + self::assertNotNull($response); + self::assertSame('#/components/responses/Success', $response->ref); + self::assertSame('Override description', $response->refDescription); + } + + #[Test] + public function response_reference_can_override_summary(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"responses":{"Success":{"summary":"Original summary","description":"Some description"}}},"paths":{"/test":{"get":{"responses":{"200":{"$ref":"#/components/responses/Success","summary":"Override summary"}}}}}}'; + + $document = $this->parser->parse($json); + + $response = $document->paths?->paths['/test']->get?->responses?->responses['200']; + + self::assertNotNull($response); + self::assertSame('#/components/responses/Success', $response->ref); + self::assertSame('Override summary', $response->refSummary); + } + + #[Test] + public function reference_without_override_works(): void + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"schemas":{"User":{"type":"object"}}},"paths":{"/test":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/User"}}}}}}}}}'; + + $document = $this->parser->parse($json); + + $schema = $document->paths?->paths['/test']->get?->responses?->responses['200']->content?->mediaTypes['application/json']->schema; + + self::assertNotNull($schema); + self::assertSame('#/components/schemas/User', $schema->ref); + self::assertNull($schema->refSummary); + self::assertNull($schema->refDescription); + } + + #[Test] + public function resolver_applies_schema_description_override(): void + { + $document = $this->createDocumentWithSchema(); + $schema = new Schema( + ref: '#/components/schemas/User', + refDescription: 'Override description', + ); + + $resolved = $this->resolver->resolveSchemaWithOverride($schema, $document); + + self::assertNull($resolved->ref); + self::assertSame('Override description', $resolved->description); + } + + #[Test] + public function resolver_applies_schema_summary_override_as_title(): void + { + $document = $this->createDocumentWithSchema(); + $schema = new Schema( + ref: '#/components/schemas/User', + refSummary: 'Override title', + ); + + $resolved = $this->resolver->resolveSchemaWithOverride($schema, $document); + + self::assertNull($resolved->ref); + self::assertSame('Override title', $resolved->title); + } + + #[Test] + public function resolver_applies_parameter_description_override(): void + { + $document = $this->createDocumentWithParameter(); + $parameter = new Parameter( + ref: '#/components/parameters/Limit', + refDescription: 'Override description', + ); + + $resolved = $this->resolver->resolveParameterWithOverride($parameter, $document); + + self::assertNull($resolved->ref); + self::assertSame('Override description', $resolved->description); + } + + #[Test] + public function resolver_applies_response_description_override(): void + { + $document = $this->createDocumentWithResponse(); + $response = new Response( + ref: '#/components/responses/Success', + refDescription: 'Override description', + ); + + $resolved = $this->resolver->resolveResponseWithOverride($response, $document); + + self::assertNull($resolved->ref); + self::assertSame('Override description', $resolved->description); + } + + #[Test] + public function resolver_applies_response_summary_override(): void + { + $document = $this->createDocumentWithResponse(); + $response = new Response( + ref: '#/components/responses/Success', + refSummary: 'Override summary', + ); + + $resolved = $this->resolver->resolveResponseWithOverride($response, $document); + + self::assertNull($resolved->ref); + self::assertSame('Override summary', $resolved->summary); + } + + #[Test] + public function resolver_returns_same_schema_if_no_ref(): void + { + $document = $this->createDocumentWithSchema(); + $schema = new Schema( + type: 'string', + description: 'Original description', + ); + + $resolved = $this->resolver->resolveSchemaWithOverride($schema, $document); + + self::assertSame($schema, $resolved); + } + + #[Test] + public function resolver_returns_same_parameter_if_no_ref(): void + { + $document = $this->createDocumentWithParameter(); + $parameter = new Parameter( + name: 'limit', + in: 'query', + description: 'Original description', + ); + + $resolved = $this->resolver->resolveParameterWithOverride($parameter, $document); + + self::assertSame($parameter, $resolved); + } + + #[Test] + public function resolver_returns_same_response_if_no_ref(): void + { + $document = $this->createDocumentWithResponse(); + $response = new Response( + description: 'Original description', + ); + + $resolved = $this->resolver->resolveResponseWithOverride($response, $document); + + self::assertSame($response, $resolved); + } + + #[Test] + public function resolver_uses_original_values_when_no_override(): void + { + $document = $this->createDocumentWithSchema(); + $schema = new Schema( + ref: '#/components/schemas/User', + ); + + $resolved = $this->resolver->resolveSchemaWithOverride($schema, $document); + + self::assertNull($resolved->ref); + self::assertSame('Original title', $resolved->title); + self::assertSame('Original description', $resolved->description); + } + + #[Test] + public function schema_json_serialize_includes_ref_summary_and_description(): void + { + $schema = new Schema( + ref: '#/components/schemas/User', + refSummary: 'Override summary', + refDescription: 'Override description', + ); + + $serialized = $schema->jsonSerialize(); + + self::assertArrayHasKey('$ref', $serialized); + self::assertArrayHasKey('summary', $serialized); + self::assertArrayHasKey('description', $serialized); + self::assertSame('#/components/schemas/User', $serialized['$ref']); + self::assertSame('Override summary', $serialized['summary']); + self::assertSame('Override description', $serialized['description']); + } + + #[Test] + public function schema_json_serialize_ref_description_overwrites_description(): void + { + $schema = new Schema( + ref: '#/components/schemas/User', + refDescription: 'Override description', + ); + + $serialized = $schema->jsonSerialize(); + + self::assertSame('Override description', $serialized['description']); + } + + #[Test] + public function parameter_json_serialize_includes_ref_summary_and_description(): void + { + $parameter = new Parameter( + ref: '#/components/parameters/Limit', + refSummary: 'Override summary', + refDescription: 'Override description', + ); + + $serialized = $parameter->jsonSerialize(); + + self::assertArrayHasKey('$ref', $serialized); + self::assertArrayHasKey('summary', $serialized); + self::assertArrayHasKey('description', $serialized); + } + + #[Test] + public function response_json_serialize_includes_ref_summary_and_description(): void + { + $response = new Response( + ref: '#/components/responses/Success', + refSummary: 'Override summary', + refDescription: 'Override description', + ); + + $serialized = $response->jsonSerialize(); + + self::assertArrayHasKey('$ref', $serialized); + self::assertArrayHasKey('summary', $serialized); + self::assertArrayHasKey('description', $serialized); + } + + #[Test] + public function schema_ref_serializes_only_reference_fields(): void + { + $schema = new Schema( + ref: '#/components/schemas/User', + refSummary: 'Override summary', + refDescription: 'Override description', + type: 'object', + title: 'Should not appear', + description: 'Should not appear', + properties: ['id' => new Schema(type: 'string')], + ); + + $serialized = $schema->jsonSerialize(); + + self::assertCount(3, $serialized); + self::assertArrayHasKey('$ref', $serialized); + self::assertArrayHasKey('summary', $serialized); + self::assertArrayHasKey('description', $serialized); + self::assertArrayNotHasKey('type', $serialized); + self::assertArrayNotHasKey('title', $serialized); + self::assertArrayNotHasKey('properties', $serialized); + } + + #[Test] + public function schema_ref_serializes_only_ref_when_no_overrides(): void + { + $schema = new Schema( + ref: '#/components/schemas/User', + type: 'object', + ); + + $serialized = $schema->jsonSerialize(); + + self::assertCount(1, $serialized); + self::assertArrayHasKey('$ref', $serialized); + } + + #[Test] + public function parameter_ref_serializes_only_reference_fields(): void + { + $parameter = new Parameter( + ref: '#/components/parameters/Limit', + refSummary: 'Override summary', + refDescription: 'Override description', + name: 'limit', + in: 'query', + description: 'Should not appear', + required: true, + ); + + $serialized = $parameter->jsonSerialize(); + + self::assertCount(3, $serialized); + self::assertArrayHasKey('$ref', $serialized); + self::assertArrayHasKey('summary', $serialized); + self::assertArrayHasKey('description', $serialized); + self::assertArrayNotHasKey('name', $serialized); + self::assertArrayNotHasKey('in', $serialized); + self::assertArrayNotHasKey('required', $serialized); + } + + #[Test] + public function parameter_ref_serializes_only_ref_when_no_overrides(): void + { + $parameter = new Parameter( + ref: '#/components/parameters/Limit', + name: 'limit', + ); + + $serialized = $parameter->jsonSerialize(); + + self::assertCount(1, $serialized); + self::assertArrayHasKey('$ref', $serialized); + } + + #[Test] + public function response_ref_serializes_only_reference_fields(): void + { + $response = new Response( + ref: '#/components/responses/Success', + refSummary: 'Override summary', + refDescription: 'Override description', + summary: 'Should not appear', + description: 'Should not appear', + ); + + $serialized = $response->jsonSerialize(); + + self::assertCount(3, $serialized); + self::assertArrayHasKey('$ref', $serialized); + self::assertArrayHasKey('summary', $serialized); + self::assertArrayHasKey('description', $serialized); + } + + #[Test] + public function response_ref_serializes_only_ref_when_no_overrides(): void + { + $response = new Response( + ref: '#/components/responses/Success', + description: 'Should not appear', + ); + + $serialized = $response->jsonSerialize(); + + self::assertCount(1, $serialized); + self::assertArrayHasKey('$ref', $serialized); + } + + private function createDocumentWithSchema(): OpenApiDocument + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"schemas":{"User":{"type":"object","title":"Original title","description":"Original description"}}},"paths":{}}'; + return $this->parser->parse($json); + } + + private function createDocumentWithParameter(): OpenApiDocument + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"parameters":{"Limit":{"name":"limit","in":"query","description":"Original description","schema":{"type":"integer"}}}},"paths":{}}'; + return $this->parser->parse($json); + } + + private function createDocumentWithResponse(): OpenApiDocument + { + $json = '{"openapi":"3.2.0","info":{"title":"Test","version":"1.0"},"components":{"responses":{"Success":{"summary":"Original summary","description":"Original description"}}},"paths":{}}'; + return $this->parser->parse($json); + } +} diff --git a/tests/Validator/Exception/InvalidParameterExceptionTest.php b/tests/Validator/Exception/InvalidParameterExceptionTest.php new file mode 100644 index 0000000..eeaf5a3 --- /dev/null +++ b/tests/Validator/Exception/InvalidParameterExceptionTest.php @@ -0,0 +1,62 @@ +assertSame('filter', $exception->parameterName); + $this->assertStringContainsString('filter', $exception->getMessage()); + $this->assertStringContainsString('Invalid configuration', $exception->getMessage()); + } + + #[Test] + public function creates_exception_with_empty_message(): void + { + $exception = new InvalidParameterException('test'); + + $this->assertSame('test', $exception->parameterName); + } + + #[Test] + public function creates_exception_with_code_and_previous(): void + { + $previous = new RuntimeException('Previous error'); + $exception = new InvalidParameterException('param', 'Error', 500, $previous); + + $this->assertSame('param', $exception->parameterName); + $this->assertSame(500, $exception->getCode()); + $this->assertSame($previous, $exception->getPrevious()); + } + + #[Test] + public function creates_invalid_configuration_exception(): void + { + $exception = InvalidParameterException::invalidConfiguration('filter', 'Invalid schema'); + + $this->assertSame('filter', $exception->parameterName); + $this->assertStringContainsString('Invalid schema', $exception->getMessage()); + } + + #[Test] + public function creates_malformed_value_exception(): void + { + $exception = InvalidParameterException::malformedValue('filter', 'Invalid JSON syntax'); + + $this->assertSame('filter', $exception->parameterName); + $this->assertStringContainsString('Malformed value', $exception->getMessage()); + $this->assertStringContainsString('Invalid JSON syntax', $exception->getMessage()); + } +} diff --git a/tests/Validator/OpenApiValidatorMethodsTest.php b/tests/Validator/OpenApiValidatorMethodsTest.php index 92516c4..4892424 100644 --- a/tests/Validator/OpenApiValidatorMethodsTest.php +++ b/tests/Validator/OpenApiValidatorMethodsTest.php @@ -285,4 +285,210 @@ public function validateResponse_with_trace_method(): void $validator->validateResponse($response, $operation); $this->expectNotToPerformAssertions(); } + + #[Test] + public function validateRequest_with_query_method(): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $request = new Psr17Factory() + ->createServerRequest('QUERY', '/search') + ->withHeader('Content-Type', 'application/json') + ->withBody(new Psr17Factory()->createStream('{"query":"test"}')); + + $operation = $validator->validateRequest($request); + $this->assertSame('QUERY', $operation->method); + } + + #[Test] + public function validateRequest_with_additional_operation_copy(): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $request = new Psr17Factory() + ->createServerRequest('COPY', '/resource'); + + $operation = $validator->validateRequest($request); + $this->assertSame('COPY', $operation->method); + } + + #[Test] + public function validateRequest_with_additional_operation_case_insensitive(): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $request = new Psr17Factory() + ->createServerRequest('copy', '/resource'); + + $operation = $validator->validateRequest($request); + $this->assertSame('copy', $operation->method); + } + + #[Test] + public function validateRequest_with_multiple_additional_operations(): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $copyRequest = new Psr17Factory()->createServerRequest('COPY', '/resource'); + $copyOperation = $validator->validateRequest($copyRequest); + $this->assertSame('COPY', $copyOperation->method); + + $moveRequest = new Psr17Factory()->createServerRequest('MOVE', '/resource'); + $moveOperation = $validator->validateRequest($moveRequest); + $this->assertSame('MOVE', $moveOperation->method); + + $purgeRequest = new Psr17Factory()->createServerRequest('PURGE', '/resource'); + $purgeOperation = $validator->validateRequest($purgeRequest); + $this->assertSame('PURGE', $purgeOperation->method); + } + + #[Test] + public function validateResponse_with_query_method(): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $operation = new Operation('/search', 'QUERY'); + $response = new Psr17Factory() + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody(new Psr17Factory()->createStream('{"results":[]}')); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validateResponse_with_additional_operation(): void + { + $yaml = <<fromYamlString($yaml) + ->build(); + + $operation = new Operation('/resource', 'COPY'); + $response = new Psr17Factory() + ->createResponse(201); + + $validator->validateResponse($response, $operation); + $this->expectNotToPerformAssertions(); + } } diff --git a/tests/Validator/Request/CookieValidatorTest.php b/tests/Validator/Request/CookieValidatorTest.php index a613fc6..711104b 100644 --- a/tests/Validator/Request/CookieValidatorTest.php +++ b/tests/Validator/Request/CookieValidatorTest.php @@ -6,6 +6,7 @@ use Duyler\OpenApi\Schema\Model\Parameter; use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidParameterException; use Duyler\OpenApi\Validator\Exception\MissingParameterException; use Duyler\OpenApi\Validator\Exception\MinLengthError; use Duyler\OpenApi\Validator\Exception\MaxLengthError; @@ -478,4 +479,414 @@ public function parse_cookies_semicolons_only(): void $this->assertSame([], $result); } + + #[Test] + public function parse_cookie_style_simple_value(): void + { + $parameter = new Parameter( + name: 'session', + in: 'cookie', + style: 'cookie', + ); + + $result = $this->validator->parseCookieStyle('session=abc123', $parameter); + + $this->assertSame('abc123', $result); + } + + #[Test] + public function parse_cookie_style_with_explode_false(): void + { + $parameter = new Parameter( + name: 'ids', + in: 'cookie', + style: 'cookie', + explode: false, + schema: new Schema(type: 'array'), + ); + + $result = $this->validator->parseCookieStyle('ids=1,2,3', $parameter); + + $this->assertSame(['1', '2', '3'], $result); + } + + #[Test] + public function parse_cookie_style_with_explode_false_string_value(): void + { + $parameter = new Parameter( + name: 'message', + in: 'cookie', + style: 'cookie', + explode: false, + schema: new Schema(type: 'string'), + ); + + $result = $this->validator->parseCookieStyle('message=Hello, world!', $parameter); + + $this->assertSame('Hello, world!', $result); + } + + #[Test] + public function parse_cookie_style_with_explode_true(): void + { + $parameter = new Parameter( + name: 'tags', + in: 'cookie', + style: 'cookie', + explode: true, + ); + + $result = $this->validator->parseCookieStyle('tags=a;tags=b;tags=c', $parameter); + + $this->assertSame(['a', 'b', 'c'], $result); + } + + #[Test] + public function parse_cookie_style_url_encoded(): void + { + $parameter = new Parameter( + name: 'data', + in: 'cookie', + style: 'cookie', + ); + + $result = $this->validator->parseCookieStyle('data=hello%20world', $parameter); + + $this->assertSame('hello world', $result); + } + + #[Test] + public function parse_cookie_style_missing_parameter(): void + { + $parameter = new Parameter( + name: 'missing', + in: 'cookie', + style: 'cookie', + ); + + $result = $this->validator->parseCookieStyle('other=value', $parameter); + + $this->assertNull($result); + } + + #[Test] + public function parse_cookie_style_multiple_cookies(): void + { + $parameter = new Parameter( + name: 'user', + in: 'cookie', + style: 'cookie', + ); + + $result = $this->validator->parseCookieStyle('session=abc;user=john;token=xyz', $parameter); + + $this->assertSame('john', $result); + } + + #[Test] + public function form_style_remains_backward_compatible(): void + { + $parameter = new Parameter( + name: 'session', + in: 'cookie', + style: 'form', + ); + + $result = $this->validator->parseCookieStyle('session=abc123', $parameter); + + $this->assertSame('abc123', $result); + } + + #[Test] + public function parse_cookie_style_empty_header(): void + { + $parameter = new Parameter( + name: 'session', + in: 'cookie', + style: 'cookie', + ); + + $result = $this->validator->parseCookieStyle('', $parameter); + + $this->assertNull($result); + } + + #[Test] + public function parse_cookie_style_whitespace_only_header(): void + { + $parameter = new Parameter( + name: 'session', + in: 'cookie', + style: 'cookie', + ); + + $result = $this->validator->parseCookieStyle(' ', $parameter); + + $this->assertNull($result); + } + + #[Test] + public function parse_cookie_style_default_is_form(): void + { + $parameter = new Parameter( + name: 'session', + in: 'cookie', + ); + + $result = $this->validator->parseCookieStyle('session=abc123', $parameter); + + $this->assertSame('abc123', $result); + } + + #[Test] + public function parse_cookie_style_explode_url_encoded(): void + { + $parameter = new Parameter( + name: 'tags', + in: 'cookie', + style: 'cookie', + explode: true, + ); + + $result = $this->validator->parseCookieStyle('tags=hello%20world;tags=foo%2Bbar', $parameter); + + $this->assertSame(['hello world', 'foo+bar'], $result); + } + + #[Test] + public function parse_cookie_style_explode_mixed_cookies(): void + { + $parameter = new Parameter( + name: 'tags', + in: 'cookie', + style: 'cookie', + explode: true, + ); + + $result = $this->validator->parseCookieStyle('session=abc;tags=x;user=john;tags=y', $parameter); + + $this->assertSame(['x', 'y'], $result); + } + + #[Test] + public function parse_cookie_style_invalid_style_throws_exception(): void + { + $parameter = new Parameter( + name: 'session', + in: 'cookie', + style: 'matrix', + ); + + $this->expectException(InvalidParameterException::class); + + $this->validator->parseCookieStyle('session=abc123', $parameter); + } + + #[Test] + public function parse_cookie_style_comma_separated_with_spaces(): void + { + $parameter = new Parameter( + name: 'tags', + in: 'cookie', + style: 'cookie', + explode: false, + schema: new Schema(type: 'array'), + ); + + $result = $this->validator->parseCookieStyle('tags=a, b, c', $parameter); + + $this->assertSame(['a', ' b', ' c'], $result); + } + + #[Test] + public function parse_cookie_style_explode_single_value(): void + { + $parameter = new Parameter( + name: 'tag', + in: 'cookie', + style: 'cookie', + explode: true, + ); + + $result = $this->validator->parseCookieStyle('tag=onlyone', $parameter); + + $this->assertSame('onlyone', $result); + } + + #[Test] + public function parse_cookie_style_special_characters(): void + { + $parameter = new Parameter( + name: 'data', + in: 'cookie', + style: 'cookie', + ); + + $result = $this->validator->parseCookieStyle('data=%7B%22key%22%3A%22value%22%7D', $parameter); + + $this->assertSame('{"key":"value"}', $result); + } + + #[Test] + public function validate_with_header_style_cookie(): void + { + $cookies = ['session' => 'abc123']; + $parameterSchemas = [ + new Parameter( + name: 'session', + in: 'cookie', + style: 'cookie', + schema: new Schema(type: 'string'), + ), + ]; + + $this->validator->validateWithHeader($cookies, 'session=abc123', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_header_explode_true(): void + { + $cookies = []; + $parameterSchemas = [ + new Parameter( + name: 'tags', + in: 'cookie', + style: 'cookie', + explode: true, + schema: new Schema(type: 'array', items: new Schema(type: 'string')), + ), + ]; + + $this->validator->validateWithHeader($cookies, 'tags=a;tags=b;tags=c', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_header_url_encoded(): void + { + $cookies = []; + $parameterSchemas = [ + new Parameter( + name: 'data', + in: 'cookie', + style: 'cookie', + schema: new Schema(type: 'string'), + ), + ]; + + $this->validator->validateWithHeader($cookies, 'data=hello%20world', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_header_missing_required(): void + { + $cookies = []; + $parameterSchemas = [ + new Parameter( + name: 'session', + in: 'cookie', + style: 'cookie', + required: true, + ), + ]; + + $this->expectException(MissingParameterException::class); + + $this->validator->validateWithHeader($cookies, '', $parameterSchemas); + } + + #[Test] + public function validate_with_header_form_style_backward_compatible(): void + { + $cookies = ['session' => 'abc123']; + $parameterSchemas = [ + new Parameter( + name: 'session', + in: 'cookie', + style: 'form', + schema: new Schema(type: 'string'), + ), + ]; + + $this->validator->validateWithHeader($cookies, 'session=abc123', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_header_comma_separated_array(): void + { + $cookies = []; + $parameterSchemas = [ + new Parameter( + name: 'ids', + in: 'cookie', + style: 'cookie', + schema: new Schema(type: 'array', items: new Schema(type: 'string')), + ), + ]; + + $this->validator->validateWithHeader($cookies, 'ids=1,2,3', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_header_uses_parsed_cookies_for_form_style(): void + { + $cookies = ['session' => 'from_parsed']; + $parameterSchemas = [ + new Parameter( + name: 'session', + in: 'cookie', + style: 'form', + schema: new Schema(type: 'string'), + ), + ]; + + $this->validator->validateWithHeader($cookies, 'session=from_header', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_header_form_style_url_decoded(): void + { + $cookies = []; + $parameterSchemas = [ + new Parameter( + name: 'data', + in: 'cookie', + style: 'form', + schema: new Schema(type: 'string'), + ), + ]; + + $this->validator->validateWithHeader($cookies, 'data=hello%20world', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_header_fallback_url_decoded(): void + { + $cookies = ['data' => 'hello%20world']; + $parameterSchemas = [ + new Parameter( + name: 'data', + in: 'cookie', + style: 'form', + schema: new Schema(type: 'string'), + ), + ]; + + $this->validator->validateWithHeader($cookies, '', $parameterSchemas); + + $this->expectNotToPerformAssertions(); + } } diff --git a/tests/Validator/Request/ParameterDeserializerTest.php b/tests/Validator/Request/ParameterDeserializerTest.php index c96b7c7..83c2711 100644 --- a/tests/Validator/Request/ParameterDeserializerTest.php +++ b/tests/Validator/Request/ParameterDeserializerTest.php @@ -228,4 +228,13 @@ public function deserialize_with_cookie_default_style(): void $this->assertSame('value', $result); } + + #[Test] + public function deserialize_with_cookie_style(): void + { + $param = new Parameter(name: 'session', in: 'cookie', style: 'cookie'); + $result = $this->deserializer->deserialize('abc123', $param); + + $this->assertSame('abc123', $result); + } } diff --git a/tests/Validator/Request/QueryParserTest.php b/tests/Validator/Request/QueryParserTest.php index c373c11..f8a8948 100644 --- a/tests/Validator/Request/QueryParserTest.php +++ b/tests/Validator/Request/QueryParserTest.php @@ -4,6 +4,12 @@ namespace Duyler\OpenApi\Test\Validator\Request; +use Duyler\OpenApi\Schema\Model\Content; +use Duyler\OpenApi\Schema\Model\MediaType; +use Duyler\OpenApi\Schema\Model\Parameter; +use Duyler\OpenApi\Schema\Model\Schema; +use Duyler\OpenApi\Validator\Exception\InvalidParameterException; +use Duyler\OpenApi\Validator\Exception\UnsupportedMediaTypeException; use Duyler\OpenApi\Validator\Request\QueryParser; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -65,4 +71,159 @@ public function handle_explode_false(): void $this->assertSame('1,2,3', $result); } + + #[Test] + public function parse_query_string_json(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $result = $this->parser->parseQueryString('{"name":"John","age":30}', $parameter); + + $this->assertSame(['name' => 'John', 'age' => 30], $result); + } + + #[Test] + public function parse_query_string_json_url_encoded(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $result = $this->parser->parseQueryString('%7B%22name%22%3A%22John%22%2C%22age%22%3A30%7D', $parameter); + + $this->assertSame(['name' => 'John', 'age' => 30], $result); + } + + #[Test] + public function parse_query_string_plain_text(): void + { + $parameter = new Parameter( + name: 'data', + in: 'querystring', + content: new Content([ + 'text/plain' => new MediaType( + schema: new Schema(type: 'string'), + ), + ]), + ); + + $result = $this->parser->parseQueryString('raw text value', $parameter); + + $this->assertSame('raw text value', $result); + } + + #[Test] + public function parse_query_string_non_querystring_param(): void + { + $parameter = new Parameter( + name: 'foo', + in: 'query', + schema: new Schema(type: 'string'), + ); + + $result = $this->parser->parseQueryString('anything', $parameter); + + $this->assertNull($result); + } + + #[Test] + public function parse_query_string_without_content(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + ); + + $result = $this->parser->parseQueryString('{"name":"John"}', $parameter); + + $this->assertNull($result); + } + + #[Test] + public function parse_query_string_invalid_json(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Malformed value'); + + $this->parser->parseQueryString('not valid json', $parameter); + } + + #[Test] + public function parse_query_string_unsupported_media_type(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/xml' => new MediaType( + schema: new Schema(type: 'string'), + ), + ]), + ); + + $this->expectException(UnsupportedMediaTypeException::class); + + $this->parser->parseQueryString('', $parameter); + } + + #[Test] + public function parse_query_string_json_array(): void + { + $parameter = new Parameter( + name: 'ids', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'array', items: new Schema(type: 'integer')), + ), + ]), + ); + + $result = $this->parser->parseQueryString('[1,2,3,4,5]', $parameter); + + $this->assertSame([1, 2, 3, 4, 5], $result); + } + + #[Test] + public function parse_query_string_empty_string(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Malformed value'); + + $this->parser->parseQueryString('', $parameter); + } } diff --git a/tests/Validator/Request/QueryStringValidatorTest.php b/tests/Validator/Request/QueryStringValidatorTest.php new file mode 100644 index 0000000..9f5b14f --- /dev/null +++ b/tests/Validator/Request/QueryStringValidatorTest.php @@ -0,0 +1,270 @@ +queryParser = new QueryParser(); + + $this->validator = new QueryStringValidator( + queryParser: $this->queryParser, + schemaValidator: $schemaValidator, + ); + } + + #[Test] + public function accepts_valid_querystring_parameter(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $this->validator->validateParameter($parameter); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function rejects_querystring_with_schema(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + schema: new Schema(type: 'string'), + ); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage("must use 'content' field"); + + $this->validator->validateParameter($parameter); + } + + #[Test] + public function rejects_querystring_without_content(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + ); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage("requires 'content' field"); + + $this->validator->validateParameter($parameter); + } + + #[Test] + public function rejects_querystring_with_empty_content(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([]), + ); + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage("requires 'content' field"); + + $this->validator->validateParameter($parameter); + } + + #[Test] + public function validates_json_querystring(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string'), + ], + required: ['name'], + ), + ), + ]), + ); + + $queryString = '{"name":"John"}'; + + $this->validator->validate($queryString, [$parameter]); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validates_querystring_with_integer(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema( + type: 'object', + properties: [ + 'age' => new Schema(type: 'integer', minimum: 0), + ], + ), + ), + ]), + ); + + $queryString = '{"age":30}'; + + $this->validator->validate($queryString, [$parameter]); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function rejects_invalid_json_querystring_value(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema( + type: 'object', + properties: [ + 'age' => new Schema(type: 'integer', minimum: 0), + ], + ), + ), + ]), + ); + + $queryString = '{"age":-5}'; + + $this->expectException(MinimumError::class); + + $this->validator->validate($queryString, [$parameter]); + } + + #[Test] + public function rejects_missing_required_querystring(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + required: true, + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $this->expectException(MissingParameterException::class); + + $this->validator->validate('', [$parameter]); + } + + #[Test] + public function allows_missing_optional_querystring(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + required: false, + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $this->validator->validate('', [$parameter]); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function skips_non_querystring_parameters(): void + { + $queryParam = new Parameter( + name: 'foo', + in: 'query', + schema: new Schema(type: 'string'), + ); + + $this->validator->validate('foo=bar', [$queryParam]); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validates_plain_text_querystring(): void + { + $parameter = new Parameter( + name: 'data', + in: 'querystring', + content: new Content([ + 'text/plain' => new MediaType( + schema: new Schema(type: 'string'), + ), + ]), + ); + + $queryString = 'some plain text data'; + + $this->validator->validate($queryString, [$parameter]); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function rejects_invalid_json(): void + { + $parameter = new Parameter( + name: 'filter', + in: 'querystring', + required: true, + content: new Content([ + 'application/json' => new MediaType( + schema: new Schema(type: 'object'), + ), + ]), + ); + + $queryString = 'not valid json'; + + $this->expectException(InvalidParameterException::class); + $this->expectExceptionMessage('Malformed value'); + + $this->validator->validate($queryString, [$parameter]); + } +} diff --git a/tests/Validator/Request/RequestValidatorIntegrationTest.php b/tests/Validator/Request/RequestValidatorIntegrationTest.php index a616292..9c978a2 100644 --- a/tests/Validator/Request/RequestValidatorIntegrationTest.php +++ b/tests/Validator/Request/RequestValidatorIntegrationTest.php @@ -24,6 +24,7 @@ use Duyler\OpenApi\Validator\Request\PathParser; use Duyler\OpenApi\Validator\Request\QueryParametersValidator; use Duyler\OpenApi\Validator\Request\QueryParser; +use Duyler\OpenApi\Validator\Request\QueryStringValidator; use Duyler\OpenApi\Validator\Request\RequestBodyValidator; use Duyler\OpenApi\Validator\Request\RequestValidator; use Duyler\OpenApi\Validator\Request\TypeCoercer; @@ -69,11 +70,14 @@ protected function setUp(): void $xmlParser, ); + $queryStringValidator = new QueryStringValidator($queryParser, $schemaValidator); + $this->validator = new RequestValidator( $pathParser, $pathParamsValidator, $queryParser, $queryParamsValidator, + $queryStringValidator, $headersValidator, $cookieValidator, $bodyValidator, diff --git a/tests/Validator/Response/ResponseBodyValidatorTest.php b/tests/Validator/Response/ResponseBodyValidatorTest.php index 142f613..9586585 100644 --- a/tests/Validator/Response/ResponseBodyValidatorTest.php +++ b/tests/Validator/Response/ResponseBodyValidatorTest.php @@ -429,4 +429,194 @@ public function skip_validation_when_media_type_schema_is_null(): void $this->expectNotToPerformAssertions(); } + + #[Test] + public function validate_jsonl_streaming_response(): void + { + $body = '{"id":1,"name":"Item1"}' . "\n" . '{"id":2,"name":"Item2"}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + 'name' => new Schema(type: 'string'), + ], + required: ['id', 'name'], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_ndjson_streaming_response(): void + { + $body = '{"count":1}' . "\n" . '{"count":2}'; + $contentType = 'application/x-ndjson'; + $content = new Content([ + 'application/x-ndjson' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'count' => new Schema(type: 'integer'), + ], + required: ['count'], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_json_seq_streaming_response(): void + { + $body = "\x1E" . '{"id":"1"}' . "\x1E" . '{"id":"2"}'; + $contentType = 'application/json-seq'; + $content = new Content([ + 'application/json-seq' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'string'), + ], + required: ['id'], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_sse_streaming_response(): void + { + $body = "event: message\n" . "data: {\"text\":\"hello\"}\n\n"; + $contentType = 'text/event-stream'; + $content = new Content([ + 'text/event-stream' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'event' => new Schema(type: 'string'), + 'data' => new Schema(type: 'object'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_streaming_uses_schema_when_item_schema_not_defined(): void + { + $body = '{"fallback":true}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + schema: new Schema( + type: 'object', + properties: [ + 'fallback' => new Schema(type: 'boolean'), + ], + required: ['fallback'], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function skip_streaming_validation_when_no_schema(): void + { + $body = '{"data":"value"}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType(), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_with_empty_lines(): void + { + $body = '{"id":1}' . "\n\n" . '{"id":2}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + required: ['id'], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_with_invalid_json_line(): void + { + $body = '{"id":1}' . "\n" . 'invalid json' . "\n" . '{"id":2}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_invalid_item_throws_error(): void + { + $body = '{"id":"not_an_integer"}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + required: ['id'], + ), + ), + ]); + + $this->expectException(TypeMismatchError::class); + + $this->validator->validate($body, $contentType, $content); + } } diff --git a/tests/Validator/Response/ResponseBodyValidatorWithContextTest.php b/tests/Validator/Response/ResponseBodyValidatorWithContextTest.php new file mode 100644 index 0000000..d332104 --- /dev/null +++ b/tests/Validator/Response/ResponseBodyValidatorWithContextTest.php @@ -0,0 +1,488 @@ +validator = new ResponseBodyValidatorWithContext( + pool: $pool, + document: $document, + bodyParser: $bodyParser, + negotiator: $negotiator, + typeCoercer: $typeCoercer, + coercion: false, + nullableAsType: true, + emptyArrayStrategy: EmptyArrayStrategy::AllowBoth, + ); + } + + #[Test] + public function validate_json_response(): void + { + $body = '{"id":123,"name":"John"}'; + $contentType = 'application/json'; + $content = new Content([ + 'application/json' => new MediaType( + schema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + 'name' => new Schema(type: 'string'), + ], + required: ['id', 'name'], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function skip_validation_when_content_is_null(): void + { + $body = '{"id":123}'; + $contentType = 'application/json'; + + $this->validator->validate($body, $contentType, null); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function skip_validation_when_media_type_not_found(): void + { + $body = '{"id":123}'; + $contentType = 'application/xml'; + $content = new Content([ + 'application/json' => new MediaType(), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_jsonl_streaming_response(): void + { + $body = '{"id":1}' . "\n" . '{"id":2}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + required: ['id'], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_ndjson_streaming_response(): void + { + $body = '{"count":1}' . "\n" . '{"count":2}'; + $contentType = 'application/x-ndjson'; + $content = new Content([ + 'application/x-ndjson' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'count' => new Schema(type: 'integer'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_json_seq_streaming_response(): void + { + $body = "\x1E" . '{"id":"1"}' . "\x1E" . '{"id":"2"}'; + $contentType = 'application/json-seq'; + $content = new Content([ + 'application/json-seq' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'string'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_streaming_uses_schema_when_item_schema_not_defined(): void + { + $body = '{"fallback":true}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + schema: new Schema( + type: 'object', + properties: [ + 'fallback' => new Schema(type: 'boolean'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function skip_streaming_validation_when_no_schema(): void + { + $body = '{"data":"value"}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType(), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_with_invalid_json_line(): void + { + $body = '{"id":1}' . "\n" . 'invalid json'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_invalid_item_throws_error(): void + { + $body = '{"id":"not_an_integer"}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + required: ['id'], + ), + ), + ]); + + $this->expectException(TypeMismatchError::class); + + $this->validator->validate($body, $contentType, $content); + } + + #[Test] + public function streaming_with_empty_body(): void + { + $body = ''; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema(type: 'object'), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_unknown_content_type(): void + { + $body = '{"data":"value"}'; + $contentType = 'application/unknown-stream'; + $content = new Content([ + 'application/unknown-stream' => new MediaType( + itemSchema: new Schema(type: 'object'), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_with_coercion_enabled(): void + { + $pool = new ValidatorPool(); + $document = new OpenApiDocument( + openapi: '3.2.0', + info: new InfoObject( + title: 'Test API', + version: '1.0.0', + ), + ); + $negotiator = new ContentTypeNegotiator(); + $jsonParser = new JsonBodyParser(); + $formParser = new FormBodyParser(); + $multipartParser = new MultipartBodyParser(); + $textParser = new TextBodyParser(); + $xmlParser = new XmlBodyParser(); + $typeCoercer = new ResponseTypeCoercer(); + $bodyParser = new BodyParser($jsonParser, $formParser, $multipartParser, $textParser, $xmlParser); + + $validator = new ResponseBodyValidatorWithContext( + pool: $pool, + document: $document, + bodyParser: $bodyParser, + negotiator: $negotiator, + typeCoercer: $typeCoercer, + coercion: true, + nullableAsType: true, + emptyArrayStrategy: EmptyArrayStrategy::AllowBoth, + ); + + $body = '{"id":"123"}'; + $contentType = 'application/json'; + $content = new Content([ + 'application/json' => new MediaType( + schema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + ), + ), + ]); + + $validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_with_item_encoding(): void + { + $body = '{"id":1}' . "\n" . '{"id":2}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + ), + itemEncoding: new Encoding( + contentType: 'application/jsonl', + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_json_seq_with_record_separator(): void + { + $body = "\x1E" . '{"record":"first"}' . "\n\x1E" . '{"record":"second"}'; + $contentType = 'application/json-seq'; + $content = new Content([ + 'application/json-seq' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'record' => new Schema(type: 'string'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_json_seq_invalid_json(): void + { + $body = "\x1E" . 'invalid json'; + $contentType = 'application/json-seq'; + $content = new Content([ + 'application/json-seq' => new MediaType( + itemSchema: new Schema(type: 'object'), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function streaming_with_null_item_in_list(): void + { + $body = '{"id":1}' . "\n" . 'null' . "\n" . '{"id":2}'; + $contentType = 'application/jsonl'; + $content = new Content([ + 'application/jsonl' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_sse_streaming_response(): void + { + $body = "event: message\n" . "data: {\"text\":\"hello\"}\n\n"; + $contentType = 'text/event-stream'; + $content = new Content([ + 'text/event-stream' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'event' => new Schema(type: 'string'), + 'data' => new Schema(type: 'object'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_sse_with_multiple_events(): void + { + $body = "event: message\ndata: {\"count\":1}\n\nevent: update\ndata: {\"count\":2}\n\n"; + $contentType = 'text/event-stream'; + $content = new Content([ + 'text/event-stream' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'event' => new Schema(type: 'string'), + 'data' => new Schema( + type: 'object', + properties: [ + 'count' => new Schema(type: 'integer'), + ], + ), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_sse_with_id_and_comments(): void + { + $body = ": this is a comment\nid: 123\nevent: message\ndata: {\"text\":\"test\"}\n\n"; + $contentType = 'text/event-stream'; + $content = new Content([ + 'text/event-stream' => new MediaType( + itemSchema: new Schema( + type: 'object', + properties: [ + 'event' => new Schema(type: 'string'), + 'data' => new Schema(type: 'object'), + 'id' => new Schema(type: 'string'), + ], + ), + ), + ]); + + $this->validator->validate($body, $contentType, $content); + + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/Validator/Response/StreamingContentParserTest.php b/tests/Validator/Response/StreamingContentParserTest.php new file mode 100644 index 0000000..35bb225 --- /dev/null +++ b/tests/Validator/Response/StreamingContentParserTest.php @@ -0,0 +1,257 @@ +parser = new StreamingContentParser(); + } + + #[Test] + public function parse_json_lines(): void + { + $body = "{\"id\":1,\"name\":\"Alice\"}\n{\"id\":2,\"name\":\"Bob\"}"; + + $result = $this->parser->parse($body, 'application/jsonl'); + + self::assertCount(2, $result); + self::assertSame(['id' => 1, 'name' => 'Alice'], $result[0]); + self::assertSame(['id' => 2, 'name' => 'Bob'], $result[1]); + } + + #[Test] + public function parse_json_lines_with_invalid_line(): void + { + $body = "{\"valid\":true}\ninvalid json\n{\"also\":\"valid\"}"; + + $result = $this->parser->parse($body, 'application/x-ndjson'); + + self::assertCount(3, $result); + self::assertSame(['valid' => true], $result[0]); + self::assertNull($result[1]); + self::assertSame(['also' => 'valid'], $result[2]); + } + + #[Test] + public function parse_server_sent_events(): void + { + $body = "event: message\ndata: {\"text\":\"hello\"}\n\n"; + + $result = $this->parser->parse($body, 'text/event-stream'); + + self::assertCount(1, $result); + self::assertSame('message', $result[0]['event']); + self::assertSame(['text' => 'hello'], $result[0]['data']); + } + + #[Test] + public function parse_server_sent_events_multiple(): void + { + $body = "event: message\ndata: first\n\nevent: message\ndata: second\n\n"; + + $result = $this->parser->parse($body, 'text/event-stream'); + + self::assertCount(2, $result); + self::assertSame('first', $result[0]['data']); + self::assertSame('second', $result[1]['data']); + } + + #[Test] + public function parse_server_sent_events_with_id(): void + { + $body = "id: 123\nevent: update\ndata: test\n\n"; + + $result = $this->parser->parse($body, 'text/event-stream'); + + self::assertSame('123', $result[0]['id']); + self::assertSame('update', $result[0]['event']); + } + + #[Test] + public function parse_server_sent_events_ignores_comments(): void + { + $body = ": this is a comment\nevent: test\ndata: value\n\n"; + + $result = $this->parser->parse($body, 'text/event-stream'); + + self::assertCount(1, $result); + self::assertArrayNotHasKey('this is a comment', $result[0]); + } + + #[Test] + public function parse_json_sequence(): void + { + $body = "\x1E{\"id\":1}\x1E{\"id\":2}\x1E"; + + $result = $this->parser->parse($body, 'application/json-seq'); + + self::assertCount(2, $result); + self::assertSame(['id' => 1], $result[0]); + self::assertSame(['id' => 2], $result[1]); + } + + #[Test] + public function parse_json_sequence_with_invalid(): void + { + $body = "\x1E{\"valid\":true}\x1Einvalid\x1E{\"also\":\"valid\"}\x1E"; + + $result = $this->parser->parse($body, 'application/json-seq'); + + self::assertCount(3, $result); + self::assertSame(['valid' => true], $result[0]); + self::assertNull($result[1]); + self::assertSame(['also' => 'valid'], $result[2]); + } + + #[Test] + public function parse_empty_body(): void + { + $result = $this->parser->parse('', 'application/jsonl'); + + self::assertSame([], $result); + } + + #[Test] + public function parse_unknown_content_type(): void + { + $result = $this->parser->parse('some data', 'text/plain'); + + self::assertSame([], $result); + } + + #[Test] + public function parse_json_lines_empty_lines_ignored(): void + { + $body = "{\"a\":1}\n\n\n{\"b\":2}\n"; + + $result = $this->parser->parse($body, 'application/jsonl'); + + self::assertCount(2, $result); + } + + #[Test] + public function parse_json_lines_x_ndjson_content_type(): void + { + $body = "{\"item\":1}\n{\"item\":2}"; + + $result = $this->parser->parse($body, 'application/x-ndjson'); + + self::assertCount(2, $result); + } + + #[Test] + public function parse_server_sent_events_with_plaintext_data(): void + { + $body = "data: plain text message\n\n"; + + $result = $this->parser->parse($body, 'text/event-stream'); + + self::assertCount(1, $result); + self::assertSame('plain text message', $result[0]['data']); + } + + #[Test] + public function parse_server_sent_events_empty_lines_between_events(): void + { + $body = "data: first\n\n\ndata: second\n\n"; + + $result = $this->parser->parse($body, 'text/event-stream'); + + self::assertCount(2, $result); + self::assertSame('first', $result[0]['data']); + self::assertSame('second', $result[1]['data']); + } + + #[Test] + public function parse_json_sequence_without_trailing_separator(): void + { + $body = "\x1E{\"a\":1}\x1E{\"b\":2}"; + + $result = $this->parser->parse($body, 'application/json-seq'); + + self::assertCount(2, $result); + self::assertSame(['a' => 1], $result[0]); + self::assertSame(['b' => 2], $result[1]); + } + + #[Test] + public function parse_json_sequence_empty_items_skipped(): void + { + $body = "\x1E\x1E{\"valid\":true}\x1E\x1E"; + + $result = $this->parser->parse($body, 'application/json-seq'); + + self::assertCount(1, $result); + self::assertSame(['valid' => true], $result[0]); + } + + #[Test] + public function parse_server_sent_events_with_multiple_data_fields(): void + { + $body = "data: line1\ndata: line2\n\n"; + + $result = $this->parser->parse($body, 'text/event-stream'); + + self::assertCount(1, $result); + self::assertSame('line2', $result[0]['data']); + } + + #[Test] + public function parse_json_lines_with_charset_in_content_type(): void + { + $body = "{\"test\":1}"; + + $result = $this->parser->parse($body, 'application/jsonl; charset=utf-8'); + + self::assertCount(1, $result); + self::assertSame(['test' => 1], $result[0]); + } + + #[Test] + public function parse_json_lines_direct_call(): void + { + $body = "{\"direct\":1}\n{\"direct\":2}"; + + $result = $this->parser->parseJsonLines($body); + + self::assertCount(2, $result); + self::assertSame(['direct' => 1], $result[0]); + self::assertSame(['direct' => 2], $result[1]); + } + + #[Test] + public function parse_server_sent_events_direct_call(): void + { + $body = "event: test\ndata: value\n\n"; + + $result = $this->parser->parseServerSentEvents($body); + + self::assertCount(1, $result); + self::assertSame('test', $result[0]['event']); + self::assertSame('value', $result[0]['data']); + } + + #[Test] + public function parse_json_seq_direct_call(): void + { + $body = "\x1E{\"direct\":1}\x1E{\"direct\":2}"; + + $result = $this->parser->parseJsonSeq($body); + + self::assertCount(2, $result); + self::assertSame(['direct' => 1], $result[0]); + self::assertSame(['direct' => 2], $result[1]); + } +} diff --git a/tests/Validator/Response/StreamingMediaTypeDetectorTest.php b/tests/Validator/Response/StreamingMediaTypeDetectorTest.php new file mode 100644 index 0000000..3829306 --- /dev/null +++ b/tests/Validator/Response/StreamingMediaTypeDetectorTest.php @@ -0,0 +1,86 @@ +assertTrue(true); } + + #[Test] + public function validate_fallback_to_default_mapping(): void + { + $catSchema = new Schema( + title: 'Cat', + type: 'object', + properties: [ + 'name' => new Schema(type: 'string'), + 'petType' => new Schema(type: 'string'), + ], + ); + + $unknownPetSchema = new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string'), + 'petType' => new Schema(type: 'string'), + ], + ); + + $petSchema = new Schema( + discriminator: new Discriminator( + propertyName: 'petType', + mapping: [ + 'cat' => '#/components/schemas/Cat', + ], + defaultMapping: '#/components/schemas/UnknownPet', + ), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Pet API', '1.0.0'), + components: new Components( + schemas: [ + 'Pet' => $petSchema, + 'Cat' => $catSchema, + 'UnknownPet' => $unknownPetSchema, + ], + ), + ); + + $birdData = [ + 'name' => 'Tweety', + 'petType' => 'bird', + ]; + + $this->validator->validate($birdData, $petSchema, $document); + + $this->assertTrue(true); + } + + #[Test] + public function validate_without_property_name_uses_default_mapping(): void + { + $fallbackSchema = new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string'), + ], + ); + + $schema = new Schema( + discriminator: new Discriminator( + defaultMapping: '#/components/schemas/Fallback', + ), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Test API', '1.0.0'), + components: new Components( + schemas: [ + 'Test' => $schema, + 'Fallback' => $fallbackSchema, + ], + ), + ); + + $data = [ + 'name' => 'Something', + ]; + + $this->validator->validate($data, $schema, $document); + + $this->assertTrue(true); + } + + #[Test] + public function validate_without_property_name_and_without_default_mapping(): void + { + $schema = new Schema( + discriminator: new Discriminator(), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Test API', '1.0.0'), + ); + + $data = [ + 'name' => 'Something', + ]; + + $this->validator->validate($data, $schema, $document); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_mapping_fallback_to_default_mapping(): void + { + $catSchema = new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string'), + 'petType' => new Schema(type: 'string'), + ], + ); + + $unknownPetSchema = new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string'), + 'petType' => new Schema(type: 'string'), + ], + ); + + $petSchema = new Schema( + discriminator: new Discriminator( + propertyName: 'petType', + mapping: [ + 'cat' => '#/components/schemas/Cat', + ], + defaultMapping: '#/components/schemas/UnknownPet', + ), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Pet API', '1.0.0'), + components: new Components( + schemas: [ + 'Pet' => $petSchema, + 'Cat' => $catSchema, + 'UnknownPet' => $unknownPetSchema, + ], + ), + ); + + $dogData = [ + 'name' => 'Rex', + 'petType' => 'dog', + ]; + + $this->validator->validate($dogData, $petSchema, $document); + + $this->assertTrue(true); + } } diff --git a/tests/Validator/Schema/ItemsValidatorWithContextTest.php b/tests/Validator/Schema/ItemsValidatorWithContextTest.php index 2f30aac..5f8f920 100644 --- a/tests/Validator/Schema/ItemsValidatorWithContextTest.php +++ b/tests/Validator/Schema/ItemsValidatorWithContextTest.php @@ -10,6 +10,8 @@ use Duyler\OpenApi\Schema\Model\Schema; use Duyler\OpenApi\Schema\OpenApiDocument; use Duyler\OpenApi\Validator\Error\ValidationContext; +use Duyler\OpenApi\Validator\Exception\MissingDiscriminatorPropertyException; +use Duyler\OpenApi\Validator\Exception\UnknownDiscriminatorValueException; use Duyler\OpenApi\Validator\Exception\ValidationException; use Duyler\OpenApi\Validator\ValidatorPool; use PHPUnit\Framework\Attributes\Test; @@ -217,6 +219,9 @@ public function validate_items_with_discriminator_schema(): void type: 'object', discriminator: new Discriminator( propertyName: 'petType', + mapping: [ + 'cat' => '#/components/schemas/Pet', + ], ), ); @@ -300,6 +305,9 @@ public function validate_items_with_discriminator_in_nested_schema(): void type: 'object', discriminator: new Discriminator( propertyName: 'petType', + mapping: [ + 'cat' => '#/components/schemas/Pet', + ], ), ); @@ -334,4 +342,157 @@ public function validate_items_with_discriminator_in_nested_schema(): void $this->assertTrue(true); } + + #[Test] + public function validate_items_throws_missing_discriminator_property(): void + { + $this->expectNotToPerformAssertions(); + + $petSchema = new Schema( + type: 'object', + discriminator: new Discriminator( + propertyName: 'petType', + mapping: [ + 'cat' => '#/components/schemas/Pet', + ], + ), + ); + + $schema = new Schema( + type: 'array', + items: new Schema( + ref: '#/components/schemas/Pet', + ), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Pet API', '1.0.0'), + components: new Components( + schemas: [ + 'Pet' => $petSchema, + ], + ), + ); + + $validator = new ItemsValidatorWithContext( + $this->pool, + $this->refResolver, + $document, + ); + + try { + $data = [ + ['name' => 'Fluffy'], + ]; + $validator->validateWithContext($data, $schema, $this->context); + } catch (MissingDiscriminatorPropertyException|ValidationException) { + return; + } + } + + #[Test] + public function validate_items_throws_unknown_discriminator_value(): void + { + $this->expectNotToPerformAssertions(); + + $catSchema = new Schema( + type: 'object', + title: 'Cat', + properties: [ + 'petType' => new Schema(type: 'string'), + ], + ); + + $dogSchema = new Schema( + type: 'object', + title: 'Dog', + properties: [ + 'petType' => new Schema(type: 'string'), + ], + ); + + $petSchema = new Schema( + type: 'object', + discriminator: new Discriminator( + propertyName: 'petType', + mapping: [ + 'cat' => '#/components/schemas/Cat', + 'dog' => '#/components/schemas/Dog', + ], + ), + oneOf: [ + new Schema(ref: '#/components/schemas/Cat'), + new Schema(ref: '#/components/schemas/Dog'), + ], + ); + + $schema = new Schema( + type: 'array', + items: new Schema( + ref: '#/components/schemas/Pet', + ), + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Pet API', '1.0.0'), + components: new Components( + schemas: [ + 'Pet' => $petSchema, + 'Cat' => $catSchema, + 'Dog' => $dogSchema, + ], + ), + ); + + $validator = new ItemsValidatorWithContext( + $this->pool, + $this->refResolver, + $document, + ); + + try { + $data = [ + ['petType' => 'bird'], + ]; + $validator->validateWithContext($data, $schema, $this->context); + } catch (UnknownDiscriminatorValueException|ValidationException) { + return; + } + } + + #[Test] + public function validate_items_with_nullable_item_schema(): void + { + $itemSchema = new Schema(type: 'string', nullable: true); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $nullableContext = ValidationContext::create($this->pool, nullableAsType: true); + + $data = ['valid', null, 'also valid']; + + $this->validator->validateWithContext($data, $schema, $nullableContext); + + $this->assertTrue(true); + } + + #[Test] + public function validate_items_with_mixed_valid_and_invalid(): void + { + $itemSchema = new Schema(type: 'string'); + $schema = new Schema( + type: 'array', + items: $itemSchema, + ); + + $data = ['valid', 123, null, 'also valid']; + + $this->expectException(ValidationException::class); + + $this->validator->validateWithContext($data, $schema, $this->context); + } } diff --git a/tests/Validator/Schema/OneOfValidatorWithContextTest.php b/tests/Validator/Schema/OneOfValidatorWithContextTest.php new file mode 100644 index 0000000..7665e91 --- /dev/null +++ b/tests/Validator/Schema/OneOfValidatorWithContextTest.php @@ -0,0 +1,442 @@ +refResolver = new RefResolver(); + $this->pool = new ValidatorPool(); + $this->document = new OpenApiDocument( + '3.1.0', + new InfoObject('Test API', '1.0.0'), + ); + $this->context = ValidationContext::create($this->pool); + $this->validator = new OneOfValidatorWithContext( + $this->pool, + $this->refResolver, + $this->document, + ); + } + + #[Test] + public function validate_with_null_one_of(): void + { + $schema = new Schema(type: 'object'); + + $this->validator->validateWithContext(['name' => 'John'], $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_discriminator(): void + { + $userSchema = new Schema( + type: 'object', + title: 'user', + properties: [ + 'type' => new Schema(type: 'string'), + ], + ); + + $schema = new Schema( + type: 'object', + discriminator: new Discriminator( + propertyName: 'type', + mapping: [ + 'user' => '#/components/schemas/User', + ], + ), + oneOf: [ + new Schema(ref: '#/components/schemas/User'), + ], + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Test API', '1.0.0'), + components: new Components( + schemas: [ + 'User' => $userSchema, + ], + ), + ); + + $validator = new OneOfValidatorWithContext( + $this->pool, + $this->refResolver, + $document, + ); + + $data = ['type' => 'user']; + + $validator->validateWithContext($data, $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_without_discriminator_single_match(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'string'), + new Schema(type: 'integer'), + ], + ); + + $this->validator->validateWithContext('test', $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_without_discriminator_no_match(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'string'), + new Schema(type: 'integer'), + ], + ); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Exactly one of schemas must match'); + + $this->validator->validateWithContext([], $schema, $this->context); + } + + #[Test] + public function validate_without_discriminator_multiple_matches(): void + { + $schema = new Schema( + oneOf: [ + new Schema( + type: 'object', + properties: ['name' => new Schema(type: 'string')], + ), + new Schema( + type: 'object', + properties: ['id' => new Schema(type: 'integer')], + ), + ], + ); + + $data = ['name' => 'John', 'id' => 1]; + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('Data matches multiple schemas'); + + $this->validator->validateWithContext($data, $schema, $this->context); + } + + #[Test] + public function validate_with_discriminator_null_data_non_nullable(): void + { + $schema = new Schema( + type: 'object', + discriminator: new Discriminator(propertyName: 'type'), + oneOf: [ + new Schema(type: 'object'), + ], + ); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('data must be an object'); + + $this->validator->validateWithContext(null, $schema, $this->context); + } + + #[Test] + public function validate_with_discriminator_null_data_nullable(): void + { + $schema = new Schema( + type: 'object', + discriminator: new Discriminator(propertyName: 'type'), + oneOf: [ + new Schema(type: 'object', nullable: true), + ], + ); + + $nullableContext = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validateWithContext(null, $schema, $nullableContext); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_discriminator_non_array_data(): void + { + $schema = new Schema( + type: 'object', + discriminator: new Discriminator(propertyName: 'type'), + oneOf: [ + new Schema(type: 'object'), + ], + ); + + $this->expectException(ValidationException::class); + $this->expectExceptionMessage('data must be an object'); + + $this->validator->validateWithContext('string', $schema, $this->context); + } + + #[Test] + public function validate_with_use_discriminator_false(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'string'), + ], + ); + + $this->validator->validateWithContext('test', $schema, $this->context, useDiscriminator: false); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_discriminator_and_ref(): void + { + $catSchema = new Schema( + type: 'object', + title: 'Cat', + properties: [ + 'petType' => new Schema(type: 'string'), + 'name' => new Schema(type: 'string'), + ], + ); + + $petSchema = new Schema( + type: 'object', + properties: [ + 'petType' => new Schema(type: 'string'), + ], + discriminator: new Discriminator( + propertyName: 'petType', + mapping: [ + 'cat' => '#/components/schemas/Cat', + ], + ), + oneOf: [ + new Schema(ref: '#/components/schemas/Cat'), + ], + ); + + $document = new OpenApiDocument( + '3.1.0', + new InfoObject('Pet API', '1.0.0'), + components: new Components( + schemas: [ + 'Pet' => $petSchema, + 'Cat' => $catSchema, + ], + ), + ); + + $schema = new Schema( + ref: '#/components/schemas/Pet', + ); + + $validator = new OneOfValidatorWithContext( + $this->pool, + $this->refResolver, + $document, + ); + + $resolvedSchema = $this->refResolver->resolve('#/components/schemas/Pet', $document); + $data = ['petType' => 'cat', 'name' => 'Fluffy']; + + $validator->validateWithContext($data, $resolvedSchema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_context_breadcrumb_tracking(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'string'), + ], + ); + + $this->validator->validateWithContext('test', $schema, $this->context); + + $this->assertNotEmpty($this->context->breadcrumbs->currentPath()); + } + + #[Test] + public function validate_with_integer_in_one_of(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'integer'), + new Schema(type: 'string'), + ], + ); + + $this->validator->validateWithContext(42, $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_boolean_in_one_of(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'boolean'), + new Schema(type: 'string'), + ], + ); + + $this->validator->validateWithContext(true, $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_number_in_one_of(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'number'), + new Schema(type: 'string'), + ], + ); + + $this->validator->validateWithContext(3.14, $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_nullable_in_one_of(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'string'), + new Schema(type: 'integer', nullable: true), + ], + ); + + $nullableContext = ValidationContext::create($this->pool, nullableAsType: true); + + $this->validator->validateWithContext(null, $schema, $nullableContext); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_complex_object_in_one_of(): void + { + $schema = new Schema( + oneOf: [ + new Schema( + type: 'object', + properties: [ + 'name' => new Schema(type: 'string'), + 'email' => new Schema(type: 'string'), + ], + required: ['name', 'email'], + ), + new Schema( + type: 'object', + properties: [ + 'id' => new Schema(type: 'integer'), + ], + required: ['id'], + ), + ], + ); + + $data = ['name' => 'John', 'email' => 'john@example.com']; + + $this->validator->validateWithContext($data, $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_with_array_in_one_of(): void + { + $schema = new Schema( + oneOf: [ + new Schema( + type: 'array', + items: new Schema(type: 'string'), + ), + new Schema(type: 'string'), + ], + ); + + $data = ['a', 'b', 'c']; + + $this->validator->validateWithContext($data, $schema, $this->context); + + $this->assertTrue(true); + } + + #[Test] + public function validate_exception_contains_errors(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'string', minLength: 10), + new Schema(type: 'integer', minimum: 100), + ], + ); + + try { + $this->validator->validateWithContext('short', $schema, $this->context); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame('Exactly one of schemas must match, but none did', $e->getMessage()); + } + } + + #[Test] + public function validate_with_nested_one_of(): void + { + $this->expectNotToPerformAssertions(); + } + + #[Test] + public function validate_skips_non_schema_items(): void + { + $schema = new Schema( + oneOf: [ + new Schema(type: 'string'), + null, + 'invalid', + ], + ); + + $this->validator->validateWithContext('test', $schema, $this->context); + + $this->assertTrue(true); + } +} diff --git a/tests/Validator/Schema/RefResolverTest.php b/tests/Validator/Schema/RefResolverTest.php index c38e747..12333bc 100644 --- a/tests/Validator/Schema/RefResolverTest.php +++ b/tests/Validator/Schema/RefResolverTest.php @@ -12,6 +12,7 @@ use Duyler\OpenApi\Validator\Schema\Exception\UnresolvableRefException; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Duyler\OpenApi\Exception\RefResolutionException; /** * @internal @@ -481,4 +482,102 @@ public function nested_property_discriminator_returns_true(): void $this->assertTrue($this->resolver->schemaHasDiscriminator($topSchema, $document)); } + + #[Test] + public function get_base_uri_returns_self_from_document(): void + { + $document = new OpenApiDocument( + '3.2.0', + new InfoObject('Test API', '1.0.0'), + self: 'https://api.example.com/openapi.json', + ); + + $this->assertSame('https://api.example.com/openapi.json', $this->resolver->getBaseUri($document)); + } + + #[Test] + public function get_base_uri_returns_null_when_self_not_set(): void + { + $document = new OpenApiDocument( + '3.2.0', + new InfoObject('Test API', '1.0.0'), + ); + + $this->assertNull($this->resolver->getBaseUri($document)); + } + + #[Test] + public function resolve_relative_ref_using_self(): void + { + $document = new OpenApiDocument( + '3.2.0', + new InfoObject('Test API', '1.0.0'), + self: 'https://api.example.com/schemas/main.json', + ); + + $resolved = $this->resolver->resolveRelativeRef('schemas/user.yaml', $document); + + $this->assertSame('https://api.example.com/schemas/schemas/user.yaml', $resolved); + } + + #[Test] + public function throws_for_relative_ref_without_self(): void + { + $document = new OpenApiDocument( + '3.2.0', + new InfoObject('Test API', '1.0.0'), + ); + + $this->expectException(RefResolutionException::class); + $this->expectExceptionMessage("Cannot resolve relative reference 'schemas/user.yaml' without document \$self or base URI"); + + $this->resolver->resolveRelativeRef('schemas/user.yaml', $document); + } + + #[Test] + public function combines_uris_correctly(): void + { + $combined = $this->resolver->combineUris( + 'https://api.example.com/v1/openapi.json', + 'schemas/user.yaml', + ); + + $this->assertSame('https://api.example.com/v1/schemas/user.yaml', $combined); + } + + #[Test] + public function combines_uris_with_nested_path(): void + { + $combined = $this->resolver->combineUris( + 'https://api.example.com/schemas/v2/main.json', + 'components/responses.yaml', + ); + + $this->assertSame('https://api.example.com/schemas/v2/components/responses.yaml', $combined); + } + + #[Test] + public function combines_uris_with_relative_path(): void + { + $combined = $this->resolver->combineUris( + 'https://api.example.com/schemas/main.json', + '../common/types.yaml', + ); + + $this->assertSame('https://api.example.com/schemas/../common/types.yaml', $combined); + } + + #[Test] + public function resolves_relative_ref_from_nested_directory(): void + { + $document = new OpenApiDocument( + '3.2.0', + new InfoObject('Test API', '1.0.0'), + self: 'https://api.example.com/api/v2/openapi.json', + ); + + $resolved = $this->resolver->resolveRelativeRef('paths/users.yaml', $document); + + $this->assertSame('https://api.example.com/api/v2/paths/users.yaml', $resolved); + } } diff --git a/tests/Validator/Webhook/WebhookValidatorTest.php b/tests/Validator/Webhook/WebhookValidatorTest.php index 9ed3486..c1d27de 100644 --- a/tests/Validator/Webhook/WebhookValidatorTest.php +++ b/tests/Validator/Webhook/WebhookValidatorTest.php @@ -30,6 +30,7 @@ use Duyler\OpenApi\Validator\Request\PathParser; use Duyler\OpenApi\Validator\Request\QueryParametersValidator; use Duyler\OpenApi\Validator\Request\QueryParser; +use Duyler\OpenApi\Validator\Request\QueryStringValidator; use Duyler\OpenApi\Validator\Request\RequestBodyValidator; use Duyler\OpenApi\Validator\Request\RequestValidator; use Duyler\OpenApi\Validator\Request\TypeCoercer; @@ -79,11 +80,14 @@ protected function setUp(): void $xmlParser, ); + $queryStringValidator = new QueryStringValidator($queryParser, $schemaValidator); + $requestValidator = new RequestValidator( $pathParser, $pathParamsValidator, $queryParser, $queryParamsValidator, + $queryStringValidator, $headersValidator, $cookieValidator, $bodyValidator, diff --git a/tests/fixtures/openapi-3.2-basic.yaml b/tests/fixtures/openapi-3.2-basic.yaml new file mode 100644 index 0000000..b835aca --- /dev/null +++ b/tests/fixtures/openapi-3.2-basic.yaml @@ -0,0 +1,35 @@ +openapi: '3.2.0' +$self: 'https://api.example.com/openapi.json' +info: + title: Test API + version: '1.0.0' +servers: + - url: https://api.example.com + name: production + description: Production server +tags: + - name: Users + summary: User management endpoints + parent: Administration + kind: nav +paths: + /users: + get: + summary: List users + responses: + '200': + summary: Successful response + description: List of users + content: + application/json: + schema: + type: array +components: + schemas: + Pet: + type: object + discriminator: + propertyName: type + mapping: + dog: '#/components/schemas/Dog' + defaultMapping: '#/components/schemas/Pet' diff --git a/tests/fixtures/response-validation-specs/streaming.yaml b/tests/fixtures/response-validation-specs/streaming.yaml new file mode 100644 index 0000000..ccf2364 --- /dev/null +++ b/tests/fixtures/response-validation-specs/streaming.yaml @@ -0,0 +1,100 @@ +openapi: '3.2.0' +info: + title: Streaming API + version: '1.0.0' +paths: + /events: + get: + operationId: getEvents + responses: + '200': + description: Server-Sent Events stream + content: + text/event-stream: + itemSchema: + type: object + properties: + event: + type: string + data: + type: object + properties: + message: + type: string + count: + type: integer + required: + - event + - data + /logs: + get: + operationId: getLogs + responses: + '200': + description: JSON Lines log stream + content: + application/jsonl: + itemSchema: + type: object + properties: + timestamp: + type: string + format: date-time + level: + type: string + enum: [debug, info, warn, error] + message: + type: string + required: + - timestamp + - level + - message + /records: + get: + operationId: getRecords + responses: + '200': + description: JSON Text Sequences (RFC 7464) + content: + application/json-seq: + itemSchema: + type: object + properties: + id: + type: string + value: + type: string + required: + - id + /ndjson: + get: + operationId: getNdjson + responses: + '200': + description: Newline Delimited JSON + content: + application/x-ndjson: + itemSchema: + type: object + properties: + name: + type: string + count: + type: integer + required: + - name + /stream-with-schema: + get: + operationId: getStreamWithSchema + responses: + '200': + description: Stream using schema instead of itemSchema + content: + application/jsonl: + schema: + type: object + properties: + fallback: + type: boolean + required: + - fallback diff --git a/tests/fixtures/v3.0/simple-api.yaml b/tests/fixtures/v3.0/simple-api.yaml new file mode 100644 index 0000000..db6e613 --- /dev/null +++ b/tests/fixtures/v3.0/simple-api.yaml @@ -0,0 +1,53 @@ +openapi: '3.0.3' +info: + title: Simple API v3.0 + version: '1.0.0' +paths: + /items: + get: + summary: List items + responses: + '200': + description: List of items + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Item' + post: + summary: Create item + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Item' + responses: + '201': + description: Item created + /items/{id}: + get: + summary: Get item + parameters: + - name: id + in: path + required: true + schema: + type: integer + responses: + '200': + description: Item details + content: + application/json: + schema: + $ref: '#/components/schemas/Item' +components: + schemas: + Item: + type: object + properties: + id: + type: integer + name: + type: string diff --git a/tests/fixtures/v3.2/full-spec.yaml b/tests/fixtures/v3.2/full-spec.yaml new file mode 100644 index 0000000..c37b818 --- /dev/null +++ b/tests/fixtures/v3.2/full-spec.yaml @@ -0,0 +1,182 @@ +openapi: '3.2.0' +$self: 'https://api.example.com/openapi.json' +info: + title: Full OpenAPI 3.2 API + version: '1.0.0' +servers: + - url: https://api.example.com + name: production + description: Production server +tags: + - name: Users + summary: User management + kind: nav + - name: Admin + summary: Administration + kind: nav + - name: Operations + parent: Admin + summary: Operational endpoints +paths: + /users: + summary: User collection + get: + tags: [Users] + summary: List all users + responses: + '200': + summary: Successful response + description: List of users + content: + application/json: + schema: + $ref: '#/components/schemas/UserList' + query: + tags: [Users] + summary: Search users with complex query + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserQuery' + responses: + '200': + description: Search results + content: + application/json: + schema: + $ref: '#/components/schemas/UserList' + /search: + get: + parameters: + - name: filter + in: querystring + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SearchFilter' + responses: + '200': + description: Search results + /events: + get: + responses: + '200': + description: Event stream + content: + text/event-stream: + itemSchema: + $ref: '#/components/schemas/Event' + /logs: + get: + responses: + '200': + description: Log stream + content: + application/jsonl: + itemSchema: + $ref: '#/components/schemas/LogEntry' + /resource: + additionalOperations: + COPY: + summary: Copy resource + responses: + '201': + description: Resource copied + MOVE: + summary: Move resource + responses: + '201': + description: Resource moved +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string + type: + type: string + discriminator: + propertyName: type + mapping: + admin: '#/components/schemas/AdminUser' + defaultMapping: '#/components/schemas/User' + AdminUser: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + permissions: + type: array + items: + type: string + UserList: + type: array + items: + $ref: '#/components/schemas/User' + UserQuery: + type: object + properties: + name: + type: string + active: + type: boolean + SearchFilter: + type: object + properties: + query: + type: string + limit: + type: integer + Event: + type: object + properties: + event: + type: string + data: + type: object + id: + type: string + LogEntry: + type: object + properties: + timestamp: + type: string + format: date-time + level: + type: string + enum: [debug, info, warn, error] + message: + type: string + required: + - timestamp + - level + - message + mediaTypes: + ProblemJson: + schema: + $ref: '#/components/schemas/Problem' + Problem: + type: object + properties: + type: + type: string + title: + type: string + status: + type: integer + securitySchemes: + oauth2: + type: oauth2 + oauth2MetadataUrl: https://auth.example.com/.well-known/oauth-authorization-server + flows: + deviceCode: + tokenUrl: https://auth.example.com/token + deviceAuthorizationUrl: https://auth.example.com/device/code + scopes: + read: Read access + write: Write access diff --git a/tests/fixtures/v3.2/query-method.yaml b/tests/fixtures/v3.2/query-method.yaml new file mode 100644 index 0000000..3e0742c --- /dev/null +++ b/tests/fixtures/v3.2/query-method.yaml @@ -0,0 +1,33 @@ +openapi: '3.2.0' +info: + title: Query Method API + version: '1.0.0' +paths: + /search: + query: + requestBody: + content: + application/json: + schema: + type: object + properties: + query: + type: string + filters: + type: array + items: + type: string + responses: + '200': + description: Results + content: + application/json: + schema: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string diff --git a/tests/fixtures/v3.2/streaming-events.yaml b/tests/fixtures/v3.2/streaming-events.yaml new file mode 100644 index 0000000..f19d82f --- /dev/null +++ b/tests/fixtures/v3.2/streaming-events.yaml @@ -0,0 +1,31 @@ +openapi: '3.2.0' +info: + title: Streaming Events API + version: '1.0.0' +paths: + /events: + get: + responses: + '200': + content: + text/event-stream: + itemSchema: + type: object + properties: + event: + type: string + data: + type: string + /logs: + get: + responses: + '200': + content: + application/jsonl: + itemSchema: + type: object + properties: + level: + type: string + message: + type: string