Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/Validator/Callback/CallbackValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -177,7 +178,7 @@ private function extractOperation(
$operation = $resolved->getOperation($method);

if (null !== $operation) {
return [$operation, $pathTemplate];
return [PathItemParameterMerger::merge($resolved, $operation), $pathTemplate];
}
}

Expand Down
75 changes: 75 additions & 0 deletions src/Validator/Internal/PathItemParameterMerger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Duyler\OpenApi\Validator\Internal;

use Duyler\OpenApi\Schema\Model\Operation;
use Duyler\OpenApi\Schema\Model\Parameter;
use Duyler\OpenApi\Schema\Model\Parameters;
use Duyler\OpenApi\Schema\Model\PathItem;

/** @internal */
final readonly class PathItemParameterMerger
{
public static function merge(PathItem $pathItem, Operation $operation): Operation
{
$pathLevel = $pathItem->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;
}
}
5 changes: 3 additions & 2 deletions src/Validator/PathFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
);
}

Expand All @@ -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),
);
}
}
Expand Down
3 changes: 2 additions & 1 deletion src/Validator/Webhook/WebhookValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -58,6 +59,6 @@ private function extractOperation(
);
}

return $operation;
return PathItemParameterMerger::merge($webhook, $operation);
}
}
212 changes: 212 additions & 0 deletions tests/Functional/Request/PathItemParametersTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

declare(strict_types=1);

namespace Duyler\OpenApi\Test\Functional\Request;

use Duyler\OpenApi\Builder\OpenApiValidatorBuilder;
use Duyler\OpenApi\Validator\Exception\EnumError;
use Duyler\OpenApi\Validator\Exception\InvalidFormatException;
use Duyler\OpenApi\Validator\Exception\MissingParameterException;
use Nyholm\Psr7\Factory\Psr17Factory;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;

final class PathItemParametersTest extends TestCase
{
private const string WIDGETS_YAML = <<<YAML
openapi: 3.0.0
info:
title: Path Level Parameters API
version: 1.0.0
paths:
/widgets/{widgetId}:
parameters:
- name: widgetId
in: path
required: true
schema:
type: string
format: uuid
- name: mustHave
in: query
required: true
schema:
type: string
enum: [alpha, beta]
get:
responses:
'200':
description: ok
delete:
responses:
'204':
description: ok
YAML;

private const string OVERRIDE_YAML = <<<YAML
openapi: 3.0.0
info:
title: Override API
version: 1.0.0
paths:
/widgets:
parameters:
- name: status
in: query
required: true
schema:
type: string
enum: [alpha, beta]
get:
parameters:
- name: status
in: query
required: true
schema:
type: string
enum: [gamma]
responses:
'200':
description: ok
YAML;
private Psr17Factory $psrFactory;

protected function setUp(): void
{
$this->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);
}
}
Loading