diff --git a/CHANGELOG.md b/CHANGELOG.md index b546e73..860cfd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- Path Item level `parameters` are now enforced during request validation. + `PathItem.parameters` were parsed into the schema model but never reached + `RequestValidator`, so `required` and `schema` constraints on parameters + hoisted to the path item — the place the specification encourages for + parameters shared by every operation under a path — were silently ignored + for every location (`path`, `query`, `header`, `cookie`). Path item + parameters are now merged into the operation's parameter set when the + operation is resolved, with an operation level parameter overriding the + path level one on matching `name` + `in`. The same merge is applied to + webhooks and callbacks, which are Path Item objects as well. (#61) + ## [0.7.0] Preparation for the 1.0.0 stable release. This section tracks work that diff --git a/src/Validator/Callback/CallbackValidator.php b/src/Validator/Callback/CallbackValidator.php index 4dcc277..5346ae2 100644 --- a/src/Validator/Callback/CallbackValidator.php +++ b/src/Validator/Callback/CallbackValidator.php @@ -14,6 +14,7 @@ use Duyler\OpenApi\Validator\Exception\UnresolvableCallbackPathException; use Duyler\OpenApi\Validator\PregExecutor; use Duyler\OpenApi\Validator\Request\PathRegexCache; +use Duyler\OpenApi\Validator\Internal\PathItemParameterMerger; use Duyler\OpenApi\Validator\Request\RequestValidatorInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerInterface; @@ -177,7 +178,7 @@ private function extractOperation( $operation = $resolved->getOperation($method); if (null !== $operation) { - return [$operation, $pathTemplate]; + return [PathItemParameterMerger::merge($resolved, $operation), $pathTemplate]; } } diff --git a/src/Validator/Internal/PathItemParameterMerger.php b/src/Validator/Internal/PathItemParameterMerger.php new file mode 100644 index 0000000..b43881e --- /dev/null +++ b/src/Validator/Internal/PathItemParameterMerger.php @@ -0,0 +1,75 @@ +parameters?->parameters ?? []; + + if ([] === $pathLevel) { + return $operation; + } + + $operationLevel = $operation->parameters?->parameters ?? []; + + $overridden = []; + foreach ($operationLevel as $param) { + $key = self::identity($param); + if (null !== $key) { + $overridden[$key] = true; + } + } + + $merged = $operationLevel; + foreach ($pathLevel as $param) { + $key = self::identity($param); + if (null !== $key && isset($overridden[$key])) { + continue; + } + + $merged[] = $param; + } + + if ($merged === $operationLevel) { + return $operation; + } + + return new Operation( + tags: $operation->tags, + summary: $operation->summary, + description: $operation->description, + externalDocs: $operation->externalDocs, + operationId: $operation->operationId, + parameters: new Parameters($merged), + requestBody: $operation->requestBody, + responses: $operation->responses, + callbacks: $operation->callbacks, + deprecated: $operation->deprecated, + security: $operation->security, + servers: $operation->servers, + ); + } + + private static function identity(Parameter|string $parameter): ?string + { + if (false === $parameter instanceof Parameter) { + return null; + } + + if (null === $parameter->name || null === $parameter->in) { + return null; + } + + return $parameter->in . "\0" . $parameter->name; + } +} diff --git a/src/Validator/PathFinder.php b/src/Validator/PathFinder.php index a343552..fd1f89e 100644 --- a/src/Validator/PathFinder.php +++ b/src/Validator/PathFinder.php @@ -9,6 +9,7 @@ use Duyler\OpenApi\Schema\OpenApiDocument; use Duyler\OpenApi\Validator\Exception\OperationNotFoundException; use Duyler\OpenApi\Validator\Internal\CandidatePrioritizer; +use Duyler\OpenApi\Validator\Internal\PathItemParameterMerger; use Duyler\OpenApi\Validator\Internal\TrieBuilder; use Duyler\OpenApi\Validator\Internal\TrieLookup; use Duyler\OpenApi\Validator\Request\PathParser; @@ -124,7 +125,7 @@ private function getOperation(PathItem $pathItem, string $method, string $pathPa path: $pathPattern, method: $method, operationId: $schemaOperation->operationId, - schemaOperation: $schemaOperation, + schemaOperation: PathItemParameterMerger::merge($pathItem, $schemaOperation), ); } @@ -135,7 +136,7 @@ private function getOperation(PathItem $pathItem, string $method, string $pathPa path: $pathPattern, method: $method, operationId: $additionalOp->operationId, - schemaOperation: $additionalOp, + schemaOperation: PathItemParameterMerger::merge($pathItem, $additionalOp), ); } } diff --git a/src/Validator/Webhook/WebhookValidator.php b/src/Validator/Webhook/WebhookValidator.php index 57750e9..43f23a1 100644 --- a/src/Validator/Webhook/WebhookValidator.php +++ b/src/Validator/Webhook/WebhookValidator.php @@ -7,6 +7,7 @@ use Duyler\OpenApi\Schema\Model\Operation; use Duyler\OpenApi\Schema\Model\PathItem; use Duyler\OpenApi\Schema\OpenApiDocument; +use Duyler\OpenApi\Validator\Internal\PathItemParameterMerger; use Duyler\OpenApi\Validator\Request\RequestValidatorInterface; use Duyler\OpenApi\Validator\Webhook\Exception\UnknownWebhookException; use Psr\Http\Message\ServerRequestInterface; @@ -58,6 +59,6 @@ private function extractOperation( ); } - return $operation; + return PathItemParameterMerger::merge($webhook, $operation); } } diff --git a/tests/Functional/Request/PathItemParametersTest.php b/tests/Functional/Request/PathItemParametersTest.php new file mode 100644 index 0000000..bf11a2e --- /dev/null +++ b/tests/Functional/Request/PathItemParametersTest.php @@ -0,0 +1,212 @@ +psrFactory = new Psr17Factory(); + } + + #[Test] + public function path_item_level_required_query_parameter_is_enforced(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::WIDGETS_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest( + 'GET', + 'http://localhost/widgets/2b3e0c4a-59f4-4c4f-9a0a-1e0d9c7f0b11', + ); + + $this->expectException(MissingParameterException::class); + + $validator->validateRequest($request); + } + + #[Test] + public function path_item_level_query_parameter_schema_is_enforced(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::WIDGETS_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest( + 'GET', + 'http://localhost/widgets/2b3e0c4a-59f4-4c4f-9a0a-1e0d9c7f0b11?mustHave=gamma', + ); + + $this->expectException(EnumError::class); + + $validator->validateRequest($request); + } + + #[Test] + public function path_item_level_path_parameter_schema_is_enforced(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::WIDGETS_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest( + 'GET', + 'http://localhost/widgets/not-a-uuid?mustHave=alpha', + ); + + $this->expectException(InvalidFormatException::class); + + $validator->validateRequest($request); + } + + #[Test] + public function request_satisfying_path_item_level_parameters_is_accepted(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::WIDGETS_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest( + 'GET', + 'http://localhost/widgets/2b3e0c4a-59f4-4c4f-9a0a-1e0d9c7f0b11?mustHave=alpha', + ); + + $operation = $validator->validateRequest($request); + + $this->assertSame('/widgets/{widgetId}', $operation->path); + } + + #[Test] + public function path_item_level_parameters_apply_to_every_operation_under_the_path(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::WIDGETS_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest( + 'DELETE', + 'http://localhost/widgets/2b3e0c4a-59f4-4c4f-9a0a-1e0d9c7f0b11', + ); + + $this->expectException(MissingParameterException::class); + + $validator->validateRequest($request); + } + + #[Test] + public function operation_level_parameter_overrides_path_item_level_parameter_with_same_name_and_in(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::OVERRIDE_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest( + 'GET', + 'http://localhost/widgets?status=gamma', + ); + + $operation = $validator->validateRequest($request); + + $this->assertSame('/widgets', $operation->path); + } + + #[Test] + public function overridden_path_item_level_parameter_no_longer_applies(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::OVERRIDE_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest( + 'GET', + 'http://localhost/widgets?status=alpha', + ); + + $this->expectException(EnumError::class); + + $validator->validateRequest($request); + } + + #[Test] + public function operation_level_parameters_are_still_enforced_alongside_path_item_level_ones(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::OVERRIDE_YAML) + ->build(); + + $request = $this->psrFactory->createServerRequest('GET', 'http://localhost/widgets'); + + $this->expectException(MissingParameterException::class); + + $validator->validateRequest($request); + } +} diff --git a/tests/Integration/Validator/PathItemParametersMergeTest.php b/tests/Integration/Validator/PathItemParametersMergeTest.php new file mode 100644 index 0000000..59639d7 --- /dev/null +++ b/tests/Integration/Validator/PathItemParametersMergeTest.php @@ -0,0 +1,128 @@ +factory = new Psr17Factory(); + } + + #[Test] + public function webhook_path_item_level_parameter_is_enforced(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::WEBHOOK_YAML) + ->build(); + + $request = $this->factory->createServerRequest('POST', 'http://localhost/webhook'); + + $this->expectException(MissingParameterException::class); + + $validator->validateWebhook($request, 'payment.completed'); + } + + #[Test] + public function webhook_satisfying_path_item_level_parameter_is_accepted(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::WEBHOOK_YAML) + ->build(); + + $request = $this->factory->createServerRequest('POST', 'http://localhost/webhook') + ->withHeader('X-Signature', 'sha256=abc'); + + $operation = $validator->validateWebhook($request, 'payment.completed'); + + $this->assertSame('payment.completed', $operation->path); + } + + #[Test] + public function callback_path_item_level_parameter_schema_is_enforced(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::CALLBACK_YAML) + ->build(); + + $request = $this->factory->createServerRequest( + 'POST', + 'http://localhost/webhook/invoice?mode=staging', + ); + + $this->expectException(EnumError::class); + + $validator->validateCallback($request, 'invoiceCallback'); + } + + #[Test] + public function callback_satisfying_path_item_level_parameter_is_accepted(): void + { + $validator = OpenApiValidatorBuilder::create() + ->fromYamlString(self::CALLBACK_YAML) + ->build(); + + $request = $this->factory->createServerRequest( + 'POST', + 'http://localhost/webhook/invoice?mode=live', + ); + + $operation = $validator->validateCallback($request, 'invoiceCallback'); + + $this->assertSame('POST', $operation->method); + } +}