From 9930763c6fdecd3d93c889069cf517c0cbd1cb0d Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Nov 2024 19:12:06 +0000 Subject: [PATCH 01/21] Create ValidatesTemplate This extracts logic for checking balanced brackets in urls. This can be reused for Server Objects and Path Item Objects. --- src/Service/Url/ValidatesTemplate.php | 36 +++++++ tests/Service/Url/ValidatesTemplateTest.php | 104 ++++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/Service/Url/ValidatesTemplate.php create mode 100644 tests/Service/Url/ValidatesTemplateTest.php diff --git a/src/Service/Url/ValidatesTemplate.php b/src/Service/Url/ValidatesTemplate.php new file mode 100644 index 0000000..7a20797 --- /dev/null +++ b/src/Service/Url/ValidatesTemplate.php @@ -0,0 +1,36 @@ + */ + public static function provideValidUrls(): \Generator + { + $partialUrls = ['', 'concrete', '{templated}', '{templated}/{twice}']; + + foreach ($partialUrls as $partialUrl) { + foreach (self::provideVariedPrefixesToUrl($partialUrl) as $url) { + yield $url => [$url]; + } + } + } + + /** + * @return \Generator + */ + public static function provideImproperlyTemplatedUrls(): \Generator + { + $identifier = new Identifier('test'); + + foreach (['var}', '}var{'] as $partialUrl) { + foreach (self::provideVariedPrefixesToUrl($partialUrl) as $url) { + yield $url => [ + InvalidOpenAPI::urlLiteralClosingBrace($identifier), + $identifier, + $url, + ]; + } + } + + foreach (['{var', '{var1}/{var2'] as $partialUrl) { + foreach (self::provideVariedPrefixesToUrl($partialUrl) as $url) { + yield $url => [ + InvalidOpenAPI::urlUnclosedVariable($identifier), + $identifier, + $url, + ]; + } + } + + foreach (['{{var}}', '{var1{var2}}'] as $partialUrl) { + foreach (self::provideVariedPrefixesToUrl($partialUrl) as $url) { + yield $url => [ + InvalidOpenAPI::urlNestedVariable($identifier), + $identifier, + $url, + ]; + } + } + } + + /** @return list */ + private static function provideVariedPrefixesToUrl(string $url): array + { + return [ + "https://server.net/$url", + "/$url", + ]; + } +} From e9c0835fa3f112a0c4c3bc200528c2045b5b9ac3 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Nov 2024 19:13:09 +0000 Subject: [PATCH 02/21] Create Server Interface This contains convenience methods for version-agnostic data retrieval. --- src/ValueObject/Valid/Server.php | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/ValueObject/Valid/Server.php diff --git a/src/ValueObject/Valid/Server.php b/src/ValueObject/Valid/Server.php new file mode 100644 index 0000000..da06ed1 --- /dev/null +++ b/src/ValueObject/Valid/Server.php @@ -0,0 +1,38 @@ + + * ordered list of variable names + * matching their order of appearance within the URL. + */ + public function getVariableNames(): array; + + /** + * @phpstan-assert-if-true false $this->isTemplated() + * @return bool + * true if the URL does not contain variables, + * false otherwise. + */ + public function isConcrete(): bool; + + /** + * @phpstan-assert-if-true false $this->isConcrete() + * @phpstan-assert-if-true =non-empty-list $this->getVariableNames() + * @return bool + * true if the URL does contain variables, + * false otherwise. + */ + public function isTemplated(): bool; +} From b7016e0db0b7f4d151ec3cae83bee26d44aa68a8 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Nov 2024 19:13:47 +0000 Subject: [PATCH 03/21] Implement Server interface in V30 --- src/ValueObject/Valid/V30/Server.php | 68 +++++++------------- src/ValueObject/Valid/V30/ServerVariable.php | 6 +- 2 files changed, 27 insertions(+), 47 deletions(-) diff --git a/src/ValueObject/Valid/V30/Server.php b/src/ValueObject/Valid/V30/Server.php index 9e6d136..65d6a55 100644 --- a/src/ValueObject/Valid/V30/Server.php +++ b/src/ValueObject/Valid/V30/Server.php @@ -5,24 +5,23 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\V30; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; +use Membrane\OpenAPIReader\Service\Url; use Membrane\OpenAPIReader\ValueObject\Partial; +use Membrane\OpenAPIReader\ValueObject\Valid; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; -final class Server extends Validated +final class Server extends Validated implements Valid\Server { - /** - * REQUIRED - */ + /** REQUIRED */ public readonly string $url; /** * When the url has a variable named in {brackets} * This array MUST contain the definition of the corresponding variable. - * - * The name of the variable is mapped to the Server Variable Object * @var array + * A map between variable name and its value */ public readonly array $variables; @@ -34,9 +33,10 @@ public function __construct( throw InvalidOpenAPI::serverMissingUrl($parentIdentifier); } - parent::__construct($parentIdentifier->append($server->url)); + $identifier = $parentIdentifier->append($server->url); + parent::__construct($identifier); - $this->url = $this->validateUrl($parentIdentifier, $server->url); + $this->url = $this->validateUrl($identifier, $server->url); $this->variables = $this->validateVariables( $this->getIdentifier(), @@ -45,10 +45,13 @@ public function __construct( ); } - /** - * Returns the list of variable names in order of appearance within the URL. - * @return array - */ + public function getPattern(): string + { + $regex = preg_replace('#{[^/]+}#', '([^/]+)', $this->url); + assert(is_string($regex)); + return $regex; + } + public function getVariableNames(): array { preg_match_all('#{[^/]+}#', $this->url, $result); @@ -56,42 +59,19 @@ public function getVariableNames(): array return array_map(fn($v) => trim($v, '{}'), $result[0]); } - /** - * Returns the regex of the URL - */ - public function getPattern(): string + public function isConcrete(): bool { - $regex = preg_replace('#{[^/]+}#', '([^/]+)', $this->url); - assert(is_string($regex)); - return $regex; + return empty($this->variables); } - private function validateUrl(Identifier $identifier, string $url): string + public function isTemplated(): bool { - $characters = str_split($url); - - $insideVariable = false; - foreach ($characters as $character) { - if ($character === '{') { - if ($insideVariable) { - throw InvalidOpenAPI::urlNestedVariable( - $identifier->append($url) - ); - } - $insideVariable = true; - } elseif ($character === '}') { - if (!$insideVariable) { - throw InvalidOpenAPI::urlLiteralClosingBrace( - $identifier->append($url) - ); - } - $insideVariable = false; - } - } + return !empty($this->getVariableNames()); + } - if ($insideVariable) { - throw InvalidOpenAPI::urlUnclosedVariable($identifier->append($url)); - } + private function validateUrl(Identifier $identifier, string $url): string + { + (new Url\ValidatesTemplate())($identifier, $url); if (str_ends_with($url, '/') && $url !== '/') { $this->addWarning( @@ -104,7 +84,7 @@ private function validateUrl(Identifier $identifier, string $url): string } /** - * @param array $UrlVariableNames + * @param string[] $UrlVariableNames * @param Partial\ServerVariable[] $variables * @return array */ diff --git a/src/ValueObject/Valid/V30/ServerVariable.php b/src/ValueObject/Valid/V30/ServerVariable.php index 1d9e33e..4ef5544 100644 --- a/src/ValueObject/Valid/V30/ServerVariable.php +++ b/src/ValueObject/Valid/V30/ServerVariable.php @@ -18,10 +18,10 @@ final class ServerVariable extends Validated /** * If not null: * - It SHOULD NOT be empty - * - The "default" value SHOULD be contained within this list - * @var string[] + * - It SHOULD contain the "default" value + * @var non-empty-list|null */ - public readonly ?array $enum; + public readonly array|null $enum; public function __construct( Identifier $identifier, From a514c249d6c632c544e4791804bc21b80b685a82 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 27 Nov 2024 19:14:00 +0000 Subject: [PATCH 04/21] Add V31 Server Object and Server Variable Object --- src/ValueObject/Valid/V30/ServerVariable.php | 2 +- src/ValueObject/Valid/V31/Server.php | 130 +++++++ src/ValueObject/Valid/V31/ServerVariable.php | 56 +++ tests/ValueObject/Valid/V31/ServerTest.php | 326 ++++++++++++++++++ .../Valid/V31/ServerVariableTest.php | 88 +++++ 5 files changed, 601 insertions(+), 1 deletion(-) create mode 100644 src/ValueObject/Valid/V31/Server.php create mode 100644 src/ValueObject/Valid/V31/ServerVariable.php create mode 100644 tests/ValueObject/Valid/V31/ServerTest.php create mode 100644 tests/ValueObject/Valid/V31/ServerVariableTest.php diff --git a/src/ValueObject/Valid/V30/ServerVariable.php b/src/ValueObject/Valid/V30/ServerVariable.php index 4ef5544..91707d8 100644 --- a/src/ValueObject/Valid/V30/ServerVariable.php +++ b/src/ValueObject/Valid/V30/ServerVariable.php @@ -19,7 +19,7 @@ final class ServerVariable extends Validated * If not null: * - It SHOULD NOT be empty * - It SHOULD contain the "default" value - * @var non-empty-list|null + * @var list|null */ public readonly array|null $enum; diff --git a/src/ValueObject/Valid/V31/Server.php b/src/ValueObject/Valid/V31/Server.php new file mode 100644 index 0000000..e9c2b87 --- /dev/null +++ b/src/ValueObject/Valid/V31/Server.php @@ -0,0 +1,130 @@ + + * A map between variable name and its value + */ + public readonly array $variables; + + public function __construct( + Identifier $parentIdentifier, + Partial\Server $server, + ) { + if (!isset($server->url)) { + throw InvalidOpenAPI::serverMissingUrl($parentIdentifier); + } + + $identifier = $parentIdentifier->append($server->url); + parent::__construct($identifier); + + $this->url = $this->validateUrl($identifier, $server->url); + + $this->variables = $this->validateVariables( + $this->getIdentifier(), + $this->getVariableNames(), + $server->variables, + ); + } + + public function getPattern(): string + { + $regex = preg_replace('#{[^/]+}#', '([^/]+)', $this->url); + assert(is_string($regex)); + return $regex; + } + + public function getVariableNames(): array + { + preg_match_all('#{[^/]+}#', $this->url, $result); + + return array_map(fn($v) => trim($v, '{}'), $result[0]); + } + + public function isConcrete(): bool + { + return empty($this->variables); + } + + public function isTemplated(): bool + { + return !empty($this->getVariableNames()); + } + + private function validateUrl(Identifier $identifier, string $url): string + { + (new Url\ValidatesTemplate())($identifier, $url); + + if (str_ends_with($url, '/') && $url !== '/') { + $this->addWarning( + 'paths begin with a forward slash, so servers need not end in one', + Warning::REDUNDANT_FORWARD_SLASH, + ); + } + + return rtrim($url, '/'); + } + + /** + * @param string[] $UrlVariableNames + * @param Partial\ServerVariable[] $variables + * @return array + */ + private function validateVariables( + Identifier $identifier, + array $UrlVariableNames, + array $variables, + ): array { + $result = []; + foreach ($variables as $variable) { + if (!isset($variable->name)) { + throw InvalidOpenAPI::serverVariableMissingName($identifier); + } + + if (!in_array($variable->name, $UrlVariableNames)) { + $this->addWarning( + sprintf( + '"variables" defines "%s" which is not found in "url".', + $variable->name + ), + Warning::REDUNDANT_VARIABLE + ); + + continue; + } + + $result[$variable->name] = new ServerVariable( + $identifier->append($variable->name), + $variable + ); + } + + $undefined = array_diff($UrlVariableNames, array_keys($result)); + if (!empty($undefined)) { + throw InvalidOpenAPI::serverHasUndefinedVariables( + $identifier, + ...$undefined, + ); + } + + return $result; + } +} diff --git a/src/ValueObject/Valid/V31/ServerVariable.php b/src/ValueObject/Valid/V31/ServerVariable.php new file mode 100644 index 0000000..424aa01 --- /dev/null +++ b/src/ValueObject/Valid/V31/ServerVariable.php @@ -0,0 +1,56 @@ +|null + */ + public readonly array|null $enum; + + public function __construct( + Identifier $identifier, + Partial\ServerVariable $serverVariable, + ) { + parent::__construct($identifier); + + if (!isset($serverVariable->default)) { + throw InvalidOpenAPI::serverVariableMissingDefault($identifier); + } + + $this->default = $serverVariable->default; + + if (isset($serverVariable->enum)) { + if (empty($serverVariable->enum)) { + $this->addWarning( + 'If "enum" is defined, it SHOULD NOT be empty', + Warning::EMPTY_ENUM, + ); + } + + if (!in_array($serverVariable->default, $serverVariable->enum)) { + $this->addWarning( + 'If "enum" is defined, the "default" SHOULD exist within it.', + Warning::IMPOSSIBLE_DEFAULT + ); + } + } + + $this->enum = $serverVariable->enum; + } +} diff --git a/tests/ValueObject/Valid/V31/ServerTest.php b/tests/ValueObject/Valid/V31/ServerTest.php new file mode 100644 index 0000000..8270ff3 --- /dev/null +++ b/tests/ValueObject/Valid/V31/ServerTest.php @@ -0,0 +1,326 @@ +getPattern()); + } + + /** + * @param string[] $expected + */ + #[Test, DataProvider('provideUrlsToGetVariableNamesFrom')] + #[TestDox('It returns a list of variable names in order of appearance within the URL')] + public function itGetsVariableNames( + array $expected, + Partial\Server $server + ): void { + $sut = new Server(new Identifier('test'), $server); + + self::assertSame($expected, $sut->getVariableNames()); + } + + /** + * @param Partial\ServerVariable[] $partialServerVariables + */ + #[Test, DataProvider('provideUrlsToGetVariablesFrom')] + #[TestDox('It returns a list of variables in order of appearance within the URL')] + public function itContainsServerVariables( + array $partialServerVariables, + Partial\Server $server + ): void { + $parentIdentifier = new Identifier('test'); + + $expected = array_map( + fn($v) => new ServerVariable( + $parentIdentifier->append($server->url)->append($v->name), + $v + ), + $partialServerVariables + ); + + $sut = new Server($parentIdentifier, $server); + + self::assertEquals($expected, $sut->variables); + } + + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideServersWithRedundantVariables')] + #[TestDox('It warns against having a variable defined that does not appear in the "url"')] + public function itWarnsAgainstRedundantVariables( + array $expected, + Partial\Server $server + ): void { + $sut = new Server(new Identifier('test'), $server); + + self::assertEquals($expected, $sut->getWarnings()->all()); + } + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideServersWithTrailingForwardSlashes')] + #[TestDox('It warns that servers do not need trailing forward slashes')] + public function itWarnsAgainstTrailingForwardSlashes( + array $expected, + Partial\Server $server + ): void { + $sut = new Server(new Identifier('test'), $server); + + self::assertEquals($expected, $sut->getWarnings()->all()); + } + + public static function provideServersToValidate(): Generator + { + $parentIdentifier = new Identifier('test'); + + $case = fn($expected, $data) => [ + $expected, + $parentIdentifier, + PartialHelper::createServer(...$data) + ]; + + yield 'missing "url"' => $case( + InvalidOpenAPI::serverMissingUrl($parentIdentifier), + ['url' => null] + ); + + yield 'url with braces the wrong way round' => $case( + InvalidOpenAPI::urlLiteralClosingBrace($parentIdentifier->append('https://server.net/v1}')), + [ + 'url' => 'https://server.net/v1}', + 'variables' => [PartialHelper::createServerVariable(name: 'v1')] + ] + ); + + yield 'url with unclosed variable' => $case( + InvalidOpenAPI::urlUnclosedVariable($parentIdentifier->append('https://server.net/{v1')), + [ + 'url' => 'https://server.net/{v1', + 'variables' => [PartialHelper::createServerVariable(name: 'v1')] + ] + ); + + yield 'url with nested variable' => $case( + InvalidOpenAPI::urlNestedVariable($parentIdentifier->append('https://server.net/{{v1}}')), + [ + 'url' => 'https://server.net/{{v1}}', + 'variables' => [PartialHelper::createServerVariable(name: '{v1}')] + ] + ); + + yield '"url" with one variable missing a name' => $case( + InvalidOpenAPI::serverVariableMissingName( + $parentIdentifier->append('https://server.net/{var1}'), + ), + [ + 'url' => 'https://server.net/{var1}', + 'variables' => [PartialHelper::createServerVariable(name: null)] + ] + ); + + yield '"url" with one undefined variable' => $case( + InvalidOpenAPI::serverHasUndefinedVariables( + $parentIdentifier->append('https://server.net/{var1}'), + 'var1' + ), + ['url' => 'https://server.net/{var1}', 'variables' => []] + ); + + yield '"url" with three undefined variable' => $case( + InvalidOpenAPI::serverHasUndefinedVariables( + $parentIdentifier->append('https://server.net/{var1}/{var2}/{var3}'), + 'var1', + 'var2', + 'var3', + ), + [ + 'url' => 'https://server.net/{var1}/{var2}/{var3}', + 'variables' => [], + ] + ); + + yield '"url" with one defined and one undefined variable' => $case( + InvalidOpenAPI::serverHasUndefinedVariables( + $parentIdentifier->append('https://server.net/{var1}/{var2}'), + 'var2', + ), + [ + 'url' => 'https://server.net/{var1}/{var2}', + 'variables' => [ + PartialHelper::createServerVariable(name: 'var1') + ], + ] + ); + } + + private static function createServerWithVariables(string ...$variables): Partial\Server + { + return PartialHelper::createServer( + url: sprintf( + 'https://server.net/%s', + implode('/', array_map(fn($v) => sprintf('{%s}', $v), $variables)) + ), + variables: array_map( + fn($v) => PartialHelper::createServerVariable(name: $v), + $variables + ) + ); + } + + /** + * @return Generator + */ + public static function provideUrlPatterns(): Generator + { + yield 'url without variables' => [ + 'https://server.net', + self::createServerWithVariables(), + ]; + + yield 'url with one variable' => [ + 'https://server.net/([^/]+)', + self::createServerWithVariables('v1'), + ]; + + yield 'url with three variables' => [ + 'https://server.net/([^/]+)/([^/]+)/([^/]+)', + self::createServerWithVariables('v1', 'v2', 'v3'), + ]; + } + + /** + * @return Generator + */ + public static function provideUrlsToGetVariableNamesFrom(): Generator + { + $case = fn(array $variables) => [ + $variables, + self::createServerWithVariables(...$variables), + ]; + + yield 'no variables' => $case([]); + yield 'one variable' => $case(['var1']); + yield 'three variables' => $case(['var1', 'var2', 'var3']); + } + + /** + * @return Generator + */ + public static function provideUrlsToGetVariablesFrom(): Generator + { + $case = fn(array $variables) => [ + array_combine( + $variables, + array_map(fn($v) => PartialHelper::createServerVariable($v), $variables), + ), + self::createServerWithVariables(...$variables), + ]; + + yield 'no variables' => $case([]); + yield 'one variable' => $case(['var1']); + yield 'three variables' => $case(['var1', 'var2', 'var3']); + } + + /** + * @return Generator + */ + public static function provideServersWithRedundantVariables(): Generator + { + $redundantVariables = fn($variables) => [ + array_map( + fn($v) => new Warning( + sprintf('"variables" defines "%s" which is not found in "url".', $v), + Warning::REDUNDANT_VARIABLE + ), + $variables + ), + PartialHelper::createServer( + variables: array_map( + fn($v) => PartialHelper::createServerVariable(name: $v), + $variables, + ) + ) + ]; + + yield 'no warnings' => $redundantVariables([]); + yield 'one redundant variable' => $redundantVariables(['v1']); + yield 'three redundant variables' => $redundantVariables(['v1', 'v2', 'v3']); + } + + /** + * @return Generator + */ + public static function provideServersWithTrailingForwardSlashes(): Generator + { + $expectedWarning = new Warning( + 'paths begin with a forward slash, so servers need not end in one', + Warning::REDUNDANT_FORWARD_SLASH, + ); + + yield 'No warning for the default server url "/"' => [ + [], + PartialHelper::createServer('/'), + ]; + + yield 'Warning for one forward slash' => [ + [$expectedWarning], + PartialHelper::createServer('https://www.server.net/'), + ]; + + yield 'Warning for multiple forward slashes' => [ + [$expectedWarning], + PartialHelper::createServer('https://www.server.net///'), + ]; + } +} diff --git a/tests/ValueObject/Valid/V31/ServerVariableTest.php b/tests/ValueObject/Valid/V31/ServerVariableTest.php new file mode 100644 index 0000000..315cc15 --- /dev/null +++ b/tests/ValueObject/Valid/V31/ServerVariableTest.php @@ -0,0 +1,88 @@ +hasWarnings()); + self::assertEquals($expected, $sut->getWarnings()->all()[0]); + } + + public static function provideServerVariablesToInvalidate(): Generator + { + $identifier = new Identifier('test'); + + $case = fn($expected, $data) => [ + $expected, + $identifier, + PartialHelper::createServerVariable(...$data) + ]; + + yield 'missing "default"' => $case( + InvalidOpenAPI::serverVariableMissingDefault($identifier), + ['default' => null], + ); + } + + public static function provideServerVariablesToWarnAgainst(): Generator + { + $case = fn($message, $code, $data) => [ + new Warning($message, $code), + PartialHelper::createServerVariable(...$data) + ]; + + yield 'missing "default" from "enum"' => $case( + 'If "enum" is defined, the "default" SHOULD exist within it.', + Warning::IMPOSSIBLE_DEFAULT, + ['default' => 'default-test-value', 'enum' => ['something-else']], + ); + + yield '"enum" is empty' => $case( + 'If "enum" is defined, it SHOULD NOT be empty', + Warning::EMPTY_ENUM, + ['enum' => []], + ); + } +} From 7392185fb15c08e035faad39b3ae625553aea4cd Mon Sep 17 00:00:00 2001 From: John Charman Date: Sun, 1 Dec 2024 23:00:55 +0000 Subject: [PATCH 05/21] WIP adjust schema values for DX --- src/Exception/InvalidOpenAPI.php | 12 +- src/ValueObject/Partial/Schema.php | 8 +- src/ValueObject/Valid/Enum/Type.php | 11 + src/ValueObject/Valid/Schema.php | 8 +- src/ValueObject/Valid/V30/Schema.php | 242 +++++++++++---------- src/ValueObject/Valid/Validated.php | 5 + src/ValueObject/Valid/Warning.php | 6 + tests/ValueObject/Valid/V30/SchemaTest.php | 65 +++--- tests/fixtures/Helper/PartialHelper.php | 8 +- tests/fixtures/ProvidesReviewedSchemas.php | 82 +++++++ 10 files changed, 280 insertions(+), 167 deletions(-) create mode 100644 tests/fixtures/ProvidesReviewedSchemas.php diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index 2869e5f..e6bfa4a 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -4,6 +4,7 @@ namespace Membrane\OpenAPIReader\Exception; +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use RuntimeException; @@ -312,11 +313,14 @@ public static function invalidType(Identifier $identifier, string $type): self return new self($message); } - public static function typeArrayInWrongVersion(Identifier $identifier): self - { + public static function keywordMustBeType( + Identifier $identifier, + string $keyword, + Type $type, + ): self { $message = <<value TEXT; return new self($message); @@ -368,7 +372,7 @@ public static function keywordMustBeStrictlyPositiveNumber( return new self($message); } - public static function keywordMustBeNegativeInteger( + public static function keywordMustBeNonNegativeInteger( Identifier $identifier, string $keyword, ): self { diff --git a/src/ValueObject/Partial/Schema.php b/src/ValueObject/Partial/Schema.php index 47fb2c8..529c37c 100644 --- a/src/ValueObject/Partial/Schema.php +++ b/src/ValueObject/Partial/Schema.php @@ -81,7 +81,7 @@ public function __construct( public array|null $anyOf = null, /** @var array|null */ public array|null $oneOf = null, - public Schema|null $not = null, + public bool|Schema $not = false, /** * Keywords for applying subschemas conditionally * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 @@ -123,13 +123,13 @@ public function __construct( * Keywords that MAY provide additional validation, depending on tool * https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-7 */ - public string|null $format = null, + public string $format = '', /** * Keywords that provide additional metadata * https://json-schema.org/draft/2020-12/json-schema-validation#section-9 */ - public string|null $title = null, - public string|null $description = null, + public string $title = '', + public string $description = '', ) { } } diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php index 854df1f..9320590 100644 --- a/src/ValueObject/Valid/Enum/Type.php +++ b/src/ValueObject/Valid/Enum/Type.php @@ -4,7 +4,9 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\Enum; +use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; +use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; enum Type: string { @@ -48,6 +50,15 @@ public static function valuesForVersion(OpenAPIVersion $version): array return array_map(fn($t) => $t->value, self::casesForVersion($version)); } + public static function fromVersion( + Identifier $identifier, + OpenAPIVersion $version, + string $type + ): ?self { + return self::tryFromVersion($version, $type) ?? + throw InvalidOpenAPI::invalidType($identifier, $type); + } + public static function tryFromVersion( OpenAPIVersion $version, string $type diff --git a/src/ValueObject/Valid/Schema.php b/src/ValueObject/Valid/Schema.php index e4d89fd..98988b5 100644 --- a/src/ValueObject/Valid/Schema.php +++ b/src/ValueObject/Valid/Schema.php @@ -4,13 +4,7 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid; -use Membrane\OpenAPIReader\ValueObject\Limit; -use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; - interface Schema { - /** @return Type[] */ - public function getTypes(): array; - public function getRelevantMinimum(): ?Limit; - public function getRelevantMaximum(): ?Limit; + } diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 0df8119..1907a6d 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -17,98 +17,110 @@ final class Schema extends Validated implements Valid\Schema { - public readonly Type|null $type; - public readonly bool $nullable; - /** @var array|null */ + /** @var list */ + public readonly array $types; + + /** @var list|null */ public readonly array|null $enum; public readonly Value|null $default; public readonly float|int|null $multipleOf; - public readonly float|int|null $maximum; - public readonly bool $exclusiveMaximum; - public readonly float|int|null $minimum; - public readonly bool $exclusiveMinimum; + public readonly Limit|null $maximum; + public readonly Limit|null $minimum; public readonly int|null $maxLength; public readonly int $minLength; public readonly string|null $pattern; - /** @var Schema|null */ - public readonly Schema|null $items; + public readonly Schema $items; public readonly int|null $maxItems; public readonly int $minItems; public readonly bool $uniqueItems; public readonly int|null $maxProperties; public readonly int $minProperties; - /** @var non-empty-array|null */ - public readonly array|null $required; + /** @var list */ + public readonly array $required; /** @var array */ public readonly array $properties; public readonly bool|Schema $additionalProperties; - /** @var non-empty-array|null */ - public readonly array|null $allOf; - /** @var non-empty-array|null */ - public readonly ?array $anyOf; - /** @var non-empty-array|null */ - public readonly array|null $oneOf; - public readonly Schema|null $not; + /** @var list */ + public readonly array $allOf; + /** @var list */ + public readonly array $anyOf; + /** @var list */ + public readonly array $oneOf; + public readonly bool|Schema $not; - public readonly string|null $format; + public readonly string $format; - public readonly string|null $title; - public readonly string|null $description; + public readonly string $title; + public readonly string $description; /** @var Type[] */ private readonly array $typesItCanBe; + //TODO how do we determine booleanSchemas + // public readonly bool $isEmpty; + + //TODO can we limit additionalProperties and not to Schema? + // We need to do so without creating an infinite loop of Schemas + public function __construct( Identifier $identifier, Partial\Schema $schema ) { parent::__construct($identifier); - $this->type = $this->validateType($this->getIdentifier(), $schema->type); - $this->nullable = $schema->nullable; + $this->types = $this->validateTypes( + $identifier, + $schema->type, + $schema->nullable + ); + + //TODO reviewEnum is not empty, but allow it to stay empty $this->enum = $schema->enum; $this->default = $schema->default; $this->multipleOf = $this->validatePositiveNumber('multipleOf', $schema->multipleOf); - $this->maximum = $schema->maximum; - $this->exclusiveMaximum = $this->validateExclusiveMinMax('exclusiveMaximum', $schema->exclusiveMaximum); - $this->minimum = $schema->minimum; - $this->exclusiveMinimum = $this->validateExclusiveMinMax('exclusiveMinimum', $schema->exclusiveMinimum); + $this->maximum = isset($schema->maximum) ? + new Limit($schema->maximum, $this->validateExclusiveMinMax('exclusiveMaximum', $schema->exclusiveMaximum)) : + null; + $this->minimum = isset($schema->minimum) ? + new Limit($schema->minimum, $this->validateExclusiveMinMax('exclusiveMinimum', $schema->exclusiveMinimum)) : + null; - $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength); - $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength) ?? 0; + $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength, false); + $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength, true); $this->pattern = $schema->pattern; - $this->items = $this->validateItems($this->type, $schema->items); - $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems); - $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems) ?? 0; + $this->items = $this->validateItems($this->types, $schema->items); + $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems, false); + $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems, true); $this->uniqueItems = $schema->uniqueItems; - $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties); - $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties) ?? 0; + $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties, false); + $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties, true); $this->required = $this->validateRequired($schema->required); $this->properties = $this->validateProperties($schema->properties); - $this->additionalProperties = isset($schema->additionalProperties) ? (is_bool($schema->additionalProperties) ? - $schema->additionalProperties : - new Schema($this->getIdentifier()->append('additionalProperties'), $schema->additionalProperties)) : - true; + //todo make it a boolean schema when required + $this->additionalProperties = $schema->additionalProperties instanceof Partial\Schema ? + new Schema($this->appendedIdentifier('additionalProperties'), $schema->additionalProperties) : + $schema->additionalProperties; + // make empty arrays instead of null $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); - $this->not = isset($schema->not) ? + $this->not = $schema->not instanceof Partial\Schema ? new Schema($this->getIdentifier()->append('not'), $schema->not) : - null; + $schema->not; - $this->format = $schema->format; + $this->format = $this->formatMetadataString($schema->format); - $this->title = $schema->title; - $this->description = $schema->description; + $this->title = $this->formatMetadataString($schema->title); + $this->description = $this->formatMetadataString($schema->description); $this->typesItCanBe = array_map( fn($t) => Type::from($t), @@ -144,41 +156,13 @@ public function canBePrimitive(): bool return false; } - /** @return Type[] */ - public function getTypes(): array - { - $result = isset($this->type) ? - [$this->type] : - Type::casesForVersion(OpenAPIVersion::Version_3_0); - - if ($this->nullable) { - $result[] = Type::Null; - } - - return $result; - } - - public function getRelevantMaximum(): ?Limit - { - return isset($this->maximum) ? - new Limit($this->maximum, $this->exclusiveMaximum) : - null; - } - - public function getRelevantMinimum(): ?Limit - { - return isset($this->minimum) ? - new Limit($this->minimum, $this->exclusiveMinimum) : - null; - } - /** @return string[] */ private function typesItCanBe(): array { $possibilities = [Type::valuesForVersion(OpenAPIVersion::Version_3_0)]; - if ($this->type !== null) { - $possibilities[] = [$this->type->value]; + if ($this->types !== []) { + $possibilities[] = $this->types; } if (!empty($this->allOf)) { @@ -205,21 +189,40 @@ private function typesItCanBe(): array return array_values(array_intersect(...$possibilities)); } - /** @param null|string|array $type */ - private function validateType(Identifier $identifier, null|string|array $type): ?Type - { - if (is_null($type)) { - return null; + /** + * @param null|string|array $type + * @return list + */ + private function validateTypes( + Identifier $identifier, + null|string|array $type, + bool $nullable, + ): array { + if (empty($type)) { // If type is unspecified, nullable has no effect + return []; // So we can return immediately + } + + if (is_string($type)) { + $type = [$type]; + } elseif (count($type) > 1) { + throw InvalidOpenAPI::keywordMustBeType( + $identifier, + 'type', + Type::String, + ); } - if (is_array($type)) { - throw InvalidOpenAPI::typeArrayInWrongVersion($identifier); + $result = array_map( + fn($t) => Type::tryFromVersion(OpenAPIVersion::Version_3_0, $t) + ?? throw InvalidOpenAPI::invalidType($identifier, $t), + $type, + ); + + if ($nullable) { + $result[] = Type::Null; } - return Type::tryFromVersion( - OpenAPIVersion::Version_3_0, - $type - ) ?? throw InvalidOpenAPI::invalidType($identifier, $type); + return $result; } private function validateExclusiveMinMax( @@ -244,50 +247,64 @@ private function validatePositiveNumber( return $value; } + /** @return ($defaultsToZero is true ? int : int|null) */ private function validateNonNegativeInteger( string $keyword, - int|null $value + int|null $value, + bool $defaultsToZero, ): int|null { if ($value !== null && $value < 0) { - throw InvalidOpenAPI::keywordMustBeNegativeInteger($this->getIdentifier(), $keyword); + if (! $defaultsToZero) { + throw InvalidOpenAPI::keywordMustBeNonNegativeInteger( + $this->getIdentifier(), + $keyword + ); + } else { + $this->addWarning("$keyword must not be negative", Warning::INVALID_API); + return 0; + } } return $value; } /** - * @param array|null $value - * @return non-empty-array|null + * @param array|null $required + * @return list */ - private function validateRequired(array|null $value): array|null + private function validateRequired(array | null $required): array { - if ($value === null) { - return $value; + if ($required === null) { + return []; } - if ($value === []) { - throw InvalidOpenAPI::mustBeNonEmpty($this->getIdentifier(), 'required'); + if ($required === []) { + $this->addWarning('required must not be empty', Warning::INVALID_API); + return []; } - if (count($value) !== count(array_unique($value))) { - throw InvalidOpenAPI::mustContainUniqueItems($this->getIdentifier(), 'required'); + $uniqueRequired = array_unique($required); + + if (count($required) !== count($uniqueRequired)) { + $this->addWarning('required must not contain duplicates', Warning::INVALID_API); } - return $value; + return $uniqueRequired; } /** * @param null|array $subSchemas - * @return null|non-empty-array + * @return list */ - private function validateSubSchemas(string $keyword, ?array $subSchemas): ?array + private function validateSubSchemas(string $keyword, ?array $subSchemas): array { if ($subSchemas === null) { - return null; + return []; } if ($subSchemas === []) { - throw InvalidOpenAPI::mustBeNonEmpty($this->getIdentifier(), $keyword); + $this->addWarning("$keyword must not be empty", Warning::INVALID_API); + return []; } $result = []; @@ -327,17 +344,24 @@ private function validateProperties(?array $properties): array return $result; } - private function validateItems(Type|null $type, Partial\Schema|null $items): Schema|null + /** @param list $types */ + private function validateItems(array $types, Partial\Schema|null $items): Schema { - if (is_null($items)) { - //@todo update tests to support this validation - //if ($type == Type::Array) { - // throw InvalidOpenAPI::mustSpecifyItemsForArrayType($this->getIdentifier()); - //} - - return $items; + if (in_array(Type::Array, $types) && !isset($items)) { + $this->addWarning( + 'items must be specified, if type is specified as array', + Warning::INVALID_API, + ); } - return new Schema($this->getIdentifier()->append('items'), $items); + return new Schema( + $this->getIdentifier()->append('items'), + $items ?? new Partial\Schema(), + ); + } + + public function formatMetadataString(string $metadata): string + { + return trim($metadata); } } diff --git a/src/ValueObject/Valid/Validated.php b/src/ValueObject/Valid/Validated.php index ec1e0a6..77edff4 100644 --- a/src/ValueObject/Valid/Validated.php +++ b/src/ValueObject/Valid/Validated.php @@ -4,6 +4,11 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid; +/** + * A Validated object **may** make _opinionated optimizations_ to improve DX. + * - It **may** change _appearance_ from its OpenAPI counterpart. + * - It **must** express the same _intent_ as its OpenAPI counterpart. + */ abstract class Validated implements HasIdentifier, HasWarnings { private Warnings $warnings; diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index 21339fc..5aa014e 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -12,6 +12,12 @@ public function __construct( ) { } + /** + * Warning code given for: + * An API that is technically invalid but safely corrected. + */ + public const INVALID_API = 'invalid-api'; + /** * Server Variable: "enum" SHOULD NOT be empty * Schema: "enum" SHOULD have at least one element diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index b3184d9..5d8c6f4 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -8,6 +8,7 @@ use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; +use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesReviewedSchemas; use Membrane\OpenAPIReader\ValueObject\Limit; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; @@ -18,6 +19,7 @@ use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\DataProviderExternal; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\Attributes\UsesClass; @@ -33,9 +35,32 @@ #[UsesClass(Warnings::class)] class SchemaTest extends TestCase { + #[Test] + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] + public function itReviewsSchema(Partial\Schema $schema, Warning $warning): void + { + $sut = new Schema(new Identifier('test'), $schema); + + self::assertContainsEquals($warning, $sut->getWarnings()->all()); + } + + #[Test] + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] + public function itAmendsSchema(Partial\Schema $schema, Warning $warning, Partial\Schema $amendment): void + { + $schema = new Schema(new Identifier('test'), $schema); + $amendment = new Schema(new Identifier('test'), $amendment); + + self::assertNotContainsEquals($warning, $amendment->getWarnings()->all()); + //@TODO assert equality of all properties EXCEPT FOR the warning + //self::assertEquals($amended, $original); + } + #[Test] #[DataProvider('provideInvalidSchemas')] - public function itThrowsOnInvalidSchemas( + public function itValidatesSchema( InvalidOpenAPI $expected, Identifier $identifier, Partial\Schema $partialSchema, @@ -142,10 +167,6 @@ public function itGetsTypes(array $expected, Partial\Schema $schema): void public static function provideInvalidSchemas(): Generator { - foreach (self::provideInvalidComplexSchemas() as $case => $dataset) { - yield $case => $dataset; - } - yield 'invalid type' => [ InvalidOpenAPI::invalidType(new Identifier('invalid type'), 'invalid'), new Identifier('invalid type'), @@ -162,40 +183,6 @@ public static function provideInvalidSchemas(): Generator ]; } - private static function provideInvalidComplexSchemas(): Generator - { - $xOfs = [ - 'allOf' => fn(Partial\Schema ...$subSchemas) => PartialHelper::createSchema( - allOf: $subSchemas - ), - 'anyOf' => fn(Partial\Schema ...$subSchemas) => PartialHelper::createSchema( - anyOf: $subSchemas - ), - 'oneOf' => fn(Partial\Schema ...$subSchemas) => PartialHelper::createSchema( - oneOf: $subSchemas - ), - ]; - - $identifier = new Identifier('test-schema'); - $case = fn(Identifier $exceptionId, Partial\Schema $schema, string $keyword) => [ - InvalidOpenAPI::mustBeNonEmpty($exceptionId, $keyword), - $identifier, - $schema - ]; - - foreach ($xOfs as $keyword => $xOf) { - yield "empty $keyword" => $case($identifier, $xOf(), $keyword); - - foreach ($xOfs as $otherKeyWord => $otherXOf) { - yield "$keyword with empty $otherKeyWord inside" => $case( - $identifier->append($keyword, '0'), - $xOf($otherXOf()), - $otherKeyWord, - ); - } - } - } - /** * @return \Generator + */ + public static function provideV3xReviews(): Generator + { + yield 'empty allOf' => [ + new Partial\Schema(allOf: []), + new Warning('allOf must not be empty', Warning::INVALID_API), + new Partial\Schema(allOf: null), + ]; + + yield 'empty anyOf' => [ + new Partial\Schema(anyOf: []), + new Warning('anyOf must not be empty', Warning::INVALID_API), + new Partial\Schema(anyOf: null), + ]; + + yield 'empty oneOf' => [ + new Partial\Schema(oneOf: []), + new Warning('oneOf must not be empty', Warning::INVALID_API), + new Partial\Schema(oneOf: null), + ]; + + yield 'negative minLength' => [ + new Partial\Schema(minLength: -1), + new Warning('minLength must not be negative', Warning::INVALID_API), + new Partial\Schema(minLength: 0), + ]; + + yield 'negative minItems' => [ + new Partial\Schema(minItems: -1), + new Warning('minItems must not be negative', Warning::INVALID_API), + new Partial\Schema(minItems: 0), + ]; + + yield 'negative minProperties' => [ + new Partial\Schema(minProperties: -1), + new Warning('minProperties must not be negative', Warning::INVALID_API), + new Partial\Schema(minProperties: 0), + ]; + } + + /** + * @return Generator + */ + public static function provideV30Reviews(): Generator + { + yield 'empty required' => [ + new Partial\Schema(required: []), + new Warning('required must not be empty', Warning::INVALID_API), + new Partial\Schema(required: null), + ]; + + yield 'required contains duplicates' => [ + new Partial\Schema(required: ['id', 'id']), + new Warning('required must not contain duplicates', Warning::INVALID_API), + new Partial\Schema(required: ['id']), + ]; + } +} From b47bf044dc0d4c1d3626b8bba2b49165b4154cb6 Mon Sep 17 00:00:00 2001 From: John Charman Date: Tue, 3 Dec 2024 20:47:06 +0000 Subject: [PATCH 06/21] Allow V30 Schema to be boolean Added Keywords object to contain keywords IF a Schema contains any --- src/Exception/InvalidOpenAPI.php | 13 +- src/Factory/V30/FromCebe.php | 2 +- src/ValueObject/Partial/Schema.php | 8 +- src/ValueObject/Valid/Enum/Type.php | 2 +- .../Valid/Exception/SchemaShouldBeBoolean.php | 46 ++ src/ValueObject/Valid/V30/Keywords.php | 437 ++++++++++++++++++ src/ValueObject/Valid/V30/Schema.php | 347 ++------------ src/ValueObject/Valid/Validated.php | 1 + src/ValueObject/Valid/Warning.php | 10 +- src/ValueObject/Value.php | 38 +- tests/ValueObject/Valid/V30/SchemaTest.php | 19 +- tests/fixtures/ProvidesReviewedSchemas.php | 22 +- 12 files changed, 592 insertions(+), 353 deletions(-) create mode 100644 src/ValueObject/Valid/Exception/SchemaShouldBeBoolean.php create mode 100644 src/ValueObject/Valid/V30/Keywords.php diff --git a/src/Exception/InvalidOpenAPI.php b/src/Exception/InvalidOpenAPI.php index e6bfa4a..b7043b6 100644 --- a/src/Exception/InvalidOpenAPI.php +++ b/src/Exception/InvalidOpenAPI.php @@ -360,13 +360,13 @@ public static function boolExclusiveMinMaxIn31( return new self($message); } - public static function keywordMustBeStrictlyPositiveNumber( + public static function keywordCannotBeZero( Identifier $identifier, string $keyword, ): self { $message = <<|null */ public array|null $oneOf = null, - public bool|Schema $not = false, + public Schema|null $not = null, /** * Keywords for applying subschemas conditionally * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 @@ -123,13 +123,13 @@ public function __construct( * Keywords that MAY provide additional validation, depending on tool * https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-7 */ - public string $format = '', + public string|null $format = null, /** * Keywords that provide additional metadata * https://json-schema.org/draft/2020-12/json-schema-validation#section-9 */ - public string $title = '', - public string $description = '', + public string|null $title = null, + public string|null $description = null, ) { } } diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php index 9320590..8f98c3a 100644 --- a/src/ValueObject/Valid/Enum/Type.php +++ b/src/ValueObject/Valid/Enum/Type.php @@ -54,7 +54,7 @@ public static function fromVersion( Identifier $identifier, OpenAPIVersion $version, string $type - ): ?self { + ): self { return self::tryFromVersion($version, $type) ?? throw InvalidOpenAPI::invalidType($identifier, $type); } diff --git a/src/ValueObject/Valid/Exception/SchemaShouldBeBoolean.php b/src/ValueObject/Valid/Exception/SchemaShouldBeBoolean.php new file mode 100644 index 0000000..e29eeea --- /dev/null +++ b/src/ValueObject/Valid/Exception/SchemaShouldBeBoolean.php @@ -0,0 +1,46 @@ + */ + public readonly array $types; + + /** @var list|null */ + public readonly array|null $enum; + public readonly Value|null $default; + + public readonly float|int|null $multipleOf; + public readonly Limit|null $maximum; + public readonly Limit|null $minimum; + + public readonly int|null $maxLength; + public readonly int $minLength; + public readonly string|null $pattern; + + public readonly Schema $items; + public readonly int|null $maxItems; + public readonly int $minItems; + public readonly bool $uniqueItems; + + public readonly int|null $maxProperties; + public readonly int $minProperties; + /** @var list */ + public readonly array $required; + /** @var array */ + public readonly array $properties; + public readonly bool|Schema $additionalProperties; + + /** @var list */ + public readonly array $allOf; + /** @var list */ + public readonly array $anyOf; + /** @var list */ + public readonly array $oneOf; + public readonly Schema $not; + + public readonly string $format; + + public readonly string $title; + public readonly string $description; + + /** @var Type[] */ + private readonly array $typesItCanBe; + + public function __construct( + Identifier $identifier, + Partial\Schema $schema + ) { + parent::__construct($identifier); + + $this->types = $this->validateTypes($schema->type, $schema->nullable); + $this->enum = $this->reviewEnum($this->types, $schema->enum); + $this->default = $this->validateDefault($this->types, $schema->default); + + $this->multipleOf = $this->validateMultipleOf($schema->multipleOf); + $this->maximum = $this->validateMinMax( + 'maximum', + $schema->maximum, + 'exclusiveMaximum', + $schema->exclusiveMaximum, + ); + $this->minimum = $this->validateMinMax( + 'minimum', + $schema->minimum, + 'exclusiveMinimum', + $schema->exclusiveMinimum + ); + + $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength, false); + $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength, true); + $this->pattern = $schema->pattern; //TODO validatePattern is valid regex + + $this->items = $this->reviewItems($this->types, $schema->items); + $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems, false); + $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems, true); + $this->uniqueItems = $schema->uniqueItems; + + $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties, false); + $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties, true); + //TODO if a property is defined as a false schema AND required, that should be a warning + //TODO if property is false schema and required, and type must be object, the whole schema is the false schema + $this->required = $this->validateRequired($schema->required); + $this->properties = $this->validateProperties($schema->properties); + $this->additionalProperties = new Schema( + $this->appendedIdentifier('additionalProperties'), + $schema->additionalProperties + ); + + + // make empty arrays instead of null + $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); + $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); + $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); + $this->not = new Schema( + $this->appendedIdentifier('not'), + $schema->not ?? false, + ); + + $this->format = $this->formatMetadataString($schema->format); + + $this->title = $this->formatMetadataString($schema->title); + $this->description = $this->formatMetadataString($schema->description); + + $this->typesItCanBe = array_map( + fn($t) => Type::from($t), + $this->typesItCanBe() + ); + + if (empty($this->typesItCanBe)) { + $this->addWarning( + 'no data type can satisfy this schema', + Warning::IMPOSSIBLE_SCHEMA + ); + } + } + + /** @return string[] */ + public function typesItCanBe(): array + { + $possibilities = [array_merge( + Type::valuesForVersion(OpenAPIVersion::Version_3_0), + [Type::Null->value], + )]; + + if ($this->types !== []) { + $possibilities[] = array_map(fn($t) => $t->value, $this->types); + } + + if (!empty($this->allOf)) { + $possibilities[] = array_intersect(...array_map( + fn($s) => $s->typesItCanBe(), + $this->allOf, + )); + } + + if (!empty($this->anyOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->anyOf + ))); + } + + if (!empty($this->oneOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->oneOf + ))); + } + + return array_values(array_intersect(...$possibilities)); + } + + /** + * @param null|string|array $type + * @return list + */ + private function validateTypes( + null|string|array $type, + bool $nullable, + ): array { + if (isset($this->types)) { + return $this->types; + } + + if (empty($type)) { // If type is unspecified, nullable has no effect + return []; // So we can return immediately + } + + if (is_string($type)) { // In 3.0 "type" must be a string, a single item + $type = [$type]; + } elseif (count($type) > 1) { // We can amend arrays of 1 but no greater + throw InvalidOpenAPI::keywordMustBeType( + $this->getIdentifier(), + 'type', + Type::String, + ); + } + + $result = array_map( + fn($t) => Type::tryFromVersion(OpenAPIVersion::Version_3_0, $t) + ?? throw InvalidOpenAPI::invalidType($this->getIdentifier(), $t), + $type, + ); + + if ($nullable) { + $result[] = Type::Null; + } + + return $result; + } + + /** + * @param list $types + * @param list|null $enum + * @return list + */ + private function reviewEnum( + array $types, + array|null $enum, + ): array { + if ($enum === null) { + return []; + } + + if ($enum === []) { + $this->addWarning('enum should not be empty', Warning::EMPTY_ENUM); + return $enum; + } + + if ($types === []) { + return $enum; + } + + $enumContainsValidValue = false; + foreach ($enum as $value) { + if (in_array($value->getType(), $types)) { + $enumContainsValidValue = true; + } else { + $this->addWarning( + "$value does not match allowed types", + Warning::MISLEADING, + ); + } + } + + if (! $enumContainsValidValue) { + throw Valid\Exception\SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any valid values', + ); + } + + return array_values($enum); + } + + /** + * @param list $types + */ + private function validateDefault(array $types, Value|null $default): Value|null + { + if ($default === null) { + return null; + } + + if (! in_array($default->getType(), $types)) { + throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); + } + + return $default; + } + + private function validateMultipleOf(float|int|null $value): float|int|null + { + if ($value === null || $value > 0) { + return $value; + } + + if ($value < 0) { + $this->addWarning( + 'multipleOf must be greater than zero', + Warning::INVALID, + ); + return abs($value); + } + + throw InvalidOpenAPI::keywordCannotBeZero( + $this->getIdentifier(), + 'multipleOf' + ); + } + + private function validateMinMax( + string $keyword, + float|int|null $minMax, + string $exclusiveKeyword, + bool|float|int|null $exclusiveMinMax, + ): Limit|null { + if (is_float($exclusiveMinMax) || is_integer($exclusiveMinMax)) { + throw InvalidOpenAPI::numericExclusiveMinMaxIn30( + $this->getIdentifier(), + $keyword, + ); + } + + if ($minMax === null) { + if ($exclusiveMinMax === true) { + $this->addWarning( + "$exclusiveKeyword has no effect without $keyword", + Warning::REDUNDANT, + ); + } + return null; + } + + return new Limit($minMax, $exclusiveMinMax ?? false); + } + + /** @return ($defaultsToZero is true ? int : int|null) */ + private function validateNonNegativeInteger( + string $keyword, + int|null $value, + bool $defaultsToZero, + ): int|null { + if ($value !== null && $value < 0) { + if (! $defaultsToZero) { + throw InvalidOpenAPI::keywordMustBeNonNegativeInteger( + $this->getIdentifier(), + $keyword + ); + } else { + $this->addWarning("$keyword must not be negative", Warning::INVALID); + return 0; + } + } + + return $value; + } + + /** @param list $types */ + private function reviewItems( + array $types, + Partial\Schema|null $items + ): Schema { + if (in_array(Type::Array, $types) && ! isset($items)) { + $this->addWarning( + 'items must be specified, if type is array', + Warning::INVALID, + ); + } + + return new Schema( + $this->getIdentifier()->append('items'), + $items ?? new Partial\Schema(), + ); + } + + /** + * @param array|null $required + * @return list + */ + private function validateRequired(array | null $required): array + { + if ($required === null) { + return []; + } + + if ($required === []) { + $this->addWarning('required must not be empty', Warning::INVALID); + return []; + } + + $uniqueRequired = array_unique($required); + + if (count($required) !== count($uniqueRequired)) { + $this->addWarning('required must not contain duplicates', Warning::INVALID); + } + + return $uniqueRequired; + } + + /** + * @param null|array $subSchemas + * @return list + */ + private function validateSubSchemas(string $keyword, ?array $subSchemas): array + { + if ($subSchemas === null) { + return []; + } + + if ($subSchemas === []) { + $this->addWarning("$keyword must not be empty", Warning::INVALID); + return []; + } + + $result = []; + //TODO if all subschemas have a title, use that instead of their index + foreach ($subSchemas as $index => $subSchema) { + $result[] = new Schema( + $this->getIdentifier()->append("$keyword($index)"), + $subSchema + ); + } + + return $result; + } + + /** + * @param array $properties + * @return array + */ + private function validateProperties(?array $properties): array + { + $properties ??= []; + + $result = []; + foreach ($properties as $key => $subSchema) { + if (!is_string($key)) { + throw InvalidOpenAPI::mustHaveStringKeys( + $this->getIdentifier(), + 'properties', + ); + } + + $result[$key] = new Schema( + $this->getIdentifier()->append("properties($key)"), + $subSchema + ); + } + + return $result; + } + + public function formatMetadataString(string|null $metadata): string + { + return trim($metadata ?? ''); + } +} diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index 1907a6d..aff5292 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -4,150 +4,51 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid\V30; -use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; -use Membrane\OpenAPIReader\ValueObject\Limit; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; +use Membrane\OpenAPIReader\ValueObject\Valid\Exception\SchemaShouldBeBoolean; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; -use Membrane\OpenAPIReader\ValueObject\Valid\Warning; -use Membrane\OpenAPIReader\ValueObject\Value; final class Schema extends Validated implements Valid\Schema { - /** @var list */ - public readonly array $types; - - /** @var list|null */ - public readonly array|null $enum; - public readonly Value|null $default; - - public readonly float|int|null $multipleOf; - public readonly Limit|null $maximum; - public readonly Limit|null $minimum; - - public readonly int|null $maxLength; - public readonly int $minLength; - public readonly string|null $pattern; - - public readonly Schema $items; - public readonly int|null $maxItems; - public readonly int $minItems; - public readonly bool $uniqueItems; - - public readonly int|null $maxProperties; - public readonly int $minProperties; - /** @var list */ - public readonly array $required; - /** @var array */ - public readonly array $properties; - public readonly bool|Schema $additionalProperties; - - /** @var list */ - public readonly array $allOf; - /** @var list */ - public readonly array $anyOf; - /** @var list */ - public readonly array $oneOf; - public readonly bool|Schema $not; - - public readonly string $format; - - public readonly string $title; - public readonly string $description; - - /** @var Type[] */ - private readonly array $typesItCanBe; - - //TODO how do we determine booleanSchemas - // public readonly bool $isEmpty; - - //TODO can we limit additionalProperties and not to Schema? - // We need to do so without creating an infinite loop of Schemas + public readonly bool|Keywords $value; public function __construct( Identifier $identifier, - Partial\Schema $schema + bool|Partial\Schema $schema, ) { parent::__construct($identifier); - $this->types = $this->validateTypes( - $identifier, - $schema->type, - $schema->nullable - ); - - //TODO reviewEnum is not empty, but allow it to stay empty - $this->enum = $schema->enum; - $this->default = $schema->default; - - $this->multipleOf = $this->validatePositiveNumber('multipleOf', $schema->multipleOf); - $this->maximum = isset($schema->maximum) ? - new Limit($schema->maximum, $this->validateExclusiveMinMax('exclusiveMaximum', $schema->exclusiveMaximum)) : - null; - $this->minimum = isset($schema->minimum) ? - new Limit($schema->minimum, $this->validateExclusiveMinMax('exclusiveMinimum', $schema->exclusiveMinimum)) : - null; - - $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength, false); - $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength, true); - $this->pattern = $schema->pattern; - - $this->items = $this->validateItems($this->types, $schema->items); - $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems, false); - $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems, true); - $this->uniqueItems = $schema->uniqueItems; - - $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties, false); - $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties, true); - $this->required = $this->validateRequired($schema->required); - $this->properties = $this->validateProperties($schema->properties); - //todo make it a boolean schema when required - $this->additionalProperties = $schema->additionalProperties instanceof Partial\Schema ? - new Schema($this->appendedIdentifier('additionalProperties'), $schema->additionalProperties) : - $schema->additionalProperties; - - // make empty arrays instead of null - $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); - $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); - $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); - $this->not = $schema->not instanceof Partial\Schema ? - new Schema($this->getIdentifier()->append('not'), $schema->not) : - $schema->not; - - $this->format = $this->formatMetadataString($schema->format); - - $this->title = $this->formatMetadataString($schema->title); - $this->description = $this->formatMetadataString($schema->description); - - $this->typesItCanBe = array_map( - fn($t) => Type::from($t), - $this->typesItCanBe() - ); - - if (empty($this->typesItCanBe)) { - $this->addWarning( - 'no data type can satisfy this schema', - Warning::IMPOSSIBLE_SCHEMA - ); + if (is_bool($schema)) { + $this->value = $schema; + } else { + try { + $this->value = new Keywords($identifier, $schema); + } catch (SchemaShouldBeBoolean $e) { + $this->addWarning($e->getMessage(), Valid\Warning::IMPOSSIBLE_SCHEMA); + $this->value = $e->getCode() === SchemaShouldBeBoolean::ALWAYS_TRUE; + } } } public function canBe(Type $type): bool { - return in_array($type, $this->typesItCanBe); + return in_array($type->value, $this->typesItCanBe()); } public function canOnlyBe(Type $type): bool { - return [$type] === $this->typesItCanBe; + return [$type->value] === $this->typesItCanBe(); } public function canBePrimitive(): bool { - foreach ($this->typesItCanBe as $typeItCanBe) { + $types = array_map(fn($t) => Type::from($t), $this->typesItCanBe()); + + foreach ($types as $typeItCanBe) { if ($typeItCanBe->isPrimitive()) { return true; } @@ -156,212 +57,18 @@ public function canBePrimitive(): bool return false; } - /** @return string[] */ - private function typesItCanBe(): array - { - $possibilities = [Type::valuesForVersion(OpenAPIVersion::Version_3_0)]; - - if ($this->types !== []) { - $possibilities[] = $this->types; - } - - if (!empty($this->allOf)) { - $possibilities[] = array_intersect(...array_map( - fn($s) => $s->typesItCanBe(), - $this->allOf, - )); - } - - if (!empty($this->anyOf)) { - $possibilities[] = array_unique(array_merge(...array_map( - fn($s) => $s->typesItCanBe(), - $this->anyOf - ))); - } - - if (!empty($this->oneOf)) { - $possibilities[] = array_unique(array_merge(...array_map( - fn($s) => $s->typesItCanBe(), - $this->oneOf - ))); - } - - return array_values(array_intersect(...$possibilities)); - } - - /** - * @param null|string|array $type - * @return list - */ - private function validateTypes( - Identifier $identifier, - null|string|array $type, - bool $nullable, - ): array { - if (empty($type)) { // If type is unspecified, nullable has no effect - return []; // So we can return immediately - } - - if (is_string($type)) { - $type = [$type]; - } elseif (count($type) > 1) { - throw InvalidOpenAPI::keywordMustBeType( - $identifier, - 'type', - Type::String, - ); - } - - $result = array_map( - fn($t) => Type::tryFromVersion(OpenAPIVersion::Version_3_0, $t) - ?? throw InvalidOpenAPI::invalidType($identifier, $t), - $type, - ); - - if ($nullable) { - $result[] = Type::Null; - } - - return $result; - } - - private function validateExclusiveMinMax( - string $keyword, - bool|float|int|null $exclusiveMinMax, - ): bool { - if (is_float($exclusiveMinMax) || is_integer($exclusiveMinMax)) { - throw InvalidOpenAPI::numericExclusiveMinMaxIn30($this->getIdentifier(), $keyword); - } - - return $exclusiveMinMax ?? false; - } - - private function validatePositiveNumber( - string $keyword, - float|int|null $value - ): float|int|null { - if ($value !== null && $value <= 0) { - throw InvalidOpenAPI::keywordMustBeStrictlyPositiveNumber($this->getIdentifier(), $keyword); - } - - return $value; - } - - /** @return ($defaultsToZero is true ? int : int|null) */ - private function validateNonNegativeInteger( - string $keyword, - int|null $value, - bool $defaultsToZero, - ): int|null { - if ($value !== null && $value < 0) { - if (! $defaultsToZero) { - throw InvalidOpenAPI::keywordMustBeNonNegativeInteger( - $this->getIdentifier(), - $keyword - ); - } else { - $this->addWarning("$keyword must not be negative", Warning::INVALID_API); - return 0; - } - } - - return $value; - } - - /** - * @param array|null $required - * @return list - */ - private function validateRequired(array | null $required): array - { - if ($required === null) { - return []; - } - - if ($required === []) { - $this->addWarning('required must not be empty', Warning::INVALID_API); - return []; - } - - $uniqueRequired = array_unique($required); - - if (count($required) !== count($uniqueRequired)) { - $this->addWarning('required must not contain duplicates', Warning::INVALID_API); - } - - return $uniqueRequired; - } - - /** - * @param null|array $subSchemas - * @return list - */ - private function validateSubSchemas(string $keyword, ?array $subSchemas): array + /** @return list */ + public function typesItCanBe(): array { - if ($subSchemas === null) { - return []; - } - - if ($subSchemas === []) { - $this->addWarning("$keyword must not be empty", Warning::INVALID_API); + if ($this->value === true) { + return [ + Type::Null->value, + ...Type::valuesForVersion(OpenAPIVersion::Version_3_0), + ]; + } elseif ($this->value === false) { return []; + } else { + return $this->value->typesItCanBe(); } - - $result = []; - foreach ($subSchemas as $index => $subSchema) { - $result[] = new Schema( - $this->getIdentifier()->append("$keyword($index)"), - $subSchema - ); - } - - return $result; - } - - /** - * @param array $properties - * @return array - */ - private function validateProperties(?array $properties): array - { - $properties ??= []; - - $result = []; - foreach ($properties as $key => $subSchema) { - if (!is_string($key)) { - throw InvalidOpenAPI::mustHaveStringKeys( - $this->getIdentifier(), - 'properties', - ); - } - - $result[$key] = new Schema( - $this->getIdentifier()->append("properties($key)"), - $subSchema - ); - } - - return $result; - } - - /** @param list $types */ - private function validateItems(array $types, Partial\Schema|null $items): Schema - { - if (in_array(Type::Array, $types) && !isset($items)) { - $this->addWarning( - 'items must be specified, if type is specified as array', - Warning::INVALID_API, - ); - } - - return new Schema( - $this->getIdentifier()->append('items'), - $items ?? new Partial\Schema(), - ); - } - - public function formatMetadataString(string $metadata): string - { - return trim($metadata); } } diff --git a/src/ValueObject/Valid/Validated.php b/src/ValueObject/Valid/Validated.php index 77edff4..f88dcc7 100644 --- a/src/ValueObject/Valid/Validated.php +++ b/src/ValueObject/Valid/Validated.php @@ -37,6 +37,7 @@ public function hasWarnings(): bool return $this->warnings->hasWarnings(); } + /** @return Warnings contains the list of issues found during validation */ public function getWarnings(): Warnings { return $this->warnings; diff --git a/src/ValueObject/Valid/Warning.php b/src/ValueObject/Valid/Warning.php index 5aa014e..eb00fd7 100644 --- a/src/ValueObject/Valid/Warning.php +++ b/src/ValueObject/Valid/Warning.php @@ -12,11 +12,11 @@ public function __construct( ) { } - /** - * Warning code given for: - * An API that is technically invalid but safely corrected. - */ - public const INVALID_API = 'invalid-api'; + public const INVALID = 'invalid'; + + public const MISLEADING = 'misleading'; + + public const REDUNDANT = 'redundant'; /** * Server Variable: "enum" SHOULD NOT be empty diff --git a/src/ValueObject/Value.php b/src/ValueObject/Value.php index 9f6a6ea..2584380 100644 --- a/src/ValueObject/Value.php +++ b/src/ValueObject/Value.php @@ -4,10 +4,46 @@ namespace Membrane\OpenAPIReader\ValueObject; -final class Value +use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; +use RuntimeException; +use Stringable; + +final class Value implements Stringable { public function __construct( public readonly mixed $value, ) { } + + public function getType(): Type + { + if (is_array($this->value)) { + return array_is_list($this->value) ? Type::Array : Type::Object; + } + + if (is_bool($this->value)) { + return Type::Boolean; + } + + if (is_float($this->value)) { + return Type::Number; + } + + if (is_int($this->value)) { + return Type::Integer; + } + + if (is_string($this->value)) { + return Type::String; + } + + return Type::Null; + } + + public function __toString(): string + { + return json_encode($this->value) ?: + throw new RuntimeException('Failed to encode value'); + } + } diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index 5d8c6f4..66ed9a9 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -38,24 +38,25 @@ class SchemaTest extends TestCase #[Test] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] - public function itReviewsSchema(Partial\Schema $schema, Warning $warning): void + public function itReviewsSchema(Partial\Schema $schema, Warnings $warnings): void { $sut = new Schema(new Identifier('test'), $schema); - self::assertContainsEquals($warning, $sut->getWarnings()->all()); + self::assertContainsEquals($warnings, $sut->getWarnings()->all()); } #[Test] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] - public function itAmendsSchema(Partial\Schema $schema, Warning $warning, Partial\Schema $amendment): void - { - $schema = new Schema(new Identifier('test'), $schema); - $amendment = new Schema(new Identifier('test'), $amendment); + public function itAmendsSchema( + Partial\Schema $schema, + $_, + string $propertyName, + mixed $expected, + ): void { + $sut = new Schema(new Identifier('test'), $schema); - self::assertNotContainsEquals($warning, $amendment->getWarnings()->all()); - //@TODO assert equality of all properties EXCEPT FOR the warning - //self::assertEquals($amended, $original); + self::assertEquals($expected, $sut->{$propertyName}); } #[Test] diff --git a/tests/fixtures/ProvidesReviewedSchemas.php b/tests/fixtures/ProvidesReviewedSchemas.php index ce32ee2..85d0352 100644 --- a/tests/fixtures/ProvidesReviewedSchemas.php +++ b/tests/fixtures/ProvidesReviewedSchemas.php @@ -16,44 +16,46 @@ final class ProvidesReviewedSchemas * @return Generator */ public static function provideV3xReviews(): Generator { yield 'empty allOf' => [ new Partial\Schema(allOf: []), - new Warning('allOf must not be empty', Warning::INVALID_API), - new Partial\Schema(allOf: null), + new Warning('allOf must not be empty', Warning::INVALID), + 'allOf', + [], ]; yield 'empty anyOf' => [ new Partial\Schema(anyOf: []), - new Warning('anyOf must not be empty', Warning::INVALID_API), + new Warning('anyOf must not be empty', Warning::INVALID), new Partial\Schema(anyOf: null), ]; yield 'empty oneOf' => [ new Partial\Schema(oneOf: []), - new Warning('oneOf must not be empty', Warning::INVALID_API), + new Warning('oneOf must not be empty', Warning::INVALID), new Partial\Schema(oneOf: null), ]; yield 'negative minLength' => [ new Partial\Schema(minLength: -1), - new Warning('minLength must not be negative', Warning::INVALID_API), + new Warning('minLength must not be negative', Warning::INVALID), new Partial\Schema(minLength: 0), ]; yield 'negative minItems' => [ new Partial\Schema(minItems: -1), - new Warning('minItems must not be negative', Warning::INVALID_API), + new Warning('minItems must not be negative', Warning::INVALID), new Partial\Schema(minItems: 0), ]; yield 'negative minProperties' => [ new Partial\Schema(minProperties: -1), - new Warning('minProperties must not be negative', Warning::INVALID_API), + new Warning('minProperties must not be negative', Warning::INVALID), new Partial\Schema(minProperties: 0), ]; } @@ -69,13 +71,13 @@ public static function provideV30Reviews(): Generator { yield 'empty required' => [ new Partial\Schema(required: []), - new Warning('required must not be empty', Warning::INVALID_API), + new Warning('required must not be empty', Warning::INVALID), new Partial\Schema(required: null), ]; yield 'required contains duplicates' => [ new Partial\Schema(required: ['id', 'id']), - new Warning('required must not contain duplicates', Warning::INVALID_API), + new Warning('required must not contain duplicates', Warning::INVALID), new Partial\Schema(required: ['id']), ]; } From e1a1e57b2b6739df8334511406f095fc019e3e62 Mon Sep 17 00:00:00 2001 From: John Charman Date: Thu, 5 Dec 2024 11:04:49 +0000 Subject: [PATCH 07/21] Add BooleanSchema --- docs/object-simplifications.md | 56 ++++++ docs/validation-deviations.md | 77 ++++++++ docs/validation.md | 58 ------ src/ValueObject/Partial/Schema.php | 6 +- src/ValueObject/Valid/Schema.php | 1 - src/ValueObject/Valid/V30/Keywords.php | 22 +-- src/ValueObject/Valid/V30/Schema.php | 6 +- src/ValueObject/Valid/Validated.php | 5 +- src/ValueObject/Value.php | 1 - tests/ValueObject/Valid/V30/SchemaTest.php | 200 ++++++++++++++------- tests/fixtures/Helper/OpenAPIProvider.php | 18 +- tests/fixtures/Helper/PartialHelper.php | 2 +- tests/fixtures/ProvidesReviewedSchemas.php | 63 +++++-- 13 files changed, 343 insertions(+), 172 deletions(-) create mode 100644 docs/object-simplifications.md create mode 100644 docs/validation-deviations.md delete mode 100644 docs/validation.md diff --git a/docs/object-simplifications.md b/docs/object-simplifications.md new file mode 100644 index 0000000..6d0ec04 --- /dev/null +++ b/docs/object-simplifications.md @@ -0,0 +1,56 @@ +# How The Reader Simplifies Your OpenAPI + +Besides simply parsing your OpenAPI into `Validated` objects, +the reader is designed to _simplify the developer experience_ of other OpenAPI tools. + +## Standard Simplifications + +### Never Worry About Uninitialized Properties +Properties should be safe to access: +All properties have a value. +`null` represents an omitted field _if_ no other value can be safely assumed. + +### Strong Typehints +Data structures should be easily discoverable. +All properties should have strong typehints. + +## Opinionated Simplifications + +## Narrow Typehints +Typehints are narrowed, if and only if, it has no impact on expressiveness. + +### String Metadata Is Always String + +Optional metadata is expressed in two ways; +1. There is data +2. There is not + +As such, the Reader structures metadata in two ways: +1. A string containing non-whitespace characters +2. An empty string `''` + +When accessing string metadata only one check is necessary: + +```php +if ($metadata !== '') { + // do something... +} +``` + +## Combined Fields +Data is combined, if and only if, it has no impact on expressiveness. + +### Maximum|Minimum are combined with ExclusiveMaximum|ExclusiveMinimum + +The structure of numeric maximums and minimums vary between versions. +However, both versions only express two things: +- Is there a limit +- Is it exclusive + +As such the Reader combines the fields into: +- A `Limit|null $maximum`. +- A `Limit|null $minimum`. + +Where A `Limit` has two properties: +- `float|int $limit` +- `bool $exclusive` diff --git a/docs/validation-deviations.md b/docs/validation-deviations.md new file mode 100644 index 0000000..e324574 --- /dev/null +++ b/docs/validation-deviations.md @@ -0,0 +1,77 @@ +# Validation And How It Deviates From OpenAPI Specification +This page documents where validation deviates from the OpenAPI Specification. + +## Stricter Requirements +It is stricter when it comes to providing an unambiguous specification. +This helps to avoid confusion as well as simplify development for other OpenAPI tools. + +### OperationId Is Required + +Operations MUST have an [operationId](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-8). + +`operationId` is a unique string used to identify an operation. +By making it required, it serves as a _reliable_ method of identification. + +### Query Strings Must Be Unambiguous + +Operations MUST NOT use more than one _ambiguous parameter_. + +In this context, an _ambiguous parameter_ is defined as being `in:query` with one of the following combinations: +- `type:object` with `style:form` and `explode:true` +- `type:object` or `type:array` with `style:spaceDelimited` +- `type:object` or `type:array` with `style:pipeDelimited` + +If everything else can be identified; then through a process of elimination, the ambiguous segment belongs to the _ambiguous parameter_ + +If an operation contains two or more _ambiguous parameters_, then there are multiple ways of interpreting the ambiguous segment. +This ambiguity means the query string cannot be resolved deterministically. +As such, it is not allowed. + +## Looser Requirements + +These requirements are looser than the OpenAPI Specification. +Where the OpenAPI Specification would be invalid, the reader will add a warning to the `Validated` object. + +### MultipleOf Can Be Negative + +Normally `multipleOf` MUST be a positive non-zero number. + +The Reader allows `multipleOf` to be any non-zero number. +A multiple of a negative number is also a multiple of its absolute value. +It's more confusing, but what is expressed is identical. + +Therefore, if a negative value is given: +- You will receive a Warning +- The absolute value will be used + +### AllOf, AnyOf and OneOf Can Be Empty + +Normally, `allOf`, `anyOf` and `oneOf` MUST be non-empty. + +The Reader allows them to be empty. + +[] and null express basically the same thing, no value. +By replacing null with [], we can narrow the typehint from `array|null` to `array`. +Simplifies code using it. + +### Required Can Be Empty + +OpenAPI 3.0 states `required` MUST be non-empty. +OpenAPI 3.1 allows `required` to be empty and defaults to empty if omitted. + +The Reader always allows `required` to be empty and defaults to empty if omitted. +This allows us to narrow the typehint for `required` to always being an array. + +If an empty array is given: +- If your API is using 3.0, you will receive a Warning +- An empty array will be used + +### Required Can Contain Duplicates + +Normally `required` MUST contain unique values. + +The Reader allows `required` to contain duplicates. + +If a duplicate item is found: +- You will receive a Warning +- The duplicate will be removed. diff --git a/docs/validation.md b/docs/validation.md deleted file mode 100644 index 846cbc2..0000000 --- a/docs/validation.md +++ /dev/null @@ -1,58 +0,0 @@ -# Validation Performed By OpenAPI Reader - -## Additional Requirements For Membrane. - -### Specify an OperationId - -Membrane requires all Operations to set a [unique](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-8) `operationId`. - -This is used for identification of all available operations across your OpenAPI. - -### Unambiguous Query Strings - -For query parameters (i.e. `in:query`) with a `schema` that allows compound types i.e. `array` or `objects` -there are certain combinations of `style` and `explode` that do not use the parameter's `name`. - -These combinations are: -- `type:object` with `style:form` and `explode:true` -- `type:object` or `type:array` with `style:spaceDelimited` -- `type:object` or `type:array` with `style:pipeDelimited` - -If an operation only has one query parameter (i.e. `in:query`) then this is fine. Membrane can safely assume the entire string belongs to that one parameter. - -If an operation contains two query parameters, both of which do not use the parameter's name; Membrane cannot ascertain which parameter relates to which part of the query string. - -This ambiguity leads to multiple "correct" ways to interpret the query string. Making it impossible to safely assume Membrane has validated it. Therefore, only one parameter, with one of the above combinations, is allowed on any given Operation. - -## Version 3.0.X - -### OpenAPI Object - -- [An OpenAPI Object requires an `openapi` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields). -- [An OpenAPI Object requires an `info` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields). - - [The Info Object requires a `title`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-1). - - [The Info Object requires a `version`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-1). -- [All Path Items must be mapped to by their relative endpoint](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#paths-object). -### Path Item - -- [All Operations MUST be mapped to by a method](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-7). -- [Parameters must be unique. Uniqueness is defined by a combination of "name" and "in".](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-7) - -### Operation - -- [Parameters must be unique. Uniqueness is defined by a combination of "name" and "in".](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-8) - -### Parameter - -- A Parameter [MUST contain a `name` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). -- A Parameter [MUST contain an `in` field](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - `in` [MUST be set to `path`, `query`, `header` or `cookie`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - [if `in:path` then the Parameter MUST specify `required:true`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - if `style` is specified, [acceptable values depend on the value of `in`](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#style-values). -- [A Parameter MUST contain a `schema` or `content`, but not both](https://github.com/OAI/OpenAPI-Specification/blob/3.1.0/versions/3.0.3.md#fixed-fields-10). - - if `content` is specified, it MUST contain exactly one Media Type - - A Parameter's MediaType MUST contain a schema. - -### Schema - -- [If allOf, anyOf or oneOf are set; They MUST not be empty](https://json-schema.org/draft/2020-12/json-schema-core#section-10.2). diff --git a/src/ValueObject/Partial/Schema.php b/src/ValueObject/Partial/Schema.php index 47fb2c8..16b6455 100644 --- a/src/ValueObject/Partial/Schema.php +++ b/src/ValueObject/Partial/Schema.php @@ -20,8 +20,8 @@ public function __construct( * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#autoid-11 * 3.1 https://json-schema.org/draft/2020-12/json-schema-validation#name-validation-keywords-for-any */ - /** @var null|string|array */ - public null|string|array $type = null, + /** @var string[]|string|null */ + public array|string|null $type = null, /** @var array|null */ public array|null $enum = null, public Value|null $const = null, @@ -81,7 +81,7 @@ public function __construct( public array|null $anyOf = null, /** @var array|null */ public array|null $oneOf = null, - public Schema|null $not = null, + public bool|Schema|null $not = null, /** * Keywords for applying subschemas conditionally * 3.0 https://datatracker.ietf.org/doc/html/draft-wright-json-schema-validation-00#section-5.22 diff --git a/src/ValueObject/Valid/Schema.php b/src/ValueObject/Valid/Schema.php index 98988b5..3ef6dc1 100644 --- a/src/ValueObject/Valid/Schema.php +++ b/src/ValueObject/Valid/Schema.php @@ -6,5 +6,4 @@ interface Schema { - } diff --git a/src/ValueObject/Valid/V30/Keywords.php b/src/ValueObject/Valid/V30/Keywords.php index ccb63c0..883017a 100644 --- a/src/ValueObject/Valid/V30/Keywords.php +++ b/src/ValueObject/Valid/V30/Keywords.php @@ -63,9 +63,10 @@ final class Keywords extends Validated implements Valid\Schema public function __construct( Identifier $identifier, + Valid\Warnings $warnings, Partial\Schema $schema ) { - parent::__construct($identifier); + parent::__construct($identifier, $warnings); $this->types = $this->validateTypes($schema->type, $schema->nullable); $this->enum = $this->reviewEnum($this->types, $schema->enum); @@ -102,11 +103,11 @@ public function __construct( $this->properties = $this->validateProperties($schema->properties); $this->additionalProperties = new Schema( $this->appendedIdentifier('additionalProperties'), - $schema->additionalProperties + $schema->additionalProperties, ); - // make empty arrays instead of null + //TODO throw ShouldBeBooleanSchema::false if allOf contains false schema $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); @@ -297,7 +298,7 @@ private function validateMinMax( if (is_float($exclusiveMinMax) || is_integer($exclusiveMinMax)) { throw InvalidOpenAPI::numericExclusiveMinMaxIn30( $this->getIdentifier(), - $keyword, + $exclusiveKeyword, ); } @@ -349,7 +350,7 @@ private function reviewItems( return new Schema( $this->getIdentifier()->append('items'), - $items ?? new Partial\Schema(), + $items ?? true, ); } @@ -393,12 +394,13 @@ private function validateSubSchemas(string $keyword, ?array $subSchemas): array } $result = []; - //TODO if all subschemas have a title, use that instead of their index foreach ($subSchemas as $index => $subSchema) { - $result[] = new Schema( - $this->getIdentifier()->append("$keyword($index)"), - $subSchema - ); + $identifier = $this->appendedIdentifier($keyword, sprintf( + empty(trim($subSchema->title ?? '')) ? '%s' : '%2$s[%1$s]', + $index, + trim($subSchema->title ?? ''), + )); + $result[] = new Schema($identifier, $subSchema); } return $result; diff --git a/src/ValueObject/Valid/V30/Schema.php b/src/ValueObject/Valid/V30/Schema.php index aff5292..f6a2b38 100644 --- a/src/ValueObject/Valid/V30/Schema.php +++ b/src/ValueObject/Valid/V30/Schema.php @@ -26,7 +26,11 @@ public function __construct( $this->value = $schema; } else { try { - $this->value = new Keywords($identifier, $schema); + $this->value = new Keywords( + $this->getIdentifier(), + $this->getWarnings(), + $schema + ); } catch (SchemaShouldBeBoolean $e) { $this->addWarning($e->getMessage(), Valid\Warning::IMPOSSIBLE_SCHEMA); $this->value = $e->getCode() === SchemaShouldBeBoolean::ALWAYS_TRUE; diff --git a/src/ValueObject/Valid/Validated.php b/src/ValueObject/Valid/Validated.php index f88dcc7..b866644 100644 --- a/src/ValueObject/Valid/Validated.php +++ b/src/ValueObject/Valid/Validated.php @@ -5,7 +5,7 @@ namespace Membrane\OpenAPIReader\ValueObject\Valid; /** - * A Validated object **may** make _opinionated optimizations_ to improve DX. + * A Validated object **may** make _opinionated simplifications_ to improve DX. * - It **may** change _appearance_ from its OpenAPI counterpart. * - It **must** express the same _intent_ as its OpenAPI counterpart. */ @@ -15,8 +15,9 @@ abstract class Validated implements HasIdentifier, HasWarnings public function __construct( private readonly Identifier $identifier, + Warnings|null $warnings = null, ) { - $this->warnings = new Warnings($this->identifier); + $this->warnings = $warnings ?? new Warnings($this->identifier); } public function getIdentifier(): Identifier diff --git a/src/ValueObject/Value.php b/src/ValueObject/Value.php index 2584380..ad0ac69 100644 --- a/src/ValueObject/Value.php +++ b/src/ValueObject/Value.php @@ -45,5 +45,4 @@ public function __toString(): string return json_encode($this->value) ?: throw new RuntimeException('Failed to encode value'); } - } diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index 66ed9a9..ac4eed4 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -7,7 +7,6 @@ use Generator; use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; -use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesReviewedSchemas; use Membrane\OpenAPIReader\ValueObject\Limit; use Membrane\OpenAPIReader\ValueObject\Partial; @@ -17,6 +16,7 @@ use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Valid\Warnings; +use Membrane\OpenAPIReader\ValueObject\Value; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DataProviderExternal; @@ -38,17 +38,17 @@ class SchemaTest extends TestCase #[Test] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] - public function itReviewsSchema(Partial\Schema $schema, Warnings $warnings): void + public function itReviewsSchema(Partial\Schema $schema, array $warnings): void { $sut = new Schema(new Identifier('test'), $schema); - self::assertContainsEquals($warnings, $sut->getWarnings()->all()); + self::assertEqualsCanonicalizing($warnings, $sut->getWarnings()->all()); } #[Test] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] - public function itAmendsSchema( + public function itSimplifiesSchema( Partial\Schema $schema, $_, string $propertyName, @@ -56,7 +56,7 @@ public function itAmendsSchema( ): void { $sut = new Schema(new Identifier('test'), $schema); - self::assertEquals($expected, $sut->{$propertyName}); + self::assertEquals($expected, $sut->value->{$propertyName}); } #[Test] @@ -140,7 +140,7 @@ public function itGetsRelevantMaximum(?Limit $expected, Partial\Schema $schema): { $sut = new Schema(new Identifier(''), $schema); - self::assertEquals($expected, $sut->getRelevantMaximum()); + self::assertEquals($expected, $sut->value->maximum); } #[Test] @@ -150,20 +150,20 @@ public function itGetsRelevantMinimum(?Limit $expected, Partial\Schema $schema): { $sut = new Schema(new Identifier(''), $schema); - self::assertEquals($expected, $sut->getRelevantMinimum()); + self::assertEquals($expected, $sut->value->minimum); } /** * @param Type[] $expected */ #[Test] - #[TestDox('It gets the types allowed, in a version agnostic format')] + #[TestDox('It gets types specified, in a version agnostic format')] #[DataProvider('provideSchemasToGetTypes')] public function itGetsTypes(array $expected, Partial\Schema $schema): void { $sut = new Schema(new Identifier(''), $schema); - self::assertEqualsCanonicalizing($expected, $sut->getTypes()); + self::assertEqualsCanonicalizing($expected, $sut->value->types); } public static function provideInvalidSchemas(): Generator @@ -182,6 +182,68 @@ public static function provideInvalidSchemas(): Generator new Identifier('properties list'), new Partial\Schema(properties: [new Partial\Schema()]), ]; + + yield 'negative maxLength' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxLength'), + 'maxLength', + ), + new Identifier('negative maxLength'), + new Partial\Schema(maxLength: -1), + ]; + + yield 'negative maxItems' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxItems'), + 'maxItems', + ), + new Identifier('negative maxItems'), + new Partial\Schema(maxItems: -1), + ]; + + yield 'negative maxProperties' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxProperties'), + 'maxProperties', + ), + new Identifier('negative maxProperties'), + new Partial\Schema(maxProperties: -1), + ]; + + yield 'zero multipleOf' => [ + InvalidOpenAPI::keywordCannotBeZero( + new Identifier('zero multipleOf'), + 'multipleOf', + ), + new Identifier('zero multipleOf'), + new Partial\Schema(multipleOf: 0), + ]; + + yield 'default does not conform to type' => [ + InvalidOpenAPI::defaultMustConformToType( + new Identifier('non-conforming default'), + ), + new Identifier('non-conforming default'), + new Partial\Schema(type: 'string', default: new Value(1)), + ]; + + yield 'numeric exclusiveMaximum in 3.0' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30( + new Identifier('numeric exclusiveMaximum'), + 'exclusiveMaximum' + ), + new Identifier('numeric exclusiveMaximum'), + new Partial\Schema(exclusiveMaximum: 5), + ]; + + yield 'numeric exclusiveMinimum in 3.0' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30( + new Identifier('numeric exclusiveMinimum'), + 'exclusiveMinimum' + ), + new Identifier('numeric exclusiveMinimum'), + new Partial\Schema(exclusiveMinimum: 5), + ]; } /** @@ -195,53 +257,53 @@ public static function provideSchemasToCheckTypes(): Generator { foreach (Type::cases() as $typeToCheck) { yield "$typeToCheck->value? empty schema" => [ - Type::casesForVersion(OpenAPIVersion::Version_3_0), - $typeToCheck, - PartialHelper::createSchema(), + Type::cases(), + $typeToCheck, + new Partial\Schema(), ]; foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { yield "$typeToCheck->value? top level type: $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(type: $type->value) + new Partial\Schema(type: $type->value) ]; yield "$typeToCheck->value? allOf MUST be $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(allOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(allOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ]) ]; yield "$typeToCheck->value? anyOf MUST be $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(anyOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(anyOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ]) ]; yield "$typeToCheck->value? oneOf MUST be $type->value" => [ [$type], $typeToCheck, - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ]) ]; yield "$typeToCheck->value? top-level type: string, allOf MUST be $type->value" => [ $type === Type::String ? [Type::String] : [], $typeToCheck, - PartialHelper::createSchema( + new Partial\Schema( type: Type::String->value, allOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ] ) ]; @@ -249,11 +311,11 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? top-level type: number, anyOf MUST be $type->value" => [ $type === Type::Number ? [Type::Number] : [], $typeToCheck, - PartialHelper::createSchema( + new Partial\Schema( type: Type::Number->value, anyOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ] ) ]; @@ -261,11 +323,11 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? top-level type: array, oneOf MUST be $type->value" => [ $type === Type::Array ? [Type::Array] : [], $typeToCheck, - PartialHelper::createSchema( + new Partial\Schema( type: Type::Array->value, oneOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), ] ) ]; @@ -275,9 +337,9 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? anyOf MAY be $type->value|string" => [ [$type, Type::String], $typeToCheck, - PartialHelper::createSchema(anyOf: [ - PartialHelper::createSchema(type: 'string'), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(anyOf: [ + new Partial\Schema(type: 'string'), + new Partial\Schema(type: $type->value), ]) ]; } @@ -286,9 +348,9 @@ public static function provideSchemasToCheckTypes(): Generator yield "$typeToCheck->value? oneOf MAY be $type->value|boolean" => [ [$type, Type::Boolean], $typeToCheck, - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: 'boolean'), - PartialHelper::createSchema(type: $type->value), + new Partial\Schema(oneOf: [ + new Partial\Schema(type: 'boolean'), + new Partial\Schema(type: $type->value), ]) ]; } @@ -297,10 +359,10 @@ public static function provideSchemasToCheckTypes(): Generator yield "can it be $typeToCheck->value? allOf contains oneOf that may be $type->value|integer" => [ [$type, Type::Integer], $typeToCheck, - PartialHelper::createSchema(allOf: [ - PartialHelper::createSchema(oneOf: [ - PartialHelper::createSchema(type: $type->value), - PartialHelper::createSchema(type: 'integer') + new Partial\Schema(allOf: [ + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: 'integer') ]), ]) ]; @@ -335,7 +397,7 @@ public static function provideSchemasWithMax(): Generator { yield 'no min or max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -345,7 +407,7 @@ public static function provideSchemasWithMax(): Generator yield 'inclusive min' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -355,7 +417,7 @@ public static function provideSchemasWithMax(): Generator yield 'exclusive min' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: null, @@ -365,7 +427,7 @@ public static function provideSchemasWithMax(): Generator yield 'inclusive max' => [ new Limit(1, false), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: 1, @@ -375,7 +437,7 @@ public static function provideSchemasWithMax(): Generator yield 'exclusive max' => [ new Limit(1, true), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: true, exclusiveMinimum: false, maximum: 1, @@ -385,7 +447,7 @@ public static function provideSchemasWithMax(): Generator yield 'inclusive max and exclusive min' => [ new Limit(5, false), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: 5, @@ -404,7 +466,7 @@ public static function provideSchemasWithMin(): Generator { yield 'no min or max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -414,7 +476,7 @@ public static function provideSchemasWithMin(): Generator yield 'inclusive min' => [ new Limit(1, false), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: null, @@ -424,7 +486,7 @@ public static function provideSchemasWithMin(): Generator yield 'exclusive min' => [ new Limit(1, true), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: null, @@ -434,7 +496,7 @@ public static function provideSchemasWithMin(): Generator yield 'inclusive max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: false, maximum: 1, @@ -444,7 +506,7 @@ public static function provideSchemasWithMin(): Generator yield 'exclusive max' => [ null, - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: true, exclusiveMinimum: false, maximum: 1, @@ -454,7 +516,7 @@ public static function provideSchemasWithMin(): Generator yield 'inclusive max and exclusive min' => [ new Limit(1, true), - PartialHelper::createSchema( + new Partial\Schema( exclusiveMaximum: false, exclusiveMinimum: true, maximum: 5, @@ -469,48 +531,48 @@ public static function provideSchemasWithMin(): Generator public static function provideSchemasToGetTypes(): Generator { yield 'no type' => [ - Type::casesForVersion(OpenAPIVersion::Version_3_0), - PartialHelper::createSchema(), + [], + new Partial\Schema(), ]; yield 'nullable' => [ - [Type::Null, ...Type::casesForVersion(OpenAPIVersion::Version_3_0)], - PartialHelper::createSchema(nullable: true) + [], + new Partial\Schema(nullable: true) ]; - yield 'string' => [[Type::String], PartialHelper::createSchema(type: 'string')]; - yield 'integer' => [[Type::Integer], PartialHelper::createSchema(type: 'integer')]; - yield 'number' => [[Type::Number], PartialHelper::createSchema(type: 'number')]; - yield 'boolean' => [[Type::Boolean], PartialHelper::createSchema(type: 'boolean')]; + yield 'string' => [[Type::String], new Partial\Schema(type: 'string')]; + yield 'integer' => [[Type::Integer], new Partial\Schema(type: 'integer')]; + yield 'number' => [[Type::Number], new Partial\Schema(type: 'number')]; + yield 'boolean' => [[Type::Boolean], new Partial\Schema(type: 'boolean')]; yield 'array' => [ [Type::Array], - PartialHelper::createSchema(type: 'array', items: PartialHelper::createSchema()), + new Partial\Schema(type: 'array', items: new Partial\Schema()), ]; - yield 'object' => [[Type::Object], PartialHelper::createSchema(type: 'object')]; + yield 'object' => [[Type::Object], new Partial\Schema(type: 'object')]; yield 'nullable string' => [ [Type::String, Type::Null], - PartialHelper::createSchema(type: 'string', nullable: true), + new Partial\Schema(type: 'string', nullable: true), ]; yield 'nullable integer' => [ [Type::Integer, Type::Null], - PartialHelper::createSchema(type: 'integer', nullable: true), + new Partial\Schema(type: 'integer', nullable: true), ]; yield 'nullable number' => [ [Type::Number, Type::Null], - PartialHelper::createSchema(type: 'number', nullable: true), + new Partial\Schema(type: 'number', nullable: true), ]; yield 'nullable boolean' => [ [Type::Boolean, Type::Null], - PartialHelper::createSchema(type: 'boolean', nullable: true), + new Partial\Schema(type: 'boolean', nullable: true), ]; yield 'nullable array' => [ [Type::Array, Type::Null], - PartialHelper::createSchema(type: 'array', nullable: true, items: PartialHelper::createSchema()), + new Partial\Schema(type: 'array', nullable: true, items: new Partial\Schema()), ]; yield 'nullable object' => [ [Type::Object, Type::Null], - PartialHelper::createSchema(type: 'object', nullable: true), + new Partial\Schema(type: 'object', nullable: true), ]; } } diff --git a/tests/fixtures/Helper/OpenAPIProvider.php b/tests/fixtures/Helper/OpenAPIProvider.php index b0ff2fb..8f62d10 100644 --- a/tests/fixtures/Helper/OpenAPIProvider.php +++ b/tests/fixtures/Helper/OpenAPIProvider.php @@ -211,7 +211,7 @@ enum: ['2.0', '2.1', '2.2'], name: 'limit', in: 'query', required: false, - schema: PartialHelper::createSchema( + schema: new Partial\Schema( type: 'integer' ) ), @@ -227,12 +227,12 @@ enum: ['2.0', '2.1', '2.2'], content: [ PartialHelper::createMediaType( mediaType: 'application/json', - schema: PartialHelper::createSchema( + schema: new Partial\Schema( allOf: [ - PartialHelper::createSchema( + new Partial\Schema( type: 'integer' ), - PartialHelper::createSchema( + new Partial\Schema( type: 'number' ) ] @@ -258,7 +258,7 @@ enum: ['2.0', '2.1', '2.2'], name: 'limit', in: 'query', required: false, - schema: PartialHelper::createSchema( + schema: new Partial\Schema( type: 'integer' ) ), @@ -274,12 +274,12 @@ enum: ['2.0', '2.1', '2.2'], content: [ PartialHelper::createMediaType( mediaType: 'application/json', - schema: PartialHelper::createSchema( + schema: new Partial\Schema( allOf: [ - PartialHelper::createSchema( + new Partial\Schema( type: 'integer' ), - PartialHelper::createSchema( + new Partial\Schema( type: 'number' ) ] @@ -302,7 +302,7 @@ enum: ['2.0', '2.1', '2.2'], name: 'user', in: 'cookie', required: false, - schema: PartialHelper::createSchema( + schema: new Partial\Schema( type: 'object' ) ) diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php index 831cd0e..38b925c 100644 --- a/tests/fixtures/Helper/PartialHelper.php +++ b/tests/fixtures/Helper/PartialHelper.php @@ -169,7 +169,7 @@ public static function createSchema( array|null $allOf = null, array|null $anyOf = null, array|null $oneOf = null, - bool|Schema $not = false, + bool|Schema|null $not = null, string $format = '', string $title = '', string $description = '', diff --git a/tests/fixtures/ProvidesReviewedSchemas.php b/tests/fixtures/ProvidesReviewedSchemas.php index 85d0352..02a9edd 100644 --- a/tests/fixtures/ProvidesReviewedSchemas.php +++ b/tests/fixtures/ProvidesReviewedSchemas.php @@ -15,48 +15,74 @@ final class ProvidesReviewedSchemas /** * @return Generator */ public static function provideV3xReviews(): Generator { + yield 'null allOf' => [ + new Partial\Schema(allOf: null), + [], + 'allOf', + [], + ]; + + yield 'null anyOf' => [ + new Partial\Schema(anyOf: null), + [], + 'anyOf', + [], + ]; + + yield 'null oneOf' => [ + new Partial\Schema(oneOf: null), + [], + 'oneOf', + [], + ]; + yield 'empty allOf' => [ new Partial\Schema(allOf: []), - new Warning('allOf must not be empty', Warning::INVALID), + [new Warning('allOf must not be empty', Warning::INVALID)], 'allOf', [], ]; yield 'empty anyOf' => [ new Partial\Schema(anyOf: []), - new Warning('anyOf must not be empty', Warning::INVALID), - new Partial\Schema(anyOf: null), + [new Warning('anyOf must not be empty', Warning::INVALID)], + 'anyOf', + [], ]; yield 'empty oneOf' => [ new Partial\Schema(oneOf: []), - new Warning('oneOf must not be empty', Warning::INVALID), - new Partial\Schema(oneOf: null), + [new Warning('oneOf must not be empty', Warning::INVALID)], + 'oneOf', + [], ]; yield 'negative minLength' => [ new Partial\Schema(minLength: -1), - new Warning('minLength must not be negative', Warning::INVALID), - new Partial\Schema(minLength: 0), + [new Warning('minLength must not be negative', Warning::INVALID)], + 'minLength', + 0, ]; yield 'negative minItems' => [ new Partial\Schema(minItems: -1), - new Warning('minItems must not be negative', Warning::INVALID), - new Partial\Schema(minItems: 0), + [new Warning('minItems must not be negative', Warning::INVALID)], + 'minItems', + 0, ]; yield 'negative minProperties' => [ new Partial\Schema(minProperties: -1), - new Warning('minProperties must not be negative', Warning::INVALID), - new Partial\Schema(minProperties: 0), + [new Warning('minProperties must not be negative', Warning::INVALID)], + 'minProperties', + 0, ]; } @@ -64,21 +90,24 @@ public static function provideV3xReviews(): Generator * @return Generator */ public static function provideV30Reviews(): Generator { yield 'empty required' => [ new Partial\Schema(required: []), - new Warning('required must not be empty', Warning::INVALID), - new Partial\Schema(required: null), + [new Warning('required must not be empty', Warning::INVALID)], + 'required', + [], ]; yield 'required contains duplicates' => [ new Partial\Schema(required: ['id', 'id']), - new Warning('required must not contain duplicates', Warning::INVALID), - new Partial\Schema(required: ['id']), + [new Warning('required must not contain duplicates', Warning::INVALID)], + 'required', + ['id'], ]; } } From ae01426d624c1383af06eacb5b1bf8d1cfd21be1 Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 6 Dec 2024 11:51:32 +0000 Subject: [PATCH 08/21] Ensure enum is false --- src/ValueObject/Valid/V30/Keywords.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/ValueObject/Valid/V30/Keywords.php b/src/ValueObject/Valid/V30/Keywords.php index 883017a..ee56641 100644 --- a/src/ValueObject/Valid/V30/Keywords.php +++ b/src/ValueObject/Valid/V30/Keywords.php @@ -10,6 +10,7 @@ use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; +use Membrane\OpenAPIReader\ValueObject\Valid\Exception\SchemaShouldBeBoolean; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; @@ -20,7 +21,7 @@ final class Keywords extends Validated implements Valid\Schema /** @var list */ public readonly array $types; - /** @var list|null */ + /** @var non-empty-list|null */ public readonly array|null $enum; public readonly Value|null $default; @@ -212,23 +213,26 @@ private function validateTypes( /** * @param list $types * @param list|null $enum - * @return list + * @return non-empty-list */ private function reviewEnum( array $types, array|null $enum, - ): array { + ): array|null { if ($enum === null) { - return []; + return null; } if ($enum === []) { - $this->addWarning('enum should not be empty', Warning::EMPTY_ENUM); - return $enum; + throw SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any values', + ); } - if ($types === []) { - return $enum; + // TODO validate enum against more than just type. + if ($types === []) { // currently only type is checked against enum values + return array_values($enum); // so if it is empty, return } $enumContainsValidValue = false; @@ -244,9 +248,9 @@ private function reviewEnum( } if (! $enumContainsValidValue) { - throw Valid\Exception\SchemaShouldBeBoolean::alwaysFalse( + throw SchemaShouldBeBoolean::alwaysFalse( $this->getIdentifier(), - 'enum does not contain any valid values', + 'enum does not contain any values that pass the schema', ); } From 7a8cad279a72878ec07cdafdd24980f6621214ca Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 6 Dec 2024 12:05:06 +0000 Subject: [PATCH 09/21] Update docs --- docs/object-simplifications.md | 69 +++++++++++++++++++++++++++------- docs/validation-deviations.md | 13 +++++-- 2 files changed, 65 insertions(+), 17 deletions(-) diff --git a/docs/object-simplifications.md b/docs/object-simplifications.md index 6d0ec04..c762ee6 100644 --- a/docs/object-simplifications.md +++ b/docs/object-simplifications.md @@ -16,12 +16,45 @@ All properties should have strong typehints. ## Opinionated Simplifications -## Narrow Typehints -Typehints are narrowed, if and only if, it has no impact on expressiveness. +### Narrow Schemas To False If Impossible To Pass +The `false` boolean schema explicitly states any input will fail. +The Reader will narrow schemas to `false` if it is proven impossible to pass. +This optimizes code that may otherwise validate input that will always fail. -### String Metadata Is Always String +#### Enum Specified Empty +Any schema specifying an empty `enum`, is narrowed to the `false` boolean schema. -Optional metadata is expressed in two ways; +If `enum` is specified, it contains the exhaustive list of valid values. +If `enum` is specified as an empty array, there are no valid values. + +#### Enum Without A Valid Value +If a schema specifies `enum` without a value that passes the rest of the schema; +it is impossible to pass, it will be narrowed to the `false` boolean schema. + +### Narrow Typehints +Typehints are narrowed if it has no impact on expressiveness. + +#### AllOf, AnyOf and OneOf Are Always Arrays + +`allOf`, `anyOf` and `oneOf` can express two things: +1. There are subschemas +2. There are not + +To express there are no subschemas, the value is omitted. + +As such, the Reader structures `allOf`, `anyOf` and `oneOf` in two ways: +1. A non-empty array +2. An empty array + +Though these keywords are not allowed to be empty, +[The Reader allows it](validation-deviations.md#allof-anyof-and-oneof-can-be-empty) +for the sake of simplicity. + +This simplifies code that loops through subschemas. + +#### String Metadata Is Always String + +Optional metadata is expressed in two ways: 1. There is data 2. There is not @@ -37,20 +70,28 @@ if ($metadata !== '') { } ``` -## Combined Fields +### Combined Fields Data is combined, if and only if, it has no impact on expressiveness. -### Maximum|Minimum are combined with ExclusiveMaximum|ExclusiveMinimum +#### Maximum|Minimum are combined with ExclusiveMaximum|ExclusiveMinimum + +[//]: # (TODO explain how they are combined for 3.0 and in 3.1 we take the more restrictive keyword) -The structure of numeric maximums and minimums vary between versions. -However, both versions only express two things: -- Is there a limit -- Is it exclusive +A numerical limit can only be expressed in three ways: +- There is no limit +- There is an inclusive limit +- There is an exclusive limit -As such the Reader combines the fields into: -- A `Limit|null $maximum`. -- A `Limit|null $minimum`. +As such the Reader combines the relevant keywords into: +- `Limit|null $maximum`. +- `Limit|null $minimum`. -Where A `Limit` has two properties: +Where `Limit` has two properties: - `float|int $limit` - `bool $exclusive` + +#### Const Overrides Enum in 3.1 + +[//]: # (TODO Flesh out a bit) + +The more restrictive keyword takes precedence. diff --git a/docs/validation-deviations.md b/docs/validation-deviations.md index e324574..bc3dbb8 100644 --- a/docs/validation-deviations.md +++ b/docs/validation-deviations.md @@ -49,10 +49,17 @@ Therefore, if a negative value is given: Normally, `allOf`, `anyOf` and `oneOf` MUST be non-empty. The Reader allows them to be empty. +If any of these keywords are omitted, they are treated as empty arrays. -[] and null express basically the same thing, no value. -By replacing null with [], we can narrow the typehint from `array|null` to `array`. -Simplifies code using it. + +Therefore, if an empty array is given: +- You will receive a Warning +- An empty array will be used. + +If it is omitted: +- An empty array will be used. + +[This is done for simplicity](object-simplifications.md#allof-anyof-and-oneof-are-always-arrays). ### Required Can Be Empty From 4aebc6a72bc30416b9eacc37245ec49708b4ece8 Mon Sep 17 00:00:00 2001 From: John Charman Date: Fri, 6 Dec 2024 16:08:48 +0000 Subject: [PATCH 10/21] Add V3.1 Schema --- src/ValueObject/Valid/V31/Keywords.php | 466 +++++++++++++++++ src/ValueObject/Valid/V31/Schema.php | 78 +++ tests/ValueObject/Valid/V31/SchemaTest.php | 554 +++++++++++++++++++++ 3 files changed, 1098 insertions(+) create mode 100644 src/ValueObject/Valid/V31/Keywords.php create mode 100644 src/ValueObject/Valid/V31/Schema.php create mode 100644 tests/ValueObject/Valid/V31/SchemaTest.php diff --git a/src/ValueObject/Valid/V31/Keywords.php b/src/ValueObject/Valid/V31/Keywords.php new file mode 100644 index 0000000..d00be9c --- /dev/null +++ b/src/ValueObject/Valid/V31/Keywords.php @@ -0,0 +1,466 @@ + */ + public readonly array $types; + + /** @var non-empty-list|null */ + public readonly array|null $enum; + public readonly Value|null $default; + + public readonly float|int|null $multipleOf; + public readonly Limit|null $maximum; + public readonly Limit|null $minimum; + + public readonly int|null $maxLength; + public readonly int $minLength; + public readonly string|null $pattern; + + public readonly Schema $items; + public readonly int|null $maxItems; + public readonly int $minItems; + public readonly bool $uniqueItems; + + public readonly int|null $maxProperties; + public readonly int $minProperties; + /** @var list */ + public readonly array $required; + /** @var array */ + public readonly array $properties; + public readonly bool|Schema $additionalProperties; + + /** @var list */ + public readonly array $allOf; + /** @var list */ + public readonly array $anyOf; + /** @var list */ + public readonly array $oneOf; + public readonly Schema $not; + + public readonly string $format; + + public readonly string $title; + public readonly string $description; + + /** @var Type[] */ + private readonly array $typesItCanBe; + + public function __construct( + Identifier $identifier, + Valid\Warnings $warnings, + Partial\Schema $schema + ) { + parent::__construct($identifier, $warnings); + + $this->types = $this->validateTypes($schema->type); + $this->enum = $this->reviewEnum($this->types, $schema->const, $schema->enum); + $this->default = $this->validateDefault($this->types, $schema->default); + + $this->multipleOf = $this->validateMultipleOf($schema->multipleOf); + $this->maximum = $this->validateMaximum($schema->maximum, $schema->exclusiveMaximum); + $this->minimum = $this->validateMinimum($schema->minimum, $schema->exclusiveMinimum); + + $this->maxLength = $this->validateNonNegativeInteger('maxLength', $schema->maxLength, false); + $this->minLength = $this->validateNonNegativeInteger('minLength', $schema->minLength, true); + $this->pattern = $schema->pattern; //TODO validatePattern is valid regex + + $this->items = $this->reviewItems($this->types, $schema->items); + $this->maxItems = $this->validateNonNegativeInteger('maxItems', $schema->maxItems, false); + $this->minItems = $this->validateNonNegativeInteger('minItems', $schema->minItems, true); + $this->uniqueItems = $schema->uniqueItems; + + $this->maxProperties = $this->validateNonNegativeInteger('maxProperties', $schema->maxProperties, false); + $this->minProperties = $this->validateNonNegativeInteger('minProperties', $schema->minProperties, true); + //TODO if a property is defined as a false schema AND required, that should be a warning + //TODO if property is false schema and required, and type must be object, the whole schema is the false schema + $this->required = $this->validateRequired($schema->required); + $this->properties = $this->validateProperties($schema->properties); + $this->additionalProperties = new Schema( + $this->appendedIdentifier('additionalProperties'), + $schema->additionalProperties, + ); + + + //TODO throw ShouldBeBooleanSchema::false if allOf contains false schema + $this->allOf = $this->validateSubSchemas('allOf', $schema->allOf); + $this->anyOf = $this->validateSubSchemas('anyOf', $schema->anyOf); + $this->oneOf = $this->validateSubSchemas('oneOf', $schema->oneOf); + $this->not = new Schema( + $this->appendedIdentifier('not'), + $schema->not ?? false, + ); + + $this->format = $this->formatMetadataString($schema->format); + + $this->title = $this->formatMetadataString($schema->title); + $this->description = $this->formatMetadataString($schema->description); + + $this->typesItCanBe = array_map( + fn($t) => Type::from($t), + $this->typesItCanBe() + ); + + if (empty($this->typesItCanBe)) { + $this->addWarning( + 'no data type can satisfy this schema', + Warning::IMPOSSIBLE_SCHEMA + ); + } + } + + /** @return string[] */ + public function typesItCanBe(): array + { + $possibilities = [array_merge( + Type::valuesForVersion(OpenAPIVersion::Version_3_0), + [Type::Null->value], + )]; + + if ($this->types !== []) { + $possibilities[] = array_map(fn($t) => $t->value, $this->types); + } + + if (!empty($this->allOf)) { + $possibilities[] = array_intersect(...array_map( + fn($s) => $s->typesItCanBe(), + $this->allOf, + )); + } + + if (!empty($this->anyOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->anyOf + ))); + } + + if (!empty($this->oneOf)) { + $possibilities[] = array_unique(array_merge(...array_map( + fn($s) => $s->typesItCanBe(), + $this->oneOf + ))); + } + + return array_values(array_intersect(...$possibilities)); + } + + /** + * @param null|string|array $type + * @return list + */ + private function validateTypes(null|string|array $type): array + { + if (empty($type)) { + return []; + } + + if (is_string($type)) { + $type = [$type]; + } + + return array_map( + fn($t) => Type::tryFromVersion(OpenAPIVersion::Version_3_1, $t) + ?? throw InvalidOpenAPI::invalidType($this->getIdentifier(), $t), + $type, + ); + } + + /** + * @param list $types + * @param list|null $enum + * @return non-empty-list|null + */ + private function reviewEnum( + array $types, + Value|null $const, + array|null $enum, + ): array|null { + if ($const !== null) { + if (empty($enum)) { + return [$const]; + } elseif (in_array($const, $enum)) { + $this->addWarning( + 'enum is redundant when const is specified', + Warning::REDUNDANT, + ); + return [$const]; + } else { + throw SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'const is not contained within enum, ' + . 'one or the other will always fail', + ); + } + } + + if ($enum === null) { + return null; + } + + if ($enum === []) { + throw SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any values', + ); + } + + if ($types === []) { + return array_values($enum); + } + + $enumContainsValidValue = false; + foreach ($enum as $value) { + if (in_array($value->getType(), $types)) { + $enumContainsValidValue = true; + } else { + $this->addWarning( + "$value does not match allowed types", + Warning::MISLEADING, + ); + } + } + + if (! $enumContainsValidValue) { + throw Valid\Exception\SchemaShouldBeBoolean::alwaysFalse( + $this->getIdentifier(), + 'enum does not contain any valid values', + ); + } + + return array_values($enum); + } + + /** + * @param list $types + */ + private function validateDefault(array $types, Value|null $default): Value|null + { + if ($default === null) { + return null; + } + + if (! in_array($default->getType(), $types)) { + throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); + } + + return $default; + } + + private function validateMultipleOf(float|int|null $value): float|int|null + { + if ($value === null || $value > 0) { + return $value; + } + + if ($value < 0) { + $this->addWarning( + 'multipleOf must be greater than zero', + Warning::INVALID, + ); + return abs($value); + } + + throw InvalidOpenAPI::keywordCannotBeZero( + $this->getIdentifier(), + 'multipleOf' + ); + } + + private function validateMinimum( + float|int|null $minimum, + bool|float|int|null $exclusiveMinimum, + ): Limit|null { + if (is_bool($exclusiveMinimum)) { + throw InvalidOpenAPI::boolExclusiveMinMaxIn31( + $this->getIdentifier(), + 'exclusiveMinimum', + ); + } + + if (isset($exclusiveMinimum) && isset($minimum)) { + $this->addWarning( + 'Having both minimum and exclusiveMinimum is redundant, ' + . 'only the stricter one will ever apply', + Warning::REDUNDANT, + ); + return $exclusiveMinimum >= $minimum ? + new Limit($exclusiveMinimum, true) : + new Limit($minimum, false); + } elseif (isset($exclusiveMinimum)) { + return new Limit($exclusiveMinimum, true); + } elseif (isset($minimum)) { + return new Limit($minimum, false); + } else { + return null; + } + } + + private function validateMaximum( + float|int|null $maximum, + bool|float|int|null $exclusiveMaximum, + ): Limit|null { + if (is_bool($exclusiveMaximum)) { + throw InvalidOpenAPI::boolExclusiveMinMaxIn31( + $this->getIdentifier(), + 'exclusiveMaximum', + ); + } + + if (isset($exclusiveMaximum) && isset($maximum)) { + $this->addWarning( + 'Having both maximum and exclusiveMaximum is redundant, ' + . 'only the stricter one will ever apply', + Warning::REDUNDANT, + ); + return $exclusiveMaximum <= $maximum ? + new Limit($exclusiveMaximum, true) : + new Limit($maximum, false); + } elseif (isset($exclusiveMaximum)) { + return new Limit($exclusiveMaximum, true); + } elseif (isset($maximum)) { + return new Limit($maximum, false); + } else { + return null; + } + } + + /** @return ($defaultsToZero is true ? int : int|null) */ + private function validateNonNegativeInteger( + string $keyword, + int|null $value, + bool $defaultsToZero, + ): int|null { + if ($value !== null && $value < 0) { + if (! $defaultsToZero) { + throw InvalidOpenAPI::keywordMustBeNonNegativeInteger( + $this->getIdentifier(), + $keyword + ); + } else { + $this->addWarning("$keyword must not be negative", Warning::INVALID); + return 0; + } + } + + return $value; + } + + /** @param list $types */ + private function reviewItems( + array $types, + Partial\Schema|null $items + ): Schema { + if (in_array(Type::Array, $types) && ! isset($items)) { + $this->addWarning( + 'items must be specified, if type is array', + Warning::INVALID, + ); + } + + return new Schema( + $this->getIdentifier()->append('items'), + $items ?? true, + ); + } + + /** + * @param array|null $required + * @return list + */ + private function validateRequired(array | null $required): array + { + if ($required === null) { + return []; + } + + if ($required === []) { + $this->addWarning('required must not be empty', Warning::INVALID); + return []; + } + + $uniqueRequired = array_unique($required); + + if (count($required) !== count($uniqueRequired)) { + $this->addWarning('required must not contain duplicates', Warning::INVALID); + } + + return $uniqueRequired; + } + + /** + * @param null|array $subSchemas + * @return list + */ + private function validateSubSchemas(string $keyword, ?array $subSchemas): array + { + if ($subSchemas === null) { + return []; + } + + if ($subSchemas === []) { + $this->addWarning("$keyword must not be empty", Warning::INVALID); + return []; + } + + $result = []; + foreach ($subSchemas as $index => $subSchema) { + $identifier = $this->appendedIdentifier($keyword, sprintf( + empty(trim($subSchema->title ?? '')) ? '%s' : '%2$s[%1$s]', + $index, + trim($subSchema->title ?? ''), + )); + $result[] = new Schema($identifier, $subSchema); + } + + return $result; + } + + /** + * @param array $properties + * @return array + */ + private function validateProperties(?array $properties): array + { + $properties ??= []; + + $result = []; + foreach ($properties as $key => $subSchema) { + if (!is_string($key)) { + throw InvalidOpenAPI::mustHaveStringKeys( + $this->getIdentifier(), + 'properties', + ); + } + + $result[$key] = new Schema( + $this->getIdentifier()->append("properties($key)"), + $subSchema + ); + } + + return $result; + } + + public function formatMetadataString(string|null $metadata): string + { + return trim($metadata ?? ''); + } +} diff --git a/src/ValueObject/Valid/V31/Schema.php b/src/ValueObject/Valid/V31/Schema.php new file mode 100644 index 0000000..ccc41ab --- /dev/null +++ b/src/ValueObject/Valid/V31/Schema.php @@ -0,0 +1,78 @@ +value = $schema; + } else { + try { + $this->value = new Keywords( + $this->getIdentifier(), + $this->getWarnings(), + $schema + ); + } catch (SchemaShouldBeBoolean $e) { + $this->addWarning($e->getMessage(), Valid\Warning::IMPOSSIBLE_SCHEMA); + $this->value = $e->getCode() === SchemaShouldBeBoolean::ALWAYS_TRUE; + } + } + } + + public function canBe(Type $type): bool + { + return in_array($type->value, $this->typesItCanBe()); + } + + public function canOnlyBe(Type $type): bool + { + return [$type->value] === $this->typesItCanBe(); + } + + public function canBePrimitive(): bool + { + $types = array_map(fn($t) => Type::from($t), $this->typesItCanBe()); + + foreach ($types as $typeItCanBe) { + if ($typeItCanBe->isPrimitive()) { + return true; + } + } + + return false; + } + + /** @return list */ + public function typesItCanBe(): array + { + if ($this->value === true) { + return [ + Type::Null->value, + ...Type::valuesForVersion(OpenAPIVersion::Version_3_0), + ]; + } elseif ($this->value === false) { + return []; + } else { + return $this->value->typesItCanBe(); + } + } +} diff --git a/tests/ValueObject/Valid/V31/SchemaTest.php b/tests/ValueObject/Valid/V31/SchemaTest.php new file mode 100644 index 0000000..d302586 --- /dev/null +++ b/tests/ValueObject/Valid/V31/SchemaTest.php @@ -0,0 +1,554 @@ +getWarnings()->all()); + } + + #[Test] + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] + //#[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV31Reviews')] + public function itSimplifiesSchema( + Partial\Schema $schema, + $_, + string $propertyName, + mixed $expected, + ): void { + $sut = new Schema(new Identifier('test'), $schema); + + self::assertEquals($expected, $sut->value->{$propertyName}); + } + + #[Test] + #[DataProvider('provideInvalidSchemas')] + public function itValidatesSchema( + InvalidOpenAPI $expected, + Identifier $identifier, + Partial\Schema $partialSchema, + ): void { + self::expectExceptionObject($expected); + + new Schema($identifier, $partialSchema); + } + + /** @param Type[] $typesItCanBe */ + #[Test, DataProvider('provideSchemasToCheckTypes')] + public function itKnowsIfItCanBeACertainType( + array $typesItCanBe, + Type $typeToCheck, + Partial\Schema $partialSchema, + ): void { + $sut = new Schema(new Identifier(''), $partialSchema); + + self::assertSame( + in_array($typeToCheck, $typesItCanBe, true), + $sut->canBe($typeToCheck) + ); + } + + /** @param Type[] $typesItCanBe */ + #[Test, DataProvider('provideSchemasToCheckTypes')] + public function itKnowsIfItCanOnlyBeACertainType( + array $typesItCanBe, + Type $typeToCheck, + Partial\Schema $partialSchema, + ): void { + $sut = new Schema(new Identifier(''), $partialSchema); + + self::assertSame( + [$typeToCheck] === $typesItCanBe, + $sut->canOnlyBe($typeToCheck) + ); + } + + /** @param Type[] $typesItCanBe */ + #[Test, DataProvider('provideSchemasToCheckTypes')] + public function itKnowsIfItCanOnlyBePrimitive( + array $typesItCanBe, + Type $typeToCheck, + Partial\Schema $partialSchema, + ): void { + $sut = new Schema(new Identifier(''), $partialSchema); + + self::assertSame( + !empty(array_filter($typesItCanBe, fn($t) => !in_array( + $t, + [Type::Array, Type::Object] + ))), + $sut->canBePrimitive() + ); + } + + #[Test] + #[DataProvider('provideSchemasAcceptNoTypes')] + public function itWarnsAgainstImpossibleSchemas( + bool $expected, + Partial\Schema $schema, + ): void { + $sut = new Schema(new Identifier(''), $schema); + + self::assertSame( + $expected, + $sut->getWarnings()->hasWarningCode(Warning::IMPOSSIBLE_SCHEMA) + ); + } + + #[Test] + #[TestDox('It determines the relevant numeric inclusive|exclusive maximum, if there is one')] + #[DataProvider('provideSchemasWithMax')] + public function itGetsRelevantMaximum(?Limit $expected, Partial\Schema $schema): void + { + $sut = new Schema(new Identifier(''), $schema); + + self::assertEquals($expected, $sut->value->maximum); + } + + #[Test] + #[TestDox('It determines the relevant numeric inclusive|exclusive minimum, if there is one')] + #[DataProvider('provideSchemasWithMin')] + public function itGetsRelevantMinimum(?Limit $expected, Partial\Schema $schema): void + { + $sut = new Schema(new Identifier(''), $schema); + + self::assertEquals($expected, $sut->value->minimum); + } + + /** + * @param Type[] $expected + */ + #[Test] + #[TestDox('It gets types specified, in a version agnostic format')] + #[DataProvider('provideSchemasToGetTypes')] + public function itGetsTypes(array $expected, Partial\Schema $schema): void + { + $sut = new Schema(new Identifier(''), $schema); + + self::assertEqualsCanonicalizing($expected, $sut->value->types); + } + + public static function provideInvalidSchemas(): Generator + { + yield 'invalid type' => [ + InvalidOpenAPI::invalidType(new Identifier('invalid type'), 'invalid'), + new Identifier('invalid type'), + new Partial\Schema(type: 'invalid'), + ]; + + yield 'properties list' => [ + InvalidOpenAPI::mustHaveStringKeys( + new Identifier('properties list'), + 'properties', + ), + new Identifier('properties list'), + new Partial\Schema(properties: [new Partial\Schema()]), + ]; + + yield 'negative maxLength' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxLength'), + 'maxLength', + ), + new Identifier('negative maxLength'), + new Partial\Schema(maxLength: -1), + ]; + + yield 'negative maxItems' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxItems'), + 'maxItems', + ), + new Identifier('negative maxItems'), + new Partial\Schema(maxItems: -1), + ]; + + yield 'negative maxProperties' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger( + new Identifier('negative maxProperties'), + 'maxProperties', + ), + new Identifier('negative maxProperties'), + new Partial\Schema(maxProperties: -1), + ]; + + yield 'zero multipleOf' => [ + InvalidOpenAPI::keywordCannotBeZero( + new Identifier('zero multipleOf'), + 'multipleOf', + ), + new Identifier('zero multipleOf'), + new Partial\Schema(multipleOf: 0), + ]; + + yield 'default does not conform to type' => [ + InvalidOpenAPI::defaultMustConformToType( + new Identifier('non-conforming default'), + ), + new Identifier('non-conforming default'), + new Partial\Schema(type: 'string', default: new Value(1)), + ]; + + yield 'bool exclusiveMaximum in 3.0' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31( + new Identifier('bool exclusiveMaximum'), + 'exclusiveMaximum' + ), + new Identifier('bool exclusiveMaximum'), + new Partial\Schema(exclusiveMaximum: true), + ]; + + yield 'bool exclusiveMinimum in 3.0' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31( + new Identifier('bool exclusiveMinimum'), + 'exclusiveMinimum' + ), + new Identifier('bool exclusiveMinimum'), + new Partial\Schema(exclusiveMinimum: false), + ]; + } + + /** + * @return \Generator + */ + public static function provideSchemasToCheckTypes(): Generator + { + foreach (Type::cases() as $typeToCheck) { + yield "$typeToCheck->value? empty schema" => [ + Type::cases(), + $typeToCheck, + new Partial\Schema(), + ]; + + foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { + yield "$typeToCheck->value? top level type: $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(type: $type->value) + ]; + + yield "$typeToCheck->value? allOf MUST be $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(allOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ]) + ]; + + yield "$typeToCheck->value? anyOf MUST be $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(anyOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ]) + ]; + + yield "$typeToCheck->value? oneOf MUST be $type->value" => [ + [$type], + $typeToCheck, + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ]) + ]; + + yield "$typeToCheck->value? top-level type: string, allOf MUST be $type->value" => [ + $type === Type::String ? [Type::String] : [], + $typeToCheck, + new Partial\Schema( + type: Type::String->value, + allOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ] + ) + ]; + + yield "$typeToCheck->value? top-level type: number, anyOf MUST be $type->value" => [ + $type === Type::Number ? [Type::Number] : [], + $typeToCheck, + new Partial\Schema( + type: Type::Number->value, + anyOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ] + ) + ]; + + yield "$typeToCheck->value? top-level type: array, oneOf MUST be $type->value" => [ + $type === Type::Array ? [Type::Array] : [], + $typeToCheck, + new Partial\Schema( + type: Type::Array->value, + oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: $type->value), + ] + ) + ]; + + + if ($type !== Type::String) { + yield "$typeToCheck->value? anyOf MAY be $type->value|string" => [ + [$type, Type::String], + $typeToCheck, + new Partial\Schema(anyOf: [ + new Partial\Schema(type: 'string'), + new Partial\Schema(type: $type->value), + ]) + ]; + } + + if ($type !== Type::Boolean) { + yield "$typeToCheck->value? oneOf MAY be $type->value|boolean" => [ + [$type, Type::Boolean], + $typeToCheck, + new Partial\Schema(oneOf: [ + new Partial\Schema(type: 'boolean'), + new Partial\Schema(type: $type->value), + ]) + ]; + } + + if ($type !== Type::Integer) { + yield "can it be $typeToCheck->value? allOf contains oneOf that may be $type->value|integer" => [ + [$type, Type::Integer], + $typeToCheck, + new Partial\Schema(allOf: [ + new Partial\Schema(oneOf: [ + new Partial\Schema(type: $type->value), + new Partial\Schema(type: 'integer') + ]), + ]) + ]; + } + } + } + } + + /** + * @return Generator + */ + public static function provideSchemasAcceptNoTypes(): Generator + { + foreach (self::provideSchemasToCheckTypes() as $case => $dataSet) { + yield $case => [ + empty($dataSet[0]), + $dataSet[2] + ]; + } + } + + /** + * @return Generator + */ + public static function provideSchemasWithMax(): Generator + { + yield 'no max' => [ + null, + new Partial\Schema( + exclusiveMaximum: null, + maximum: null, + ), + ]; + + yield 'inclusive max' => [ + new Limit(1, false), + new Partial\Schema( + exclusiveMaximum: null, + maximum: 1, + ), + ]; + + yield 'exclusive max' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMaximum: 1, + maximum: null, + ), + ]; + + yield 'exclusive wins when equal' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMaximum: 1, + maximum: 1, + ), + ]; + + yield 'exclusive wins when lower' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMaximum: 1, + maximum: 2, + ), + ]; + + yield 'inclusive wins when lower' => [ + new Limit(1, false), + new Partial\Schema( + exclusiveMaximum: 3, + maximum: 1, + ), + ]; + } + + /** + * @return Generator + */ + public static function provideSchemasWithMin(): Generator + { + yield 'no min' => [ + null, + new Partial\Schema( + exclusiveMinimum: null, + minimum: null, + ), + ]; + + yield 'inclusive min' => [ + new Limit(1, false), + new Partial\Schema( + exclusiveMinimum: null, + minimum: 1, + ), + ]; + + yield 'exclusive min' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMinimum: 1, + minimum: null, + ), + ]; + + yield 'exclusive wins when equal' => [ + new Limit(1, true), + new Partial\Schema( + exclusiveMinimum: 1, + minimum: 1, + ), + ]; + + yield 'exclusive wins when higher' => [ + new Limit(3, true), + new Partial\Schema( + exclusiveMinimum: 3, + minimum: 1, + ), + ]; + + yield 'inclusive wins when higher' => [ + new Limit(3, false), + new Partial\Schema( + exclusiveMinimum: 1, + minimum: 3, + ), + ]; + } + + /** + * @return \Generator + */ + public static function provideSchemasToGetTypes(): Generator + { + yield 'no type' => [ + [], + new Partial\Schema(), + ]; + + yield 'nullable' => [ + [], + new Partial\Schema(nullable: true) + ]; + + yield 'string' => [[Type::String], new Partial\Schema(type: 'string')]; + yield 'integer' => [[Type::Integer], new Partial\Schema(type: 'integer')]; + yield 'number' => [[Type::Number], new Partial\Schema(type: 'number')]; + yield 'boolean' => [[Type::Boolean], new Partial\Schema(type: 'boolean')]; + yield 'array' => [ + [Type::Array], + new Partial\Schema(type: 'array', items: new Partial\Schema()), + ]; + yield 'object' => [[Type::Object], new Partial\Schema(type: 'object')]; + + yield 'nullable string' => [ + [Type::String, Type::Null], + new Partial\Schema(type: ['string', 'null']), + ]; + yield 'nullable integer' => [ + [Type::Integer, Type::Null], + new Partial\Schema(type: ['integer', 'null']), + ]; + yield 'nullable number' => [ + [Type::Number, Type::Null], + new Partial\Schema(type: ['number', 'null']), + ]; + yield 'nullable boolean' => [ + [Type::Boolean, Type::Null], + new Partial\Schema(type: ['boolean', 'null']), + ]; + yield 'nullable array' => [ + [Type::Array, Type::Null], + new Partial\Schema(type: ['array', 'null'], items: new Partial\Schema()), + ]; + yield 'nullable object' => [ + [Type::Object, Type::Null], + new Partial\Schema(type: ['object', 'null']), + ]; + } +} From b3d298b5bfddd00f426b02a55013aadba67cef57 Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 9 Dec 2024 17:39:20 +0000 Subject: [PATCH 11/21] Improve tests --- tests/ValueObject/Valid/V30/KeywordsTest.php | 77 ++++++++++++ tests/ValueObject/Valid/V30/SchemaTest.php | 16 ++- tests/ValueObject/Valid/V31/KeywordsTest.php | 77 ++++++++++++ tests/ValueObject/Valid/V31/SchemaTest.php | 12 +- tests/fixtures/ProvidesInvalidatedSchemas.php | 119 ++++++++++++++++++ tests/fixtures/ProvidesReviewedSchemas.php | 75 ++--------- tests/fixtures/ProvidesSimplifiedSchemas.php | 113 +++++++++++++++++ 7 files changed, 408 insertions(+), 81 deletions(-) create mode 100644 tests/ValueObject/Valid/V30/KeywordsTest.php create mode 100644 tests/ValueObject/Valid/V31/KeywordsTest.php create mode 100644 tests/fixtures/ProvidesInvalidatedSchemas.php create mode 100644 tests/fixtures/ProvidesSimplifiedSchemas.php diff --git a/tests/ValueObject/Valid/V30/KeywordsTest.php b/tests/ValueObject/Valid/V30/KeywordsTest.php new file mode 100644 index 0000000..479d104 --- /dev/null +++ b/tests/ValueObject/Valid/V30/KeywordsTest.php @@ -0,0 +1,77 @@ +getWarnings()->all()); + } + + #[Test] + #[TestDox('It simplifies schema keywords where possible')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV30')] + public function itSimplifiesKeywords( + Partial\Schema $schema, + string $propertyName, + mixed $expected, + ): void { + $identifier = new Identifier('sut'); + $sut = new Keywords($identifier, new Warnings($identifier), $schema); + + self::assertEquals($expected, $sut->{$propertyName}); + } + + #[Test] + #[TestDox('It invalidates schema keywords for non-recoverable issues')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV30')] + public function itInvalidatesKeywords( + InvalidOpenAPI $expected, + Identifier $identifier, + Partial\Schema $schema, + ): void { + $warnings = new Warnings($identifier); + + self::expectExceptionObject($expected); + + new Keywords($identifier, $warnings, $schema); + } +} diff --git a/tests/ValueObject/Valid/V30/SchemaTest.php b/tests/ValueObject/Valid/V30/SchemaTest.php index ac4eed4..4e25e3c 100644 --- a/tests/ValueObject/Valid/V30/SchemaTest.php +++ b/tests/ValueObject/Valid/V30/SchemaTest.php @@ -8,6 +8,7 @@ use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesReviewedSchemas; +use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesSimplifiedSchemas; use Membrane\OpenAPIReader\ValueObject\Limit; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; @@ -36,25 +37,22 @@ class SchemaTest extends TestCase { #[Test] - #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] - #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] - public function itReviewsSchema(Partial\Schema $schema, array $warnings): void + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'forV3X')] + public function itReviewsSchema(Partial\Schema $schema, Warning $warning): void { - $sut = new Schema(new Identifier('test'), $schema); + $sut = new Schema(new Identifier('sut'), $schema); - self::assertEqualsCanonicalizing($warnings, $sut->getWarnings()->all()); + self::assertContainsEquals($warning, $sut->getWarnings()->all()); } #[Test] - #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] - #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV30Reviews')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] public function itSimplifiesSchema( Partial\Schema $schema, - $_, string $propertyName, mixed $expected, ): void { - $sut = new Schema(new Identifier('test'), $schema); + $sut = new Schema(new Identifier('sut'), $schema); self::assertEquals($expected, $sut->value->{$propertyName}); } diff --git a/tests/ValueObject/Valid/V31/KeywordsTest.php b/tests/ValueObject/Valid/V31/KeywordsTest.php new file mode 100644 index 0000000..b7f353d --- /dev/null +++ b/tests/ValueObject/Valid/V31/KeywordsTest.php @@ -0,0 +1,77 @@ +getWarnings()->all()); + } + + #[Test] + #[TestDox('It simplifies schema keywords where possible')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV31')] + public function itSimplifiesKeywords( + Partial\Schema $schema, + string $propertyName, + mixed $expected, + ): void { + $identifier = new Identifier('sut'); + $sut = new Keywords($identifier, new Warnings($identifier), $schema); + + self::assertEquals($expected, $sut->{$propertyName}); + } + + #[Test] + #[TestDox('It invalidates schema keywords for non-recoverable issues')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV3X')] + #[DataProviderExternal(ProvidesInvalidatedSchemas::class, 'forV31')] + public function itInvalidatesKeywords( + InvalidOpenAPI $expected, + Identifier $identifier, + Partial\Schema $schema, + ): void { + $warnings = new Warnings($identifier); + + self::expectExceptionObject($expected); + + new Keywords($identifier, $warnings, $schema); + } +} diff --git a/tests/ValueObject/Valid/V31/SchemaTest.php b/tests/ValueObject/Valid/V31/SchemaTest.php index d302586..2e0c145 100644 --- a/tests/ValueObject/Valid/V31/SchemaTest.php +++ b/tests/ValueObject/Valid/V31/SchemaTest.php @@ -8,6 +8,7 @@ use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesReviewedSchemas; +use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesSimplifiedSchemas; use Membrane\OpenAPIReader\ValueObject\Limit; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; @@ -36,21 +37,18 @@ class SchemaTest extends TestCase { #[Test] - #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] - //#[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV31Reviews')] - public function itReviewsSchema(Partial\Schema $schema, array $warnings): void + #[DataProviderExternal(ProvidesReviewedSchemas::class, 'forV3X')] + public function itReviewsSchema(Partial\Schema $schema, Warning $warning): void { $sut = new Schema(new Identifier('test'), $schema); - self::assertEqualsCanonicalizing($warnings, $sut->getWarnings()->all()); + self::assertContainsEquals($warning, $sut->getWarnings()->all()); } #[Test] - #[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV3xReviews')] - //#[DataProviderExternal(ProvidesReviewedSchemas::class, 'provideV31Reviews')] + #[DataProviderExternal(ProvidesSimplifiedSchemas::class, 'forV3X')] public function itSimplifiesSchema( Partial\Schema $schema, - $_, string $propertyName, mixed $expected, ): void { diff --git a/tests/fixtures/ProvidesInvalidatedSchemas.php b/tests/fixtures/ProvidesInvalidatedSchemas.php new file mode 100644 index 0000000..4567cbe --- /dev/null +++ b/tests/fixtures/ProvidesInvalidatedSchemas.php @@ -0,0 +1,119 @@ + + */ + public static function forV3X(): Generator + { + $identifier = new Identifier('sut'); + + yield 'invalid type' => [ + InvalidOpenAPI::invalidType($identifier, 'invalid'), + $identifier, + new Partial\Schema(type: 'invalid'), + ]; + + yield 'properties without string keys' => [ + InvalidOpenAPI::mustHaveStringKeys($identifier, 'properties'), + $identifier, + new Partial\Schema(properties: [new Partial\Schema()]), + ]; + + yield 'negative maxLength' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger($identifier, 'maxLength'), + $identifier, + new Partial\Schema(maxLength: -1), + ]; + + yield 'negative maxItems' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger($identifier, 'maxItems'), + $identifier, + new Partial\Schema(maxItems: -1), + ]; + + yield 'negative maxProperties' => [ + InvalidOpenAPI::keywordMustBeNonNegativeInteger($identifier, 'maxProperties'), + $identifier, + new Partial\Schema(maxProperties: -1), + ]; + + yield 'zero multipleOf' => [ + InvalidOpenAPI::keywordCannotBeZero($identifier, 'multipleOf'), + $identifier, + new Partial\Schema(multipleOf: 0), + ]; + + yield 'default does not conform to type' => [ + InvalidOpenAPI::defaultMustConformToType($identifier), + $identifier, + new Partial\Schema(type: 'string', default: new Value(1)), + ]; + } + + /** + * @return Generator + */ + public static function forV30(): Generator + { + $identifier = new Identifier('sut'); + + yield 'numeric exclusiveMaximum' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30($identifier, 'exclusiveMaximum'), + $identifier, + new Partial\Schema(exclusiveMaximum: 5), + ]; + + yield 'numeric exclusiveMinimum' => [ + InvalidOpenAPI::numericExclusiveMinMaxIn30($identifier, 'exclusiveMinimum'), + $identifier, + new Partial\Schema(exclusiveMinimum: 5), + ]; + } + + /** + * @return Generator + */ + public static function forV31(): Generator + { + $identifier = new Identifier('sut'); + + yield 'numeric exclusiveMaximum' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31($identifier, 'exclusiveMaximum'), + $identifier, + new Partial\Schema(exclusiveMaximum: true), + ]; + + yield 'numeric exclusiveMinimum' => [ + InvalidOpenAPI::boolExclusiveMinMaxIn31($identifier, 'exclusiveMinimum'), + $identifier, + new Partial\Schema(exclusiveMinimum: true), + ]; + } +} diff --git a/tests/fixtures/ProvidesReviewedSchemas.php b/tests/fixtures/ProvidesReviewedSchemas.php index 02a9edd..3c23061 100644 --- a/tests/fixtures/ProvidesReviewedSchemas.php +++ b/tests/fixtures/ProvidesReviewedSchemas.php @@ -15,99 +15,44 @@ final class ProvidesReviewedSchemas /** * @return Generator */ - public static function provideV3xReviews(): Generator + public static function forV3X(): Generator { - yield 'null allOf' => [ - new Partial\Schema(allOf: null), - [], - 'allOf', - [], - ]; - - yield 'null anyOf' => [ - new Partial\Schema(anyOf: null), - [], - 'anyOf', - [], - ]; - - yield 'null oneOf' => [ - new Partial\Schema(oneOf: null), - [], - 'oneOf', - [], - ]; - yield 'empty allOf' => [ new Partial\Schema(allOf: []), - [new Warning('allOf must not be empty', Warning::INVALID)], - 'allOf', - [], + new Warning('allOf must not be empty', Warning::INVALID), ]; - yield 'empty anyOf' => [ new Partial\Schema(anyOf: []), - [new Warning('anyOf must not be empty', Warning::INVALID)], - 'anyOf', - [], + new Warning('anyOf must not be empty', Warning::INVALID), ]; - yield 'empty oneOf' => [ new Partial\Schema(oneOf: []), - [new Warning('oneOf must not be empty', Warning::INVALID)], - 'oneOf', - [], + new Warning('oneOf must not be empty', Warning::INVALID), ]; yield 'negative minLength' => [ new Partial\Schema(minLength: -1), - [new Warning('minLength must not be negative', Warning::INVALID)], - 'minLength', - 0, + new Warning('minLength must not be negative', Warning::INVALID), ]; - yield 'negative minItems' => [ new Partial\Schema(minItems: -1), - [new Warning('minItems must not be negative', Warning::INVALID)], - 'minItems', - 0, + new Warning('minItems must not be negative', Warning::INVALID), ]; - yield 'negative minProperties' => [ new Partial\Schema(minProperties: -1), - [new Warning('minProperties must not be negative', Warning::INVALID)], - 'minProperties', - 0, + new Warning('minProperties must not be negative', Warning::INVALID), ]; - } - /** - * @return Generator - */ - public static function provideV30Reviews(): Generator - { yield 'empty required' => [ new Partial\Schema(required: []), - [new Warning('required must not be empty', Warning::INVALID)], - 'required', - [], + new Warning('required must not be empty', Warning::INVALID), ]; - yield 'required contains duplicates' => [ new Partial\Schema(required: ['id', 'id']), - [new Warning('required must not contain duplicates', Warning::INVALID)], - 'required', - ['id'], + new Warning('required must not contain duplicates', Warning::INVALID), ]; } } diff --git a/tests/fixtures/ProvidesSimplifiedSchemas.php b/tests/fixtures/ProvidesSimplifiedSchemas.php new file mode 100644 index 0000000..ddb943b --- /dev/null +++ b/tests/fixtures/ProvidesSimplifiedSchemas.php @@ -0,0 +1,113 @@ + + */ + public static function forV3X(): Generator + { + yield 'allOf: null becomes array' => [new Partial\Schema(allOf: null), 'allOf', []]; + yield 'anyOf: null becomes array' => [new Partial\Schema(anyOf: null), 'anyOf', []]; + yield 'oneOf: null becomes array' => [new Partial\Schema(oneOf: null), 'oneOf', []]; + + yield 'minLength: negatives become zero' => [new Partial\Schema(minLength: -1), 'minLength', 0]; + yield 'minItems: negatives become zero' => [new Partial\Schema(minItems: -1), 'minItems', 0]; + yield 'minProperties: negatives become zero' => [new Partial\Schema(minProperties: -1), 'minProperties', 0]; + + yield 'required: null becomes array' => [new Partial\Schema(required: null), 'required', []]; + yield 'required: duplicates are removed' => [new Partial\Schema(required: ['id', 'id']), 'required', ['id']]; + } + + /** + * @return Generator + */ + public static function forV30(): Generator + { + yield 'minimum combined: inclusive 1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: false), + 'minimum', + new Limit(1, false), + ]; + yield 'minimum combined: exclusive 1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: true), + 'minimum', + new Limit(1, true), + ]; + yield 'maximum combined: inclusive 1' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: false), + 'maximum', + new Limit(1, false), + ]; + yield 'maximum combined: exclusive 1' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: true), + 'maximum', + new Limit(1, true), + ]; + } + + /** + * @return Generator + */ + public static function forV31(): Generator + { + yield 'exclusiveMinimum:5 overrides minimum:1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: 5), + 'minimum', + new Limit(5, true), + ]; + yield 'exclusiveMinimum:1 overrides minimum:1' => [ + new Partial\Schema(minimum: 1, exclusiveMinimum: 1), + 'minimum', + new Limit(1, true), + ]; + yield 'minimum:5 overrides exclusiveMinimum:1' => [ + new Partial\Schema(minimum: 5, exclusiveMinimum: 1), + 'minimum', + new Limit(5, false), + ]; + + yield 'exclusiveMaximum:1 overrides maximum:5' => [ + new Partial\Schema(maximum: 5, exclusiveMaximum: 1), + 'maximum', + new Limit(1, true), + ]; + yield 'exclusiveMaximum:1 overrides maximum:1' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: 1), + 'maximum', + new Limit(1, true), + ]; + yield 'maximum:1 overrides exclusiveMaximum:5' => [ + new Partial\Schema(maximum: 1, exclusiveMaximum: 5), + 'maximum', + new Limit(1, false), + ]; + + yield 'const overrides enum' => [ + new Partial\Schema(const: new Value(3), enum: [new Value(1), new Value(2), new Value(3)]), + 'enum', + [new Value(3)], + ]; + } +} From 792e1f9bfd64cf0ce45b59e3589f4506e36a34c6 Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 15:24:56 +0000 Subject: [PATCH 12/21] Copy-paste V30 objects to V31 This commit only changes the namespaces of each file to V31 --- src/Factory/V31/FromCebe.php | 271 ++++++++++++++++++++ src/ValueObject/Valid/V31/Header.php | 121 +++++++++ src/ValueObject/Valid/V31/MediaType.php | 25 ++ src/ValueObject/Valid/V31/OpenAPI.php | 206 +++++++++++++++ src/ValueObject/Valid/V31/Operation.php | 252 +++++++++++++++++++ src/ValueObject/Valid/V31/Parameter.php | 250 ++++++++++++++++++ src/ValueObject/Valid/V31/PathItem.php | 294 ++++++++++++++++++++++ src/ValueObject/Valid/V31/RequestBody.php | 62 +++++ src/ValueObject/Valid/V31/Response.php | 86 +++++++ 9 files changed, 1567 insertions(+) create mode 100644 src/Factory/V31/FromCebe.php create mode 100644 src/ValueObject/Valid/V31/Header.php create mode 100644 src/ValueObject/Valid/V31/MediaType.php create mode 100644 src/ValueObject/Valid/V31/OpenAPI.php create mode 100644 src/ValueObject/Valid/V31/Operation.php create mode 100644 src/ValueObject/Valid/V31/Parameter.php create mode 100644 src/ValueObject/Valid/V31/PathItem.php create mode 100644 src/ValueObject/Valid/V31/RequestBody.php create mode 100644 src/ValueObject/Valid/V31/Response.php diff --git a/src/Factory/V31/FromCebe.php b/src/Factory/V31/FromCebe.php new file mode 100644 index 0000000..396dfa8 --- /dev/null +++ b/src/Factory/V31/FromCebe.php @@ -0,0 +1,271 @@ +servers) === 1 && $openApi->servers[0]->url === '/' ? + [] : + $openApi->servers; + + /** + * todo when phpstan 1.11 stable is released + * replace the below lines with phpstan-ignore nullsafe.neverNull + * The reason for this is the cebe library does not specify that info is nullable + * However it is not always set, so it can be null + */ + return V31\OpenAPI::fromPartial(new OpenAPI( + $openApi->openapi, + $openApi->info?->title, // @phpstan-ignore-line + $openApi->info?->version, // @phpstan-ignore-line + self::createServers($servers), + self::createPaths($openApi->paths) + )); + } + + /** + * @param Cebe\Server[] $servers + * @return Server[] + */ + private static function createServers(array $servers): array + { + $result = []; + + foreach ($servers as $server) { + $result[] = new Server( + $server->url, + self::createServerVariables($server->variables) + ); + } + + return $result; + } + + /** + * @param Cebe\ServerVariable[] $serverVariables + * @return ServerVariable[] + */ + private static function createServerVariables(array $serverVariables): array + { + $result = []; + + foreach ($serverVariables as $name => $serverVariable) { + $result[] = new ServerVariable( + $name, + $serverVariable->default, + $serverVariable->enum, + ); + } + + return $result; + } + + /** + * @param null|Cebe\Paths $paths + * @return PathItem[] + */ + private static function createPaths(?Cebe\Paths $paths): array + { + $result = []; + + foreach ($paths ?? [] as $path => $pathItem) { + $result[] = new PathItem( + path: $path, + servers: self::createServers($pathItem->servers), + parameters: self::createParameters($pathItem->parameters), + get: self::createOperation($pathItem->get), + put: self::createOperation($pathItem->put), + post: self::createOperation($pathItem->post), + delete: self::createOperation($pathItem->delete), + options: self::createOperation($pathItem->options), + head: self::createOperation($pathItem->head), + patch: self::createOperation($pathItem->patch), + trace: self::createOperation($pathItem->trace), + ); + } + + return $result; + } + + /** + * @param Cebe\Parameter[]|Cebe\Reference[] $parameters + * @return Parameter[] + */ + private static function createParameters(array $parameters): array + { + $result = []; + + foreach ($parameters as $parameter) { + assert(!$parameter instanceof Cebe\Reference); + + $result[] = new Parameter( + $parameter->name, + $parameter->in, + $parameter->required, + $parameter->style, + $parameter->explode, + self::createSchema($parameter->schema), + self::createContent($parameter->content), + ); + } + + return $result; + } + + private static function createSchema( + Cebe\Reference|Cebe\Schema|null $schema + ): Schema|null { + assert(!$schema instanceof Cebe\Reference); + + if ($schema === null) { + return null; + } + + $createSchemas = fn($schemas) => array_filter( + array_map(fn($s) => self::createSchema($s), $schemas), + fn($s) => $s !== null, + ); + + return new Schema( + type: $schema->type, + enum: isset($schema->enum) ? + array_map(fn($e) => new Value($e), $schema->enum) : + null, + default: isset($schema->default) ? new Value($schema->default) : null, + nullable: $schema->nullable ?? false, + multipleOf: $schema->multipleOf ?? null, + exclusiveMaximum: $schema->exclusiveMaximum ?? false, + exclusiveMinimum: $schema->exclusiveMinimum ?? false, + maximum: $schema->maximum ?? null, + minimum: $schema->minimum ?? null, + maxLength: $schema->maxLength ?? null, + minLength: $schema->minLength ?? 0, + pattern: $schema->pattern ?? null, + maxItems: $schema->maxItems ?? null, + minItems: $schema->minItems ?? 0, + uniqueItems: $schema->uniqueItems ?? false, + maxProperties: $schema->maxProperties ?? null, + minProperties: $schema->minProperties ?? 0, + required: $schema->required ?? null, + allOf: isset($schema->allOf) ? $createSchemas($schema->allOf) : null, + anyOf: isset($schema->anyOf) ? $createSchemas($schema->anyOf) : null, + oneOf: isset($schema->oneOf) ? $createSchemas($schema->oneOf) : null, + not: isset($schema->not) ? self::createSchema($schema->not) : null, + items: isset($schema->items) ? self::createSchema($schema->items) : null, + properties: isset($schema->properties) ? $createSchemas($schema->properties) : [], + additionalProperties: isset($schema->additionalProperties) ? (is_bool($schema->additionalProperties) ? + $schema->additionalProperties : + self::createSchema($schema->additionalProperties) ?? true) : + true, + format: $schema->format ?? null, + title: $schema->title ?? null, + description: $schema->description ?? null, + ); + } + + /** + * @param Cebe\MediaType[] $mediaTypes + * @return MediaType[] + */ + private static function createContent(array $mediaTypes): array + { + $result = []; + + foreach ($mediaTypes as $mediaType => $mediaTypeObject) { + assert(!$mediaTypeObject->schema instanceof Cebe\Reference); + + $result[] = new MediaType( + is_string($mediaType) ? $mediaType : null, + !is_null($mediaTypeObject->schema) ? + self::createSchema($mediaTypeObject->schema) : + null + ); + } + + return $result; + } + + private static function createOperation( + ?Cebe\Operation $operation + ): ?Operation { + if (is_null($operation)) { + return null; + } + + + $responses = []; + foreach ($operation->responses ?? [] as $code => $response) { + $responses[$code] = self::createResponse($response); + } + + + return new Operation( + operationId: $operation->operationId, + servers: self::createServers($operation->servers), + parameters: self::createParameters($operation->parameters), + requestBody: self::createRequestBody($operation->requestBody), + responses: $responses, + ); + } + + private static function createRequestBody( + Cebe\Reference|Cebe\RequestBody|null $requestBody + ): ?RequestBody { + assert(! $requestBody instanceof Cebe\Reference); + + if (is_null($requestBody)) { + return null; + } + + return new RequestBody( + $requestBody->description ?? null, + self::createContent($requestBody->content ?? []), + $requestBody->required ?? false, + ); + } + + public static function createHeader( + Cebe\Reference|Cebe\Header $header + ): Header { + return new Header( + description: $header->description ?? null, + style: $header->style ?? 'simple', + explode: $header->explode ?? false, + required: $header->required ?? false, + schema: isset($header->schema) ? self::createSchema($header->schema) : null, + content: isset($header->content) ? self::createContent($header->content) : [], + ); + } + + private static function createResponse( + Cebe\Reference|Cebe\Response $response, + ): Response { + assert(! $response instanceof Cebe\Reference); + + return new Response( + description: $response->description, + headers: array_map(fn($h) => self::createHeader($h), $response->headers), + content: self::createContent($response->content ?? []), + ); + } +} diff --git a/src/ValueObject/Valid/V31/Header.php b/src/ValueObject/Valid/V31/Header.php new file mode 100644 index 0000000..b362ec2 --- /dev/null +++ b/src/ValueObject/Valid/V31/Header.php @@ -0,0 +1,121 @@ + + */ + public readonly array $content; + + public function __construct( + Identifier $identifier, + Partial\Header $header + ) { + parent::__construct($identifier); + + $this->style = $this->validateStyle($identifier, $header->style); + $this->required = $header->required ?? false; + $this->explode = $header->explode ?? Style::Simple->defaultExplode(); + + + isset($header->schema) === empty($header->content) ?: + throw InvalidOpenAPI::mustHaveSchemaXorContent($identifier); + + if (isset($header->schema)) { + $this->content = []; + $this->schema = new Schema( + $this->appendedIdentifier('schema'), + $header->schema + ); + } else { + $this->schema = null; + + $this->content = $this->validateContent( + $this->getIdentifier(), + $header->content + ); + } + } + + public function getSchema(): Schema + { + if (isset($this->schema)) { + return $this->schema; + } else { + assert(array_values($this->content)[0]->schema !== null); + return array_values($this->content)[0]->schema; + } + } + + public function hasMediaType(): bool + { + return !empty($this->content); + } + + public function getMediaType(): ?string + { + return array_key_first($this->content); + } + + private function validateStyle(Identifier $identifier, ?string $style): Style + { + $defaultStyle = Style::default(In::Header); + + if ($style !== null && Style::from($style) !== $defaultStyle) { + throw InvalidOpenAPI::parameterIncompatibleStyle($identifier); + } + + return $defaultStyle; + } + + /** + * @param array $content + * @return array + */ + private function validateContent(Identifier $identifier, array $content): array + { + if (count($content) !== 1) { + throw InvalidOpenAPI::parameterContentCanOnlyHaveOneEntry($identifier); + } + + if (!isset($content[0]->contentType)) { + throw InvalidOpenAPI::contentMissingMediaType($identifier); + } + + if (!isset($content[0]->schema)) { + throw InvalidOpenAPI::mustHaveSchemaXorContent($identifier); + } + + return [ + $content[0]->contentType => new MediaType( + $this->appendedIdentifier($content[0]->contentType), + $content[0] + ), + ]; + } +} diff --git a/src/ValueObject/Valid/V31/MediaType.php b/src/ValueObject/Valid/V31/MediaType.php new file mode 100644 index 0000000..fcacadf --- /dev/null +++ b/src/ValueObject/Valid/V31/MediaType.php @@ -0,0 +1,25 @@ +schema = isset($mediaType->schema) ? + new Schema($this->getIdentifier()->append('schema'), $mediaType->schema) : + null; + } +} diff --git a/src/ValueObject/Valid/V31/OpenAPI.php b/src/ValueObject/Valid/V31/OpenAPI.php new file mode 100644 index 0000000..30ba580 --- /dev/null +++ b/src/ValueObject/Valid/V31/OpenAPI.php @@ -0,0 +1,206 @@ + $servers + * Optional, may be left empty. + * If empty or unspecified, the array will contain the default Server. + * The default Server has "url" === "/" and no "variables" + * + * @param array $paths + * REQUIRED: + * It may be empty due to ACL constraints + * The PathItem's relative endpoint key mapped to the PathItem + */ + private function __construct( + Identifier $identifier, + public readonly array $servers, + public readonly array $paths + ) { + parent::__construct($identifier); + + $this->reviewServers($this->servers); + $this->reviewPaths($this->paths); + } + + public function withoutServers(): OpenAPI + { + return new OpenAPI( + $this->getIdentifier(), + [new Server($this->getIdentifier(), new Partial\Server('/'))], + array_map(fn($p) => $p->withoutServers(), $this->paths), + ); + } + + public static function fromPartial(Partial\OpenAPI $openAPI): self + { + $identifier = new Identifier(sprintf( + '%s(%s)', + $openAPI->title ?? throw InvalidOpenAPI::missingInfo(), + $openAPI->version ?? throw InvalidOpenAPI::missingInfo(), + )); + + $openAPI->openAPI ?? + throw InvalidOpenAPI::missingOpenAPIVersion($identifier); + + $servers = self::validateServers($identifier, $openAPI->servers); + $paths = self::validatePaths($identifier, $servers, $openAPI->paths); + + return new OpenAPI($identifier, $servers, $paths); + } + + /** + * @param Partial\Server[] $servers + * @return array + */ + private static function validateServers( + Identifier $identifier, + array $servers + ): array { + if (empty($servers)) { + return [new Server($identifier, new Partial\Server('/'))]; + } + + return array_values(array_map( + fn($s) => new Server($identifier, $s), + $servers + )); + } + + /** + * @param Server[] $servers + */ + private function reviewServers(array $servers): void + { + $uniqueURLS = array_unique(array_map(fn($s) => $s->url, $servers)); + if (count($servers) !== count($uniqueURLS)) { + $this->addWarning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + } + } + + /** + * @param Server[] $servers + * @param null|Partial\PathItem[] $pathItems + * @return array + */ + private static function validatePaths( + Identifier $identifier, + array $servers, + ?array $pathItems + ): array { + if (is_null($pathItems)) { + throw InvalidOpenAPI::missingPaths($identifier); + } + + $result = []; + + foreach ($pathItems as $pathItem) { + if (!isset($pathItem->path)) { + throw InvalidOpenAPI::pathMissingEndPoint($identifier); + } + + if (!str_starts_with($pathItem->path, '/')) { + throw InvalidOpenAPI::forwardSlashMustPrecedePath( + $identifier, + $pathItem->path + ); + } + + if (isset($result[$pathItem->path])) { + throw InvalidOpenAPI::identicalEndpoints( + $result[$pathItem->path]->getIdentifier() + ); + } + + $result[$pathItem->path] = PathItem::fromPartial( + $identifier->append($pathItem->path), + $servers, + $pathItem + ); + } + + self::checkForEquivalentPathTemplates($result); + self::checkForDuplicatedOperationIds($result); + + return $result; + } + + /** + * @param PathItem[] $paths + */ + private function reviewPaths(array $paths): void + { + if (empty($paths)) { + $this->addWarning('No Paths in OpenAPI', Warning::EMPTY_PATHS); + } + } + + /** + * @param array $pathItems + */ + private static function checkForEquivalentPathTemplates(array $pathItems): void + { + $regexToIdentifier = []; + foreach ($pathItems as $path => $pathItem) { + $regex = self::getPathRegex($path); + + if (isset($regexToIdentifier[$regex])) { + throw InvalidOpenAPI::equivalentTemplates( + $regexToIdentifier[$regex], + $pathItem->getIdentifier() + ); + } + + $regexToIdentifier[$regex] = $pathItem->getIdentifier(); + } + } + + /** + * @param PathItem[] $paths + */ + private static function checkForDuplicatedOperationIds(array $paths): void + { + $checked = []; + + foreach ($paths as $path => $pathItem) { + foreach ($pathItem->getOperations() as $method => $operation) { + $id = $operation->operationId; + + if (isset($checked[$id])) { + throw InvalidOpenAPI::duplicateOperationIds( + $id, + $checked[$id][0], + $checked[$id][1], + $path, + $method, + ); + } + + $checked[$id] = [$path, $method]; + } + } + } + + private static function getPathRegex(string $path): string + { + $pattern = preg_replace('#{[^/]+}#', '{([^/]+)}', $path); + + assert(is_string($pattern)); + + return $pattern; + } +} diff --git a/src/ValueObject/Valid/V31/Operation.php b/src/ValueObject/Valid/V31/Operation.php new file mode 100644 index 0000000..a733870 --- /dev/null +++ b/src/ValueObject/Valid/V31/Operation.php @@ -0,0 +1,252 @@ + $servers + * Optional, may be left empty. + * If empty or unspecified, the array will contain the Path level servers + * + * @param Parameter[] $parameters + * The list MUST NOT include duplicated parameters. + * A unique parameter is defined by a combination of a name and location. + * + * @param string $operationId + * Required by Membrane + * MUST be unique, value is case-sensitive. + * + * @param Response[] $responses + */ + private function __construct( + Identifier $identifier, + public readonly string $operationId, + public readonly array $servers, + public readonly array $parameters, + public readonly RequestBody|null $requestBody, + public readonly array $responses, + ) { + parent::__construct($identifier); + + $this->reviewServers($this->servers); + $this->reviewParameters($this->parameters); + } + + public function withoutServers(): Operation + { + return new Operation( + $this->getIdentifier(), + $this->operationId, + [new Server($this->getIdentifier(), new Partial\Server('/'))], + $this->parameters, + $this->requestBody, + $this->responses, + ); + } + + /** + * @param Server[] $pathServers + * @param Parameter[] $pathParameters + */ + public static function fromPartial( + Identifier $parentIdentifier, + array $pathServers, + array $pathParameters, + Method $method, + Partial\Operation $operation, + ): Operation { + $operationId = $operation->operationId ?? + throw CannotSupport::missingOperationId( + $parentIdentifier->fromEnd(0) ?? '', + $method->value, + ); + + $identifier = $parentIdentifier->append("$operationId($method->value)"); + + $servers = self::validateServers( + $identifier, + $pathServers, + $operation->servers, + ); + + $parameters = self::validateParameters( + $identifier, + $pathParameters, + $operation->parameters + ); + + $requestBody = isset($operation->requestBody) ? + new RequestBody($identifier, $operation->requestBody) : + null; + + $responses = self::validateResponses($identifier, $operation->responses); + + return new Operation( + $identifier, + $operationId, + $servers, + $parameters, + $requestBody, + $responses, + ); + } + + /** + * @param array $pathServers + * @param Partial\Server[] $operationServers + * @return array> + */ + private static function validateServers( + Identifier $identifier, + array $pathServers, + array $operationServers + ): array { + if (empty($operationServers)) { + return $pathServers; + } + + $result = array_values(array_map( + fn($s) => new Server($identifier, $s), + $operationServers + )); + + return $result; + } + + /** + * @param Server[] $servers + */ + private function reviewServers(array $servers): void + { + $uniqueURLS = array_unique(array_map(fn($s) => $s->url, $servers)); + if (count($servers) !== count($uniqueURLS)) { + $this->addWarning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + } + } + + /** + * @param Parameter[] $pathParameters + * @param Partial\Parameter[] $operationParameters + * @return Parameter[] + */ + private static function validateParameters( + Identifier $identifier, + array $pathParameters, + array $operationParameters + ): array { + $result = self::mergeParameters( + $identifier, + $operationParameters, + $pathParameters + ); + + foreach ($result as $index => $parameter) { + foreach (array_slice($result, $index + 1) as $otherParameter) { + if ($parameter->isIdentical($otherParameter)) { + throw InvalidOpenAPI::duplicateParameters( + $identifier, + $parameter->getIdentifier(), + $otherParameter->getIdentifier(), + ); + } + + if ($parameter->canConflictWith($otherParameter)) { + throw CannotSupport::conflictingParameterStyles( + (string) $parameter->getIdentifier(), + (string) $otherParameter->getIdentifier(), + ); + } + } + } + + return $result; + } + + /** @param array $parameters */ + private function reviewParameters(array $parameters): void + { + foreach ($parameters as $index => $parameter) { + foreach (array_slice($parameters, $index + 1) as $otherParameter) { + if ($parameter->isSimilar($otherParameter)) { + $this->addWarning( + <<name + $otherParameter->name + TEXT, + Warning::SIMILAR_NAMES + ); + } + } + } + } + + /** + * @param Partial\Parameter[] $operationParameters + * @param Parameter[] $pathParameters + * @return array + */ + private static function mergeParameters( + Identifier $identifier, + array $operationParameters, + array $pathParameters + ): array { + $result = array_map( + fn($p) => new Parameter($identifier, $p), + $operationParameters + ); + + foreach ($pathParameters as $pathParameter) { + foreach ($result as $operationParameter) { + if ($operationParameter->isIdentical($pathParameter)) { + continue 2; + } + } + $result[] = $pathParameter; + } + + return array_values($result); + } + + /** + * @param Partial\Response[] $responses + * @return array + */ + private static function validateResponses( + Identifier $identifier, + array $responses, + ): array { + $result = []; + + foreach ($responses as $code => $response) { + if (! is_numeric($code) && $code !== 'default') { + throw InvalidOpenAPI::responseCodeMustBeNumericOrDefault( + $identifier, + (string) $code, + ); + } + + $result[(string) $code] = new Response( + $identifier->append('response', (string) $code), + $response, + ); + } + + return $result; + } +} diff --git a/src/ValueObject/Valid/V31/Parameter.php b/src/ValueObject/Valid/V31/Parameter.php new file mode 100644 index 0000000..53562b7 --- /dev/null +++ b/src/ValueObject/Valid/V31/Parameter.php @@ -0,0 +1,250 @@ + + */ + public readonly array $content; + + public function __construct( + Identifier $parentIdentifier, + Partial\Parameter $header + ) { + $this->name = $header->name ?? + throw InvalidOpenAPI::parameterMissingName($parentIdentifier); + + $this->in = $this->validateIn( + $parentIdentifier, + $header->in, + ); + + $identifier = $parentIdentifier->append($this->name, $this->in->value); + parent::__construct($identifier); + + $this->required = $this->validateRequired( + $identifier, + $this->in, + $header->required + ); + + isset($header->schema) === empty($header->content) ?: + throw InvalidOpenAPI::mustHaveSchemaXorContent($identifier); + + if (isset($header->schema)) { + $this->content = []; + $this->schema = new Schema( + $this->appendedIdentifier('schema'), + $header->schema + ); + } else { + $this->schema = null; + + $this->content = $this->validateContent( + $this->getIdentifier(), + $header->content + ); + } + + $this->style = $this->validateStyle( + $identifier, + $this->getSchema(), + $this->in, + $header->style, + ); + + $this->explode = $header->explode ?? $this->style->defaultExplode(); + } + + public function getSchema(): Schema + { + if (isset($this->schema)) { + return $this->schema; + } else { + assert(array_values($this->content)[0]->schema !== null); + return array_values($this->content)[0]->schema; + } + } + + public function hasMediaType(): bool + { + return !empty($this->content); + } + + public function getMediaType(): ?string + { + return array_key_first($this->content); + } + + public function isIdentical(Parameter $other): bool + { + return $this->name === $other->name + && $this->in === $other->in; + } + + public function isSimilar(Parameter $other): bool + { + return $this->name !== $other->name && + mb_strtolower($this->name) === mb_strtolower($other->name); + } + + public function canConflictWith(Parameter $other): bool + { + return ($this->canCauseConflict() && $other->isVulnerableToConflict()) || + ($this->isVulnerableToConflict() && $other->canCauseConflict()); + } + + private function canCauseConflict(): bool + { + return $this->in === In::Query && + $this->style === Style::Form && + $this->explode && + $this->getSchema()->canBe(Type::Object); + } + + private function isVulnerableToConflict(): bool + { + /** + * @todo once schemas account for minItems and minProperties keywords. + * pipeDelimited and spaceDelimited are also vulnerable if: + * type:array and minItems <= 1 + * this is because there would be no delimiter to distinguish it from a form parameter + * + * form would not be vulnerable if: + * explode:false + * and... + * type:object and minProperties > 1 + * or ... + * type:array and minItems > 1 + * this is because there would be a delimiter to distinguish it from an exploding parameter + */ + + return $this->in === In::Query && match ($this->style) { + Style::Form => true, + Style::PipeDelimited, + Style::SpaceDelimited => $this->getSchema()->canBePrimitive(), + default => false + }; + } + + private function validateIn(Identifier $identifier, ?string $in): In + { + if (is_null($in)) { + throw InvalidOpenAPI::parameterMissingLocation($identifier); + } + + return In::tryFrom($in) ?? + throw InvalidOpenAPI::parameterInvalidLocation($identifier); + } + + private function validateRequired( + Identifier $identifier, + In $in, + ?bool $required + ): bool { + if ($in === In::Path && $required !== true) { + throw InvalidOpenAPI::parameterMissingRequired($identifier); + } + + return $required ?? false; + } + + private function validateStyle( + Identifier $identifier, + Schema $schema, + In $in, + ?string $style + ): Style { + if (is_null($style)) { + return Style::default($in); + } + + $style = Style::tryFrom($style) ?? + throw InvalidOpenAPI::parameterInvalidStyle($identifier); + + $style->isAllowed($in) ?: + throw InvalidOpenAPI::parameterIncompatibleStyle($identifier); + + $style !== Style::DeepObject || $schema->canOnlyBe(Type::Object) ?: + throw InvalidOpenAPI::deepObjectMustBeObject($identifier); + + if ( + in_array($style, [Style::SpaceDelimited, Style::PipeDelimited]) && + $schema->canBePrimitive() + ) { + $this->addWarning( + "style:$style->value, is not allowed to be primitive." . + 'In these instances style:form is recommended.', + Warning::UNSUITABLE_STYLE + ); + } + + return $style; + } + + /** + * @param array $content + * @return array + */ + private function validateContent( + Identifier $identifier, + array $content, + ): array { + if (count($content) !== 1) { + throw InvalidOpenAPI::parameterContentCanOnlyHaveOneEntry($identifier); + } + + if (!isset($content[0]->contentType)) { + throw InvalidOpenAPI::contentMissingMediaType($identifier); + } + + if (!isset($content[0]->schema)) { + throw InvalidOpenAPI::mustHaveSchemaXorContent($identifier); + } + + return [ + $content[0]->contentType => new MediaType( + $this->appendedIdentifier($content[0]->contentType), + $content[0] + ), + ]; + } +} diff --git a/src/ValueObject/Valid/V31/PathItem.php b/src/ValueObject/Valid/V31/PathItem.php new file mode 100644 index 0000000..e39e8db --- /dev/null +++ b/src/ValueObject/Valid/V31/PathItem.php @@ -0,0 +1,294 @@ + $servers + * + * @param array $parameters + * The list MUST NOT include duplicated parameters. + * A unique parameter is defined by a combination of a name and location. + */ + private function __construct( + Identifier $identifier, + public readonly array $servers, + public readonly array $parameters, + public readonly ?Operation $get, + public readonly ?Operation $put, + public readonly ?Operation $post, + public readonly ?Operation $delete, + public readonly ?Operation $options, + public readonly ?Operation $head, + public readonly ?Operation $patch, + public readonly ?Operation $trace, + ) { + parent::__construct($identifier); + + $this->reviewServers($servers); + $this->reviewParameters($parameters); + $this->reviewOperations($this->getOperations()); + } + + public function withoutServers(): PathItem + { + return new PathItem( + $this->getIdentifier(), + [new Server( + new Identifier($this->getIdentifier()->fromStart() ?? ''), + new Partial\Server('/') + ), + ], + $this->parameters, + $this->get?->withoutServers(), + $this->put?->withoutServers(), + $this->post?->withoutServers(), + $this->delete?->withoutServers(), + $this->options?->withoutServers(), + $this->head?->withoutServers(), + $this->patch?->withoutServers(), + $this->trace?->withoutServers(), + ); + } + + /** + * @param array $openAPIServers + * If the pathItem does not contain servers, this will be used instead + */ + public static function fromPartial( + Identifier $identifier, + array $openAPIServers, + Partial\PathItem $pathItem, + ): PathItem { + $servers = self::validateServers( + $identifier, + $openAPIServers, + $pathItem->servers, + ); + + $parameters = self::validateParameters( + $identifier, + $pathItem->parameters + ); + + return new PathItem( + identifier: $identifier, + servers: $servers, + parameters: $parameters, + get: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::GET, + $pathItem->get + ), + put: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::PUT, + $pathItem->put + ), + post: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::POST, + $pathItem->post + ), + delete: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::DELETE, + $pathItem->delete + ), + options: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::OPTIONS, + $pathItem->options + ), + head: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::HEAD, + $pathItem->head + ), + patch: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::PATCH, + $pathItem->patch + ), + trace: self::validateOperation( + $identifier, + $servers, + $parameters, + Method::TRACE, + $pathItem->trace + ), + ); + } + + /** + * Operation "method" keys mapped to Operation values + * @return array + */ + public function getOperations(): array + { + return array_filter( + [ + Method::GET->value => $this->get, + Method::PUT->value => $this->put, + Method::POST->value => $this->post, + Method::DELETE->value => $this->delete, + Method::OPTIONS->value => $this->options, + Method::HEAD->value => $this->head, + Method::PATCH->value => $this->patch, + Method::TRACE->value => $this->trace, + ], + fn($o) => !is_null($o) + ); + } + + /** + * @param array $openAPIServers + * @param Partial\Server[] $pathServers + * @return array> + */ + private static function validateServers( + Identifier $identifier, + array $openAPIServers, + array $pathServers + ): array { + if (empty($pathServers)) { + return $openAPIServers; + } + + return array_values(array_map( + fn($s) => new Server($identifier, $s), + $pathServers + )); + } + + /** + * @param Server[] $servers + */ + private function reviewServers(array $servers): void + { + $uniqueURLS = array_unique(array_map(fn($s) => $s->url, $servers)); + if (count($servers) !== count($uniqueURLS)) { + $this->addWarning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + } + } + + /** + * @param Partial\Parameter[] $parameters + * @return array + */ + private static function validateParameters( + Identifier $identifier, + array $parameters + ): array { + $result = array_values(array_map( + fn($p) => new Parameter($identifier, $p), + $parameters + )); + + foreach ($result as $index => $parameter) { + foreach (array_slice($result, $index + 1) as $otherParameter) { + if ($parameter->isIdentical($otherParameter)) { + throw InvalidOpenAPI::duplicateParameters( + $identifier, + $parameter->getIdentifier(), + $otherParameter->getIdentifier(), + ); + } + } + } + + return $result; + } + + /** + * @param array $parameters + */ + private function reviewParameters(array $parameters): void + { + foreach ($parameters as $index => $parameter) { + foreach (array_slice($parameters, $index + 1) as $other) { + if ($parameter->isSimilar($other)) { + $this->addWarning( + <<name + $other->name + TEXT, + Warning::SIMILAR_NAMES + ); + } + } + } + } + + /** + * @param Server[] $servers + * @param Parameter[] $parameters + */ + private static function validateOperation( + Identifier $identifier, + array $servers, + array $parameters, + Method $method, + ?Partial\Operation $operation + ): ?Operation { + if (is_null($operation)) { + return null; + } + + return Operation::fromPartial( + $identifier, + $servers, + $parameters, + $method, + $operation, + ); + } + + /** + * Operation "method" keys mapped to Operation values + * @param array $operations + */ + private function reviewOperations(array $operations): void + { + if (empty($operations)) { + $this->addWarning('No Operations on Path', Warning::EMPTY_PATH); + } + + foreach ([Method::OPTIONS, Method::HEAD, Method::TRACE] as $method) { + if (isset($operations[$method->value])) { + $this->addWarning( + "$method->value is redundant in an OpenAPI Specification.", + Warning::REDUNDANT_METHOD, + ); + } + } + } +} diff --git a/src/ValueObject/Valid/V31/RequestBody.php b/src/ValueObject/Valid/V31/RequestBody.php new file mode 100644 index 0000000..5c29a3d --- /dev/null +++ b/src/ValueObject/Valid/V31/RequestBody.php @@ -0,0 +1,62 @@ + + */ + public readonly array $content; + + public function __construct( + Identifier $parentIdentifier, + Partial\RequestBody $requestBody, + ) { + $identifier = $parentIdentifier->append('requestBody'); + parent::__construct($identifier); + + $this->description = $requestBody->description; + $this->required = $requestBody->required; + + $this->content = $this->validateContent( + $identifier, + $requestBody->content + ); + } + + /** + * @param array $content + * @return array + */ + public function validateContent( + Identifier $identifier, + array $content + ): array { + $result = []; + foreach ($content as $mediaType) { + if (!isset($mediaType->contentType)) { + throw InvalidOpenAPI::contentMissingMediaType($identifier); + } + + $result[$mediaType->contentType] = new MediaType( + $identifier->append($mediaType->contentType), + $mediaType, + ); + } + + return $result; + } +} diff --git a/src/ValueObject/Valid/V31/Response.php b/src/ValueObject/Valid/V31/Response.php new file mode 100644 index 0000000..f26ff69 --- /dev/null +++ b/src/ValueObject/Valid/V31/Response.php @@ -0,0 +1,86 @@ + */ + public readonly array $headers; + + /** @var array */ + public readonly array $content; + + public function __construct( + Identifier $identifier, + Partial\Response $response, + ) { + parent::__construct($identifier); + + $this->description = $response->description + ?? throw InvalidOpenAPI::missingRequiredField($identifier, 'description'); + + $this->headers = $this->validateHeaders($identifier, $response->headers); + + $this->content = $this->validateContent($identifier, $response->content); + } + + /** + * @param array $headers + * @return array + */ + private function validateHeaders( + Identifier $identifier, + array $headers + ): array { + $result = []; + foreach ($headers as $headerName => $header) { + if ($this->headerShouldBeIgnored($headerName)) { + continue; + } + + $result[$headerName] = new Header( + $identifier->append('header', $headerName), + $header, + ); + } + + return $result; + } + + private function headerShouldBeIgnored(string $headerName): bool + { + return mb_strtolower($headerName) === 'content-type'; + } + + /** + * @param array $content + * @return array + */ + private function validateContent( + Identifier $identifier, + array $content + ): array { + $result = []; + foreach ($content as $mediaType) { + if (!isset($mediaType->contentType)) { + throw InvalidOpenAPI::contentMissingMediaType($identifier); + } + + $result[$mediaType->contentType] = new MediaType( + $identifier->append($mediaType->contentType), + $mediaType, + ); + } + + return $result; + } +} From 62ec3736592699846aeca5c41961096b0f836df8 Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 15:42:09 +0000 Subject: [PATCH 13/21] Copy-paste tests for V30 to V31 Only the namespace has been changed in this PR --- tests/ValueObject/Valid/V31/MediaTypeTest.php | 53 ++ tests/ValueObject/Valid/V31/OpenAPITest.php | 290 +++++++ tests/ValueObject/Valid/V31/OperationTest.php | 450 +++++++++++ tests/ValueObject/Valid/V31/ParameterTest.php | 706 ++++++++++++++++++ tests/ValueObject/Valid/V31/PathItemTest.php | 443 +++++++++++ 5 files changed, 1942 insertions(+) create mode 100644 tests/ValueObject/Valid/V31/MediaTypeTest.php create mode 100644 tests/ValueObject/Valid/V31/OpenAPITest.php create mode 100644 tests/ValueObject/Valid/V31/OperationTest.php create mode 100644 tests/ValueObject/Valid/V31/ParameterTest.php create mode 100644 tests/ValueObject/Valid/V31/PathItemTest.php diff --git a/tests/ValueObject/Valid/V31/MediaTypeTest.php b/tests/ValueObject/Valid/V31/MediaTypeTest.php new file mode 100644 index 0000000..bc58831 --- /dev/null +++ b/tests/ValueObject/Valid/V31/MediaTypeTest.php @@ -0,0 +1,53 @@ +schema); + } + + #[Test] + public function itMayHaveASchema(): void + { + $sut = new MediaType( + new Identifier('test-mediaType'), + PartialHelper::createMediaType(schema: PartialHelper::createSchema()) + ); + + self::assertInstanceOf(Schema::class, $sut->schema); + } +} diff --git a/tests/ValueObject/Valid/V31/OpenAPITest.php b/tests/ValueObject/Valid/V31/OpenAPITest.php new file mode 100644 index 0000000..7878f84 --- /dev/null +++ b/tests/ValueObject/Valid/V31/OpenAPITest.php @@ -0,0 +1,290 @@ +getWarnings()->findByWarningCode(Warning::EMPTY_PATHS); + + self::assertEquals($expected, $actual); + } + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideDuplicateServers')] + public function itWarnsAgainstDuplicateServers( + array $expected, + Partial\OpenAPI $openAPI, + ): void { + $sut = OpenAPI::fromPartial($openAPI); + + self::assertEquals( + $expected, + $sut + ->getWarnings() + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS) + ); + } + + #[Test] + public function itHasADefaultServer(): void + { + $title = 'My API'; + $version = '1.2.1'; + $sut = OpenAPI::fromPartial(PartialHelper::createOpenAPI( + title: $title, + version: $version, + servers: [], + )); + + $expected = [new Server( + $sut->getIdentifier(), + PartialHelper::createServer(url: '/') + )]; + + self::assertEquals($expected, $sut->servers); + } + + #[Test] + #[DataProvider('provideOpenAPIWithoutServers')] + public function itCanCreateANewInstanceWithoutServers( + array $servers, + array $paths, + ): void { + $apiWith = fn($s) => OpenAPI::fromPartial( + PartialHelper::createOpenAPI( + servers: $s, + paths: $paths, + ), + ); + + self::assertEquals( + $apiWith([new Partial\Server('/')]), + $apiWith($servers)->withoutServers(), + ); + } + + /** + * @return Generator + */ + public static function provideInvalidPartialObjects(): Generator + { + $title = 'Test OpenAPI'; + $version = '1.0.0'; + $identifier = new Identifier("$title($version)"); + + $case = fn($expected, $data) => [ + $expected, + PartialHelper::createOpenAPI(...[ + 'title' => $title, + 'version' => $version, + ...$data + ]) + ]; + + yield 'no "openapi" field' => $case( + InvalidOpenAPI::missingOpenAPIVersion($identifier), + ['openapi' => null] + ); + + yield 'no "title" field on Info object' => $case( + InvalidOpenAPI::missingInfo(), + ['title' => null] + ); + + yield 'no "version" field on Info object' => $case( + InvalidOpenAPI::missingInfo(), + ['version' => null] + ); + + yield 'no "paths" field' => $case( + InvalidOpenAPI::missingPaths($identifier), + ['paths' => null] + ); + + yield 'path without an endpoint' => $case( + InvalidOpenAPI::pathMissingEndPoint($identifier), + ['paths' => [PartialHelper::createPathItem(path: null)]] + ); + + yield 'path endpoint is not preceded by a forward slash ' => $case( + InvalidOpenAPI::forwardSlashMustPrecedePath($identifier, 'path'), + ['paths' => [PartialHelper::createPathItem(path: 'path')]] + ); + + yield 'two paths with identical endpoints' => $case( + InvalidOpenAPI::identicalEndpoints($identifier->append('/path')), + [ + 'paths' => [ + PartialHelper::createPathItem(path: '/path'), + PartialHelper::createPathItem(path: '/path') + ] + ] + ); + + yield 'two paths with equivalent endpoint templates' => $case( + InvalidOpenAPI::equivalentTemplates( + $identifier->append('/path/{param1}'), + $identifier->append('/path/{param2}') + ), + [ + 'paths' => [ + PartialHelper::createPathItem(path: '/path/{param1}'), + PartialHelper::createPathItem(path: '/path/{param2}'), + ] + ] + ); + + yield 'one path with two identical operationIds' => $case( + InvalidOpenAPI::duplicateOperationIds('duplicate-id', '/path', 'get', '/path', 'post'), + [ + 'paths' => [ + PartialHelper::createPathItem( + path: '/path', + get: PartialHelper::createOperation(operationId: 'duplicate-id'), + post: PartialHelper::createOperation(operationId: 'duplicate-id'), + ) + ] + ] + ); + + yield 'two path with identical operationIds' => $case( + InvalidOpenAPI::duplicateOperationIds('duplicate-id', '/first', 'get', '/second', 'get'), + [ + 'paths' => [ + PartialHelper::createPathItem( + path: '/first', + get: PartialHelper::createOperation(operationId: 'duplicate-id') + ), + PartialHelper::createPathItem( + path: '/second', + get: PartialHelper::createOperation(operationId: 'duplicate-id'), + ), + ] + ] + ); + } + + /** + * @return Generator + */ + public static function provideDuplicateServers(): Generator + { + $expectedWarning = new Warning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + + $case = fn($servers) => [ + [$expectedWarning], + PartialHelper::createOpenAPI(servers: $servers), + ]; + + yield 'Completely identical: "/"' => $case([ + PartialHelper::createServer('/'), + PartialHelper::createServer('/'), + ]); + + yield 'Completely identical: "https://www.server.net"' => $case([ + PartialHelper::createServer('https://www.server.net'), + PartialHelper::createServer('https://www.server.net'), + ]); + + yield 'Identical IF you ignore trailing forward slashes' => $case([ + PartialHelper::createServer(''), + PartialHelper::createServer('/'), + ]); + } + + public static function provideOpenAPIWithoutServers(): Generator + { + yield 'default server' => [ + [new Partial\Server('/')], + [], + ]; + + yield 'static server' => [ + [new Partial\Server('hello-world.net/')], + [], + ]; + + yield 'multiple servers' => [ + [ + new Partial\Server('hello-world.net/'), + new Partial\Server('howdy-planet.io/'), + ], + [], + ]; + + yield 'dynamic server' => [ + [new Partial\Server('hello-{world}.net/', [ + new Partial\ServerVariable('world', 'world'), + ])], + [], + ]; + + yield 'path item' => [ + [new Partial\Server('hello-parameter.io/')], + [PartialHelper::createPathItem()] + ]; + } +} diff --git a/tests/ValueObject/Valid/V31/OperationTest.php b/tests/ValueObject/Valid/V31/OperationTest.php new file mode 100644 index 0000000..2c7f051 --- /dev/null +++ b/tests/ValueObject/Valid/V31/OperationTest.php @@ -0,0 +1,450 @@ +parameters); + } + + #[Test] + public function itCannotSupportConflictingParameters(): void + { + $parentIdentifier = new Identifier('test-path'); + + $operationId = 'test-id'; + $method = Method::GET; + $identifier = $parentIdentifier->append($operationId, $method->value); + + $parameterNames = ['param1', 'param2']; + $parameterIdentifiers = array_map( + fn($n) => (string)$identifier->append($n, 'query'), + $parameterNames + ); + + $partialOperation = PartialHelper::createOperation( + operationId: $operationId, + parameters: [ + PartialHelper::createParameter( + name: $parameterNames[0], + in: 'query', + style: 'form', + explode: true, + schema: new Partial\Schema(type: 'object') + ), + PartialHelper::createParameter( + name: $parameterNames[1], + in: 'query', + style: 'form', + explode: true, + schema: new Partial\Schema(type: 'object') + ), + ] + ); + + self::expectExceptionObject(CannotSupport::conflictingParameterStyles(...$parameterIdentifiers)); + + Operation::fromPartial($parentIdentifier, [], [], $method, $partialOperation); + } + + #[Test, DataProvider('provideOperationsToValidate')] + public function itValidatesOperations( + InvalidOpenAPI $expected, + Identifier $parentIdentifier, + Method $method, + Partial\Operation $partialOperation, + ): void { + self::expectExceptionObject($expected); + + Operation::fromPartial($parentIdentifier, [], [], $method, $partialOperation); + } + + /** + * @param array $expected + * @param array $pathServers + * @param Partial\Server[] $operationServers + */ + #[Test, DataProvider('provideServers')] + #[TestDox('If a Path Item specifies any Servers, it overrides OpenAPI servers')] + public function itOverridesPathLevelServers( + array $expected, + Identifier $parentIdentifier, + string $operationId, + Method $method, + array $pathServers, + array $operationServers, + ): void { + $sut = Operation::fromPartial( + $parentIdentifier, + $pathServers, + [], + $method, + PartialHelper::createOperation( + operationId: $operationId, + servers: $operationServers, + ) + ); + + self::assertEquals($expected, $sut->servers); + } + + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideDuplicateServers')] + public function itWarnsAgainstDuplicateServers( + array $expected, + Partial\Operation $operation, + ): void { + $sut = Operation::fromPartial( + new Identifier('test'), + [], + [], + Method::GET, + $operation + ); + + self::assertEquals($expected, $sut + ->getWarnings() + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS)); + } + + /** + * @param Parameter[] $pathParameters + * @param Partial\Parameter[] $operationParameters + */ + #[Test, DataProvider('provideSimilarParameters')] + #[TestDox('It warns that similarly named parameters may be confusing')] + public function itWarnsAgainstSimilarParameters( + array $pathParameters, + array $operationParameters, + ): void { + $sut = Operation::fromPartial( + new Identifier('test'), + [], + $pathParameters, + Method::GET, + PartialHelper::createOperation(parameters: $operationParameters), + ); + self::assertNotEmpty($sut + ->getWarnings() + ->findByWarningCode(Warning::SIMILAR_NAMES)); + } + + #[Test, TestDox('The PathItem will already warn about its own parameters')] + public function itWillNotWarnAgainstSimilarPathParameters(): void + { + $parameter = new Parameter( + new Identifier('test'), + PartialHelper::createParameter() + ); + + $sut = Operation::fromPartial( + new Identifier('test'), + [], + [$parameter, $parameter], + Method::GET, + PartialHelper::createOperation(), + ); + + self::assertEmpty($sut->getWarnings()->findByWarningCode(Warning::SIMILAR_NAMES)); + } + + #[Test] + #[DataProvider('provideOperationsWithoutServers')] + public function itCanCreateANewInstanceWithoutServers( + array $servers, + array $parameters, + ): void { + $operationWith = fn($s) => Operation::fromPartial( + new Identifier('test'), + [], + [], + Method::GET, + PartialHelper::createOperation( + servers: $s, + parameters: $parameters, + ), + ); + + self::assertEquals( + $operationWith([new Partial\Server('/')]), + $operationWith($servers)->withoutServers(), + ); + } + + public static function provideParameters(): Generator + { + $parentIdentifier = new Identifier('/path'); + $operationId = 'test-operation'; + $method = Method::GET; + $identifier = $parentIdentifier->append("$operationId($method->value)"); + + $case = fn($expected, $pathParameters, $operationParameters) => [ + $parentIdentifier, + $expected, + array_map(fn($p) => new Parameter($parentIdentifier, $p), $pathParameters), + $method, + PartialHelper::createOperation( + operationId: $operationId, + parameters: $operationParameters + ), + ]; + + $unique1 = PartialHelper::createParameter(name: 'unique-name-1'); + $unique2 = PartialHelper::createParameter(name: 'unique-name-2'); + + $sameNamePath = PartialHelper::createParameter(name: 'same-name', in: 'path'); + $sameNameQuery = PartialHelper::createParameter(name: 'same-name', in: 'query'); + + + yield 'one operation parameter' => $case( + [ + new Parameter($identifier, $unique1), + ], + [], + [$unique1] + ); + + yield 'one unique path parameter, one unique operation parameter' => $case( + [ + new Parameter($identifier, $unique1), + new Parameter($parentIdentifier, $unique2), + ], + [$unique2], + [$unique1] + ); + + /** This is technically a unique parameter according to OpenAPI 3.0.3 */ + yield 'one path parameter, one operation parameter, same name, different locations' => $case( + [ + new Parameter($identifier, $sameNamePath), + new Parameter($parentIdentifier, $sameNameQuery), + ], + [$sameNameQuery], + [$sameNamePath] + ); + + yield 'one identical path parameter' => $case( + [ + new Parameter($identifier, $sameNamePath), + ], + [$sameNamePath], + [$sameNamePath] + ); + } + + public static function provideOperationsToValidate(): Generator + { + $parentIdentifier = new Identifier('test'); + $operationId = 'test-id'; + $method = Method::GET; + $identifier = $parentIdentifier->append($operationId, $method->value); + + $case = fn($expected, $data) => [ + $expected, + $parentIdentifier, + $method, + PartialHelper::createOperation(...array_merge( + ['operationId' => $operationId], + $data + )), + ]; + + yield 'duplicate parameters' => $case( + InvalidOpenAPI::duplicateParameters( + $identifier, + $identifier->append('duplicate', 'path'), + $identifier->append('duplicate', 'path'), + ), + [ + 'parameters' => array_pad([], 2, PartialHelper::createParameter( + name: 'duplicate', + in: 'path', + )), + ] + ); + } + + public static function provideServers(): Generator + { + $parentIdentifier = new Identifier('test-api'); + $operationId = 'test-operation'; + $method = Method::GET; + $identifier = $parentIdentifier->append($operationId, $method->value); + + $pathServers = [ + new Server($parentIdentifier, PartialHelper::createServer(url: '/')), + ]; + + $case = fn($operationServers) => [ + empty($operationServers) ? $pathServers : + array_map(fn($s) => new Server($identifier, $s), $operationServers), + $parentIdentifier, + $operationId, + $method, + $pathServers, + $operationServers, + ]; + + yield 'no Path Item Servers' => $case([]); + yield 'one Path Item Server' => $case([PartialHelper::createServer()]); + yield 'three Path Item Servers' => $case([ + PartialHelper::createServer(url: 'https://server-one.io'), + PartialHelper::createServer(url: 'https://server-two.co.uk'), + PartialHelper::createServer(url: 'https://server-three.net'), + ]); + } + + /** + * @return Generator + */ + public static function provideDuplicateServers(): Generator + { + $expectedWarning = new Warning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + + $case = fn($servers) => [ + [$expectedWarning], + PartialHelper::createOperation(servers: $servers), + ]; + + yield 'Completely identical: "/"' => $case([ + PartialHelper::createServer('/'), + PartialHelper::createServer('/'), + ]); + + yield 'Completely identical: "https://www.server.net"' => $case([ + PartialHelper::createServer('https://www.server.net'), + PartialHelper::createServer('https://www.server.net'), + ]); + + yield 'Identical IF you ignore trailing forward slashes' => $case([ + PartialHelper::createServer(''), + PartialHelper::createServer('/'), + ]); + } + + public static function provideSimilarParameters(): Generator + { + $case = fn(array $pathParamNames, array $operationParamNames) => [ + array_map( + fn($p) => new Parameter(new Identifier(''), PartialHelper::createParameter(name: $p)), + $pathParamNames + ), + array_map( + fn($p) => PartialHelper::createParameter(name: $p), + $operationParamNames + ), + ]; + + yield 'similar path param names' => $case(['param', 'Param'], []); + yield 'similar operation param names' => $case([], ['param', 'Param']); + yield 'similar param names' => $case(['param'], ['Param']); + } + + public static function provideOperationsWithoutServers(): Generator + { + yield 'default server' => [ + [new Partial\Server('/')], + [], + ]; + + yield 'static server' => [ + [new Partial\Server('hello-world.net/')], + [], + ]; + + yield 'multiple servers' => [ + [ + new Partial\Server('hello-world.net/'), + new Partial\Server('howdy-planet.io/'), + ], + [], + ]; + + yield 'dynamic server' => [ + [new Partial\Server('hello-{world}.net/', [ + new Partial\ServerVariable('world', 'world'), + ])], + [], + ]; + + yield 'parameter' => [ + [new Partial\Server('hello-parameter.io/')], + [PartialHelper::createParameter()] + ]; + } +} diff --git a/tests/ValueObject/Valid/V31/ParameterTest.php b/tests/ValueObject/Valid/V31/ParameterTest.php new file mode 100644 index 0000000..7b3a022 --- /dev/null +++ b/tests/ValueObject/Valid/V31/ParameterTest.php @@ -0,0 +1,706 @@ +getSchema()); + } + + #[Test, DataProvider('provideParametersWithOrWithoutMediaTypes')] + #[TestDox('A convenience method exists to check if it has a media type')] + public function itCanTellIfItHasAMediaType( + bool $expected, + ?string $expectedMediaType, + Partial\Parameter $partialParameter + ): void { + $sut = new Parameter(new Identifier('test'), $partialParameter); + + self::assertSame($expected, $sut->hasMediaType()); + + self::assertSame($expectedMediaType, $sut->getMediaType()); + } + + #[Test] + #[DataProvider('provideValidStylesPerLocation')] + public function itCanHaveAnyValidStylePerLocation( + Style $style, + In $in, + ): void { + $sut = new Parameter( + new Identifier('test'), + PartialHelper::createParameter( + in: $in->value, + style: $style->value, + schema: PartialHelper::createSchema(type: Type::Object->value), + ) + ); + + self::assertSame($style, $sut->style); + self::assertSame($in, $sut->in); + } + + #[Test] + #[DataProvider('provideDefaultStylesPerLocation')] + public function itCanDefaultStylePerLocation( + Style $expected, + In $in, + ): void { + $sut = new Parameter( + new Identifier('test'), + PartialHelper::createParameter(in: $in->value, style: null) + ); + + self::assertSame($expected, $sut->style); + } + + #[Test, DataProvider('provideStyles')] + public function itCanDefaultExplodePerStyle(In $in, Style $style): void + { + $sut = new Parameter( + new Identifier('test'), + PartialHelper::createParameter( + in: $in->value, + style: $style->value, + explode: null, + schema: PartialHelper::createSchema(type: Type::Object->value), + ) + ); + + self::assertSame($style === Style::Form, $sut->explode); + } + + #[Test, DataProvider('provideLocations')] + public function itCanDefaultRequiredIfOptional(In $in): void + { + $sut = new Parameter( + new Identifier('test'), + PartialHelper::createParameter(in: $in->value, required: null) + ); + + self::assertFalse($sut->required); + } + + #[Test, DataProvider('provideRequired')] + public function itWillTakeRequiredIfSpecified(bool $required): void + { + $sut = new Parameter( + new Identifier('test'), + PartialHelper::createParameter(in: 'query', required: $required) + ); + + self::assertSame($required, $sut->required); + } + + #[Test, DataProvider('provideParametersThatMayBeIdentical')] + #[TestDox('Parameter "name" and "in" must be identical')] + public function itChecksIfIdentical( + bool $expected, + Partial\Parameter $parameter, + Partial\Parameter $otherParameter, + ): void { + $sut = new Parameter(new Identifier(''), $parameter); + $otherSUT = new Parameter(new Identifier(''), $otherParameter); + + self::assertSame($expected, $sut->isIdentical($otherSUT)); + } + + #[Test, DataProvider('provideNamesThatMayBeSimilar')] + #[TestDox('"name" MUST NOT be identical, unless compared by case-insensitive comparison')] + public function itChecksIfNameIsSimilar( + bool $expected, + string $name, + string $other, + ): void { + $sut = new Parameter( + new Identifier(''), + PartialHelper::createParameter(name: $name) + ); + $otherSUT = new Parameter( + new Identifier(''), + PartialHelper::createParameter(name: $other) + ); + + self::assertSame($expected, $sut->isSimilar($otherSUT)); + } + + #[Test] + public function itInvalidesDeepObjectThatIsNotStrictlyObject(): void { + $identifier = new Identifier(''); + $name = 'test-param'; + $in = In::Query->value; + $partialParameter = PartialHelper::createParameter( + name: $name, + in: $in, + style: Style::DeepObject->value, + schema: PartialHelper::createSchema(), + ); + + self::expectExceptionObject( + InvalidOpenAPI::deepObjectMustBeObject($identifier->append($name, $in)) + ); + + $sut = new Parameter($identifier, $partialParameter); + + } + + #[Test, DataProvider('provideStylesThatMayBeSuitable')] + public function itWarnsAgainstUnsuitableStyles( + bool $isUnsuitable, + Partial\Parameter $parameter, + ): void { + $sut = new Parameter(new Identifier(''), $parameter); + + self::assertSame($isUnsuitable, $sut + ->getWarnings() + ->hasWarningCode(Warning::UNSUITABLE_STYLE)); + } + + #[Test] + #[DataProvider('provideParametersThatAreNotInQuery')] + #[DataProvider('provideParametersWithDifferentStyles')] + #[DataProvider('provideParametersThatMustBePrimitiveType')] + #[DataProvider('provideParametersInQueryThatDoNotConflict')] + #[DataProvider('provideParametersThatAreVulnerableToExplodingFormObject')] + public function itChecksIfItCanConflict( + bool $expected, + Partial\Parameter $parameter, + Partial\Parameter $other, + ): void { + $sut = new Parameter(new Identifier(''), $parameter); + $otherSUT = new Parameter(new Identifier(''), $other); + + self::assertSame($expected, $sut->canConflictWith($otherSUT)); + } + + public static function provideInvalidPartialParameters(): Generator + { + $parentIdentifier = new Identifier('test'); + $name = 'test-param'; + $in = 'path'; + $identifier = $parentIdentifier->append("$name($in)"); + + $case = fn($exception, $data) => [ + $parentIdentifier, + $exception, + PartialHelper::createParameter(...array_merge( + ['name' => $name, 'in' => $in], + $data + )), + ]; + + yield 'missing "name"' => $case( + InvalidOpenAPI::parameterMissingName($parentIdentifier), + ['name' => null], + ); + + yield 'missing "in"' => $case( + InvalidOpenAPI::parameterMissingLocation($parentIdentifier), + ['in' => null], + ); + + yield 'invalid "in"' => $case( + InvalidOpenAPI::parameterInvalidLocation($parentIdentifier), + ['in' => 'Wonderland'] + ); + + yield 'missing "required" when "in":"path"' => $case( + InvalidOpenAPI::parameterMissingRequired($identifier), + ['required' => null], + ); + + yield 'invalid "style"' => $case( + InvalidOpenAPI::parameterInvalidStyle($identifier), + ['style' => 'Fabulous!'] + ); + + $incompatibleStylesPerLocation = [ + 'matrix' => ['query', 'header', 'cookie'], + 'label' => ['query', 'header', 'cookie'], + 'form' => ['path', 'header'], + 'simple' => ['query', 'cookie'], + 'spaceDelimited' => ['path', 'header', 'cookie'], + 'pipeDelimited' => ['path', 'header', 'cookie'], + 'deepObject' => ['path', 'header', 'cookie'] + ]; + + foreach ($incompatibleStylesPerLocation as $style => $locations) { + foreach ($locations as $location) { + yield "incompatible $style for $location" => $case( + InvalidOpenAPI::parameterIncompatibleStyle($parentIdentifier->append($name, $location)), + ['style' => $style, 'in' => $location] + ); + } + } + + $schemaXorContentCases = [ + 'no schema nor content' => ['schema' => null, 'content' => []], + 'schema and content with schema' => [ + 'schema' => PartialHelper::createSchema(), + 'content' => [ + PartialHelper::createMediaType( + schema: PartialHelper::createSchema() + ), + ], + ], + 'no schema, has content, but content does not have a schema' => [ + 'schema' => null, + 'content' => [PartialHelper::createMediaType(schema: null)], + ], + ]; + + foreach ($schemaXorContentCases as $schemaXorContentCase => $data) { + yield $schemaXorContentCase => $case( + InvalidOpenAPI::mustHaveSchemaXorContent($identifier), + $data, + ); + } + + yield 'content with more than one mediaType' => $case( + InvalidOpenAPI::parameterContentCanOnlyHaveOneEntry($identifier), + [ + 'schema' => null, + 'content' => [ + PartialHelper::createMediaType( + mediaType: 'application/json', + schema: PartialHelper::createSchema() + ), + PartialHelper::createMediaType( + mediaType: 'application/pdf', + schema: PartialHelper::createSchema() + ) + ] + ] + ); + + yield 'content has schema, but no mediaType specified' => $case( + InvalidOpenAPI::contentMissingMediaType($identifier), + [ + 'schema' => null, + 'content' => [PartialHelper::createMediaType( + mediaType: null, + schema: PartialHelper::createSchema() + )] + ] + ); + } + + public static function provideParametersWithSchemasXorMediaType(): array + { + $schema = PartialHelper::createSchema(type: 'boolean'); + $parentIdentifier = new Identifier('test'); + $name = 'param'; + $in = 'path'; + $identifier = $parentIdentifier->append($name, $in); + + $case = fn($schemaIdentifier, $data) => [ + new Schema($schemaIdentifier, $schema), + $parentIdentifier, + PartialHelper::createParameter(...array_merge( + ['name' => $name, 'in' => $in], + $data + )) + + ]; + + return [ + 'with schema' => $case( + $identifier->append('schema'), + ['schema' => $schema, 'content' => []] + ), + 'with media type' => $case( + ($identifier->append('application/json'))->append('schema'), + [ + 'schema' => null, + 'content' => [PartialHelper::createMediaType( + mediaType: 'application/json', + schema: $schema + )], + ] + ) + ]; + } + + public static function provideParametersWithOrWithoutMediaTypes(): Generator + { + yield 'with schema' => [ + false, + null, + PartialHelper::createParameter( + schema: PartialHelper::createSchema(), + content: [] + ) + ]; + + yield 'with media type' => [ + true, + 'application/json', + PartialHelper::createParameter( + schema: null, + content: [PartialHelper::createMediaType( + mediaType: 'application/json', + schema: PartialHelper::createSchema() + )] + ) + ]; + } + + /** + * @return Generator + */ + public static function provideValidStylesPerLocation(): Generator + { + yield 'matrix - path' => [Style::Matrix, In::Path]; + yield 'label - path' => [Style::Label, In::Path]; + yield 'simple - path' => [Style::Simple, In::Path]; + + yield 'form - query' => [Style::Form, In::Query]; + yield 'spaceDelimited - query' => [Style::SpaceDelimited, In::Query]; + yield 'pipeDelimited - query' => [Style::PipeDelimited, In::Query]; + yield 'deepObject - query' => [Style::DeepObject, In::Query]; + + yield 'simple - header' => [Style::Simple, In::Header]; + + yield 'form - cookie' => [Style::Form, In::Cookie]; + } + + /** + * @return Generator + */ + public static function provideDefaultStylesPerLocation(): Generator + { + yield 'simple - path' => [Style::Simple, In::Path]; + + yield 'form - query' => [Style::Form, In::Query]; + + yield 'simple - header' => [Style::Simple, In::Header]; + + yield 'form - cookie' => [Style::Form, In::Cookie]; + } + + public static function provideStyles(): Generator + { + yield 'matrix' => [In::Path, Style::Matrix]; + yield 'label' => [In::Path, Style::Label]; + yield 'form' => [In::Query, Style::Form]; + yield 'simple' => [In::Path, Style::Simple]; + yield 'spaceDelimited' => [In::Query, Style::SpaceDelimited]; + yield 'pipeDelimited' => [In::Query, Style::PipeDelimited]; + yield 'deepObject' => [In::Query, Style::DeepObject]; + } + + public static function provideLocations(): Generator + { + yield 'query' => [In::Query]; + yield 'header' => [In::Header]; + yield 'cookie' => [In::Cookie]; + } + + public static function provideRequired(): Generator + { + yield 'required' => [true]; + yield 'not required' => [false]; + } + + /** + * @return Generator + */ + public static function provideParametersThatMayBeIdentical(): Generator + { + $cases = [ + 'identical name - "param"' => [true, 'param', 'param'], + 'identical name - "äöü"' => [true, 'äöü', 'äöü'], + 'similar names - "param" and "Param"' => [false, 'param', 'Param'], + 'similar names - "äöü" and "Äöü"' => [false, 'äöü', 'Äöü'], + 'not similar names - "äöü" and "param"' => [false, 'äöü', 'param'], + ]; + + foreach ($cases as $case => $data) { + yield "$case with identical locations" => [ + $data[0], + PartialHelper::createParameter(name: $data[1], in: 'path'), + PartialHelper::createParameter(name: $data[2], in: 'path'), + ]; + + yield "$case with different locations" => [ + false, + PartialHelper::createParameter(name: $data[1], in: 'path'), + PartialHelper::createParameter(name: $data[2], in: 'query'), + ]; + } + } + + /** + * @return Generator + */ + public static function provideNamesThatMayBeSimilar(): Generator + { + yield 'identical - "param"' => [false, 'param', 'param']; + yield 'identical - "äöü"' => [false, 'äöü', 'äöü']; + yield 'similar - "param" and "Param"' => [true, 'param', 'Param']; + yield 'similar - "äöü" and "Äöü"' => [true, 'äöü', 'Äöü']; + yield 'similar - "Äöü" and "äöü"' => [true, 'Äöü', 'äöü']; + yield 'not similar - "äöü" and "param"' => [false, 'äöü', 'param']; + yield 'not similar - "param" and "äöü"' => [false, 'param', 'äöü']; + } + + /** @return Generator */ + public static function provideStylesThatMayBeSuitable(): Generator + { + foreach (Type::casesForVersion(OpenAPIVersion::Version_3_0) as $type) { + foreach (Style::cases() as $style) { + if ($style === Style::DeepObject && $type !== Type::Object) { + continue; // this unsuitability invalidates the OpenAPI + } + yield "style:$style->value, type:$type->value" => [ + in_array($style, [Style::SpaceDelimited, Style::PipeDelimited]) && $type->isPrimitive(), + PartialHelper::createParameter( + in: array_values(array_filter( + In::cases(), + fn($in) => $style->isAllowed($in) + ))[0]->value, + style: $style->value, + schema: PartialHelper::createSchema(type: $type->value) + ) + ]; + } + } + } + + /** + * @return Generator + */ + public static function provideParametersThatAreNotInQuery(): Generator + { + $availableStyles = fn($in) => array_filter( + Style::cases(), + fn($s) => $s->isAllowed($in) + ); + + foreach (array_filter(In::cases(), fn($i) => $i !== In::Query) as $in) { + foreach ($availableStyles($in) as $style) { + $parameter = fn(bool $explode) => PartialHelper::createParameter( + in: $in->value, + style: $style->value, + explode: $explode + ); + + yield "style:$style->value, in:$in->value, explode:true" => [ + false, + $parameter(true), + $parameter(true), + ]; + + yield "style:$style->value, in:$in->value, explode:false" => [ + false, + $parameter(false), + $parameter(false), + ]; + } + } + } + + /** + * @return Generator + */ + public static function provideParametersWithDifferentStyles(): Generator + { + $availableStyles = array_filter( + Style::cases(), + fn($s) => $s->isAllowed(In::Query) + ); + + while (count($availableStyles) > 1) { + $style = array_pop($availableStyles); + foreach ($availableStyles as $other) { + yield "style:$style->value and other style:$other->value" => [ + in_array(Style::Form, [$style, $other]) && !in_array(Style::DeepObject, [$style, $other]), + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: true, + schema: PartialHelper::createSchema( + type: $style === Style::DeepObject ? 'object' : null, + ) + ), + PartialHelper::createParameter( + in: In::Query->value, + style: $other->value, + explode: true, + schema: PartialHelper::createSchema( + type: $other === Style::DeepObject ? 'object' : null, + ) + ), + ]; + } + } + } + + /** + * @return Generator + */ + public static function provideParametersInQueryThatDoNotConflict(): Generator + { + yield 'deepObject' => [ + false, + PartialHelper::createParameter( + in: In::Query->value, + style: Style::DeepObject->value, + explode: true, + schema: PartialHelper::createSchema(type: Type::Object->value) + ), + PartialHelper::createParameter( + in: In::Query->value, + style: Style::DeepObject->value, + explode: true, + schema: PartialHelper::createSchema(type: Type::Object->value) + ), + ]; + } + + /** + * @return Generator + */ + public static function provideParametersThatAreVulnerableToExplodingFormObject(): Generator + { + $dataSet = fn(Style $style, bool $explode, Type $type) => [ + true, + PartialHelper::createParameter( + in: In::Query->value, + style: Style::Form->value, + explode: true, + schema: PartialHelper::createSchema(type: Type::Object->value) + ), + PartialHelper::createParameter( + in: In::Query->value, + style: $style->value, + explode: $explode, + schema: PartialHelper::createSchema(type: $type->value) + ), + ]; + + yield 'style:form and explode:true for object data types' => + $dataSet(Style::Form, true, Type::Object); + + yield 'style:pipeDelimited with primitive data type' => + $dataSet(Style::PipeDelimited, false, Type::String); + + yield 'style:spaceDelimited with primitive data type' => + $dataSet(Style::SpaceDelimited, false, Type::String); + } + + /** + * @return Generator + */ + public static function provideParametersThatMustBePrimitiveType(): Generator + { + foreach (self::provideParametersWithDifferentStyles() as $case => $dataSet) { + if ($dataSet[1]->style !== Style::DeepObject->value) { + $dataSet[1]->schema = PartialHelper::createSchema( + type: Type::Integer->value + ); + } + if ($dataSet[2]->style !== Style::DeepObject->value) { + $dataSet[2]->schema = PartialHelper::createSchema( + type: Type::Integer->value + ); + } + + yield 'integer only:' . $case => [ + false, + $dataSet[1], + $dataSet[2], + ]; + } + } +} diff --git a/tests/ValueObject/Valid/V31/PathItemTest.php b/tests/ValueObject/Valid/V31/PathItemTest.php new file mode 100644 index 0000000..a626ba9 --- /dev/null +++ b/tests/ValueObject/Valid/V31/PathItemTest.php @@ -0,0 +1,443 @@ + new Parameter($identifier, $p), + $partialExpected + )); + + $sut = PathItem::fromPartial($identifier, [], $partialPathItem); + + self::assertEquals($expected, $sut->parameters); + } + + #[Test] + #[TestDox('It invalidates Parameters with identical "name" & "in" values')] + public function itInvalidatesDuplicateParameters(): void + { + $identifier = new Identifier('test-path-item'); + + $name = 'param'; + $in = 'path'; + $param = PartialHelper::createParameter(name: $name, in: $in); + $paramIdentifier = $identifier->append($name, $in); + + $pathItem = PartialHelper::createPathItem(parameters: [$param, $param]); + + self::expectExceptionObject(InvalidOpenAPI::duplicateParameters( + $identifier, + $paramIdentifier, + $paramIdentifier, + )); + + PathItem::fromPartial($identifier, [], $pathItem); + } + + #[Test] + #[TestDox('Identical names in different locations may serve a purpose')] + public function itDoesNotWarnAgainstIdenticalNames(): void + { + $sut = PathItem::fromPartial( + new Identifier('test-path-item'), + [], + PartialHelper::createPathItem(parameters: [ + PartialHelper::createParameter(name: 'param', in: 'path'), + PartialHelper::createParameter(name: 'param', in: 'query') + ]) + ); + + self::assertEmpty($sut + ->getWarnings() + ->findByWarningCode(Warning::SIMILAR_NAMES)); + } + + #[Test] + #[TestDox('It warns that similar names, though valid, may be confusing')] + public function itWarnsAgainstSimilarNames(): void + { + $sut = PathItem::fromPartial( + new Identifier('test-path-item'), + [], + PartialHelper::createPathItem(parameters: [ + PartialHelper::createParameter(name: 'param'), + PartialHelper::createParameter(name: 'PARAM') + ]) + ); + + self::assertNotEmpty($sut + ->getWarnings() + ->findByWarningCode(Warning::SIMILAR_NAMES)); + } + + /** + * @param Warning[] $expected + */ + #[Test, DataProvider('provideDuplicateServers')] + public function itWarnsAgainstDuplicateServers( + array $expected, + Partial\PathItem $pathItem, + ): void { + $sut = PathItem::fromPartial(new Identifier('test'), [], $pathItem); + + self::assertEquals($expected, $sut + ->getWarnings() + ->findByWarningCode(Warning::IDENTICAL_SERVER_URLS)); + } + + #[Test, DataProvider('provideRedundantMethods')] + #[TestDox('it warns that options, head and trace are redundant methods for an OpenAPI')] + public function itWarnsAgainstRedundantMethods(string $method): void + { + $operations = [$method => PartialHelper::createOperation()]; + + $partialPathItem = PartialHelper::createPathItem(...$operations); + + $sut = PathItem::fromPartial(new Identifier('test'), [], $partialPathItem); + + self::assertEquals( + new Warning( + "$method is redundant in an OpenAPI Specification.", + Warning::REDUNDANT_METHOD + ), + $sut->getWarnings()->all()[0] + ); + } + + #[Test] + #[TestDox('it warns that there are no operations specified on this path')] + public function itWarnsAgainstHavingNoOperations(): void + { + $partialPathItem = PartialHelper::createPathItem(); + + $sut = PathItem::fromPartial(new Identifier('test'), [], $partialPathItem); + + self::assertEquals( + new Warning('No Operations on Path', Warning::EMPTY_PATH), + $sut->getWarnings()->all()[0] + ); + } + + /** + * @param array $expected + * @param Partial\Operation[] $operations + */ + #[Test, DataProvider('provideOperationsToGet')] + #[TestDox('it has a convenience method that gets all operations mapped by their method')] + public function itCanGetAllOperations( + array $expected, + Identifier $identifier, + array $operations, + ): void { + $sut = PathItem::fromPartial( + $identifier, + [], + PartialHelper::createPathItem(...$operations) + ); + + self::assertEquals($expected, $sut->getOperations()); + } + + /** + * @param array $expected + * @param array $openapiServers + * @param Partial\Server[] $pathItemServers + */ + #[Test, DataProvider('provideServers')] + #[TestDox('If a Path Item specifies any Servers, then it should override any OpenAPI servers')] + public function itOverridesOpenAPILevelServers( + array $expected, + Identifier $identifier, + array $openapiServers, + array $pathItemServers, + ): void { + $sut = PathItem::fromPartial( + $identifier, + $openapiServers, + PartialHelper::createPathItem( + servers: $pathItemServers + ) + ); + + self::assertEquals(array_values($expected), $sut->servers); + } + + #[Test] + #[DataProvider('newPathItemsWithoutServers')] + public function itCanCreateANewInstanceWithoutServers( + array $servers, + array $parameters, + ): void { + $pathWith = fn($s) => PathItem::fromPartial( + new Identifier('test'), + [], + PartialHelper::createPathItem( + servers: $s, + parameters: $parameters, + ), + ); + + self::assertEquals( + $pathWith([new Partial\Server('/')]), + $pathWith($servers)->withoutServers(), + ); + } + + public static function providePartialPathItems(): Generator + { + $p1 = PartialHelper::createParameter(name: 'p1'); + $p2 = PartialHelper::createParameter(name: 'p2'); + $p3 = PartialHelper::createParameter(name: 'p3'); + + yield 'no parameters' => [ + [], + PartialHelper::createPathItem(parameters: []), + ]; + + yield 'one parameter' => [ + [$p1], + PartialHelper::createPathItem(parameters: [$p1]), + ]; + + yield 'one parameter with name used as key' => [ + [$p1], + PartialHelper::createPathItem(parameters: ['p1' => $p1]), + ]; + + yield 'three parameters' => [ + [$p1, $p2, $p3], + PartialHelper::createPathItem(parameters: [$p1, $p2, $p3]), + ]; + + yield 'three parameters with names used as keys' => [ + [$p1, $p2, $p3], + PartialHelper::createPathItem(parameters: [ + 'p1' => $p1, + 'p2' => $p2, + 'p3' => $p3, + ]), + ]; + } + + public static function provideSimilarNames(): Generator + { + yield 'two names that only differ in case' => ['param', 'PARAM']; + + } + + /** + * @return Generator + */ + public static function provideDuplicateServers(): Generator + { + $expectedWarning = new Warning( + 'Server URLs are not unique', + Warning::IDENTICAL_SERVER_URLS + ); + + $case = fn($servers) => [ + [$expectedWarning], + PartialHelper::createPathItem(servers: $servers), + ]; + + yield 'Completely identical: "/"' => $case([ + PartialHelper::createServer('/'), + PartialHelper::createServer('/'), + ]); + + yield 'Completely identical: "https://www.server.net"' => $case([ + PartialHelper::createServer('https://www.server.net'), + PartialHelper::createServer('https://www.server.net'), + ]); + + yield 'Identical IF you ignore trailing forward slashes' => $case([ + PartialHelper::createServer(''), + PartialHelper::createServer('/'), + ]); + } + + public static function provideRedundantMethods(): Generator + { + yield 'options' => ['options']; + yield 'trace' => ['trace']; + yield 'head' => ['head']; + } + + public static function provideOperationsToGet(): Generator + { + $identifier = new Identifier('test'); + $case = fn($expected, $operations) => [ + $expected, + $identifier, + $operations, + ]; + + yield 'no operations' => $case([], []); + + $partialOperation = fn($method) => PartialHelper::createOperation( + operationId: "$method-id" + ); + + $validOperation = fn($method) => Operation::fromPartial( + $identifier, + [], + [], + Method::from($method), + $partialOperation($method), + ); + + yield '"get" operation' => $case( + [ + 'get' => $validOperation('get') + ], + ['get' => $partialOperation('get')] + ); + + yield 'every operation' => $case( + [ + 'get' => $validOperation('get'), + 'put' => $validOperation('put'), + 'post' => $validOperation('post'), + 'delete' => $validOperation('delete'), + 'options' => $validOperation('options'), + 'head' => $validOperation('head'), + 'patch' => $validOperation('patch'), + 'trace' => $validOperation('trace'), + ], + [ + 'get' => $partialOperation('get'), + 'put' => $partialOperation('put'), + 'post' => $partialOperation('post'), + 'delete' => $partialOperation('delete'), + 'options' => $partialOperation('options'), + 'head' => $partialOperation('head'), + 'patch' => $partialOperation('patch'), + 'trace' => $partialOperation('trace'), + ] + ); + } + + public static function provideServers(): Generator + { + $parentIdentifier = new Identifier('test-api'); + $identifier = $parentIdentifier->append('test-path'); + + $openAPIServers = [ + new Server($parentIdentifier, PartialHelper::createServer(url: '/')), + new Server($parentIdentifier, PartialHelper::createServer(url: '/petstore.io')), + ]; + + $case = fn($pathServers) => [ + empty($pathServers) ? $openAPIServers : + array_map(fn($s) => new Server($identifier, $s), $pathServers), + $identifier, + $openAPIServers, + $pathServers + ]; + + yield 'no Path Item Servers' => $case([]); + yield 'one Path Item Server with its url used as a key' => $case([ + 'https://server-one.io' => + PartialHelper::createServer(url: 'https://server-one.io') + ]); + yield 'three Path Item Servers' => $case([ + 'https://server-one.io' => + PartialHelper::createServer(url: 'https://server-one.io'), + 'https://server-two.co.uk' => + PartialHelper::createServer(url: 'https://server-two.co.uk'), + 'https://server-three.net' => + PartialHelper::createServer(url: 'https://server-three.net') + ]); + } + + public static function newPathItemsWithoutServers(): Generator + { + yield 'default server' => [ + [new Partial\Server('/')], + [], + ]; + + yield 'static server' => [ + [new Partial\Server('hello-world.net/')], + [], + ]; + + yield 'multiple servers' => [ + [ + new Partial\Server('hello-world.net/'), + new Partial\Server('howdy-planet.io/'), + ], + [], + ]; + + yield 'dynamic server' => [ + [new Partial\Server('hello-{world}.net/', [ + new Partial\ServerVariable('world', 'world'), + ])], + [], + ]; + + yield 'parameter' => [ + [new Partial\Server('hello-parameter.io/')], + [PartialHelper::createParameter()] + ]; + } +} From a5c72e51064e14aaa536dc14b2389e7f44af7990 Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 16:08:36 +0000 Subject: [PATCH 14/21] Update PartialHelper for 3.1 --- tests/fixtures/Helper/PartialHelper.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/Helper/PartialHelper.php b/tests/fixtures/Helper/PartialHelper.php index 38b925c..f8a3277 100644 --- a/tests/fixtures/Helper/PartialHelper.php +++ b/tests/fixtures/Helper/PartialHelper.php @@ -153,8 +153,8 @@ public static function createSchema( float|int|null $multipleOf = null, float|int|null $maximum = null, float|int|null $minimum = null, - bool $exclusiveMaximum = false, - bool $exclusiveMinimum = false, + float|int|bool|null $exclusiveMaximum = null, + float|int|bool|null $exclusiveMinimum = null, int|null $maxLength = null, int $minLength = 0, string|null $pattern = null, From 9679f726ebb62a7e6f73918c801daff85013d2a9 Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 18:10:59 +0000 Subject: [PATCH 15/21] Update MembraneReader to pass V31 tests --- src/Factory/V31/FromCebe.php | 4 +- src/MembraneReader.php | 33 +- tests/MembraneReaderTest.php | 97 +++--- tests/fixtures/Helper/V31OpenAPIProvider.php | 305 +++++++++++++++++++ 4 files changed, 387 insertions(+), 52 deletions(-) create mode 100644 tests/fixtures/Helper/V31OpenAPIProvider.php diff --git a/src/Factory/V31/FromCebe.php b/src/Factory/V31/FromCebe.php index 396dfa8..28d2c13 100644 --- a/src/Factory/V31/FromCebe.php +++ b/src/Factory/V31/FromCebe.php @@ -154,8 +154,8 @@ enum: isset($schema->enum) ? default: isset($schema->default) ? new Value($schema->default) : null, nullable: $schema->nullable ?? false, multipleOf: $schema->multipleOf ?? null, - exclusiveMaximum: $schema->exclusiveMaximum ?? false, - exclusiveMinimum: $schema->exclusiveMinimum ?? false, + exclusiveMaximum: $schema->exclusiveMaximum ?? null, + exclusiveMinimum: $schema->exclusiveMinimum ?? null, maximum: $schema->maximum ?? null, minimum: $schema->minimum ?? null, maxLength: $schema->maxLength ?? null, diff --git a/src/MembraneReader.php b/src/MembraneReader.php index 0767251..a5a8c1b 100644 --- a/src/MembraneReader.php +++ b/src/MembraneReader.php @@ -7,8 +7,7 @@ use cebe\{openapi as Cebe, openapi\exceptions as CebeException, openapi\spec as CebeSpec}; use Closure; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; -use Membrane\OpenAPIReader\Factory\V30\FromCebe; -use Membrane\OpenAPIReader\ValueObject\Valid\V30\OpenAPI; +use Membrane\OpenAPIReader\ValueObject\Valid; use Symfony\Component\Yaml\Exception\ParseException; use TypeError; @@ -21,15 +20,12 @@ public function __construct( if (empty($this->supportedVersions)) { throw CannotSupport::noSupportedVersions(); } - - /** todo create 3.1 validated objects */ - if ($this->supportedVersions !== [OpenAPIVersion::Version_3_0]) { - throw CannotSupport::membraneReaderOnlySupportsv30(); - } } - public function readFromAbsoluteFilePath(string $absoluteFilePath, ?FileFormat $fileFormat = null): OpenAPI - { + public function readFromAbsoluteFilePath( + string $absoluteFilePath, + ?FileFormat $fileFormat = null + ): Valid\V30\OpenAPI|Valid\V31\OpenAPI { file_exists($absoluteFilePath) ?: throw CannotRead::fileNotFound($absoluteFilePath); $fileFormat ??= FileFormat::fromFileExtension(pathinfo($absoluteFilePath, PATHINFO_EXTENSION)); @@ -47,7 +43,7 @@ public function readFromAbsoluteFilePath(string $absoluteFilePath, ?FileFormat $ return $this->getValidatedObject($openAPI); } - public function readFromString(string $openAPI, FileFormat $fileFormat): OpenAPI + public function readFromString(string $openAPI, FileFormat $fileFormat): Valid\V30\OpenAPI|Valid\V31\OpenAPI { if (preg_match('#\s*[\'\"]?\$ref[\'\"]?\s*:\s*[\'\"]?[^\s\'\"\#]#', $openAPI)) { throw CannotRead::cannotResolveExternalReferencesFromString(); @@ -77,19 +73,26 @@ private function getCebeObject(Closure $readOpenAPI): CebeSpec\OpenApi } } - private function getValidatedObject(CebeSpec\OpenApi $openAPI): OpenAPI + private function getValidatedObject(CebeSpec\OpenApi $openAPI): Valid\V30\OpenAPI|Valid\V31\OpenAPI { - $this->isVersionSupported($openAPI->openapi) ?: throw CannotSupport::unsupportedVersion($openAPI->openapi); + $version = OpenAPIVersion::fromString($openAPI->openapi) ?? + throw CannotSupport::unsupportedVersion($openAPI->openapi); + + $this->isVersionSupported($version) ?: + throw CannotSupport::unsupportedVersion($openAPI->openapi); - $validatedObject = FromCebe::createOpenAPI($openAPI); + $validatedObject = match ($version) { + OpenAPIVersion::Version_3_0 => Factory\V30\FromCebe::createOpenAPI($openAPI), + OpenAPIVersion::Version_3_1 => Factory\V31\FromCebe::createOpenAPI($openAPI), + }; $openAPI->validate() ?: throw InvalidOpenAPI::failedCebeValidation(...$openAPI->getErrors()); return $validatedObject; } - private function isVersionSupported(string $version): bool + private function isVersionSupported(OpenAPIVersion $version): bool { - return in_array(OpenAPIVersion::fromString($version), $this->supportedVersions, true); + return in_array($version, $this->supportedVersions, true); } } diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php index d28ca6c..371978a 100644 --- a/tests/MembraneReaderTest.php +++ b/tests/MembraneReaderTest.php @@ -7,11 +7,14 @@ use cebe\{openapi\exceptions as CebeException}; use Generator; use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesPetstoreApi; +use Membrane\OpenAPIReader\Tests\Fixtures\ProvidesTrainTravelApi; +use Membrane\OpenAPIReader\Tests\Fixtures\Helper\V31OpenAPIProvider; use Membrane\OpenAPIReader\{FileFormat, OpenAPIVersion}; use Membrane\OpenAPIReader\Exception\{CannotRead, CannotSupport, InvalidOpenAPI}; use Membrane\OpenAPIReader\Factory\V30\FromCebe; use Membrane\OpenAPIReader\MembraneReader; use Membrane\OpenAPIReader\Tests\Fixtures\Helper\OpenAPIProvider; +use Membrane\OpenAPIReader\Tests\Fixtures\Helper\PartialHelper; use Membrane\OpenAPIReader\ValueObject\Partial; use Membrane\OpenAPIReader\ValueObject\Valid; use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Method; @@ -61,15 +64,6 @@ public function itMustBeProvidedAtLeastOneVersionToSupport(): void new MembraneReader([]); } - #[Test] - #[TestDox('Currently only V3.0.X Membrane Objects are supported')] - public function itCurrentlySupportsOnlyV30(): void - { - self::expectExceptionObject(CannotSupport::membraneReaderOnlySupportsv30()); - - new MembraneReader([OpenAPIVersion::Version_3_1]); - } - #[Test] public function itCannotReadFilesItCannotFind(): void { @@ -326,12 +320,17 @@ public function itCannotSupportAmbiguousResolution( } #[Test, DataProvider('provideOpenAPIToRead')] - public function itReadsFromFile(OpenAPI $expected, string $openApi): void - { + public function itReadsFromFile( + Valid\V30\OpenAPI|Valid\V31\OpenAPI $expected, + string $openApi + ): void { $filePath = vfsStream::setup()->url() . '/openapi.json'; file_put_contents($filePath, $openApi); - $sut = new MembraneReader([OpenAPIVersion::Version_3_0]); + $sut = new MembraneReader([ + OpenAPIVersion::Version_3_0, + OpenAPIVersion::Version_3_1, + ]); $actual = $sut->readFromAbsoluteFilePath($filePath, FileFormat::Json); @@ -339,9 +338,14 @@ public function itReadsFromFile(OpenAPI $expected, string $openApi): void } #[Test, DataProvider('provideOpenAPIToRead')] - public function itReadsFromString(OpenAPI $expected, string $openApi): void - { - $sut = new MembraneReader([OpenAPIVersion::Version_3_0]); + public function itReadsFromString( + Valid\V30\OpenAPI|Valid\V31\OpenAPI $expected, + string $openApi + ): void { + $sut = new MembraneReader([ + OpenAPIVersion::Version_3_0, + OpenAPIVersion::Version_3_1, + ]); $actual = $sut->readFromString($openApi, FileFormat::Json); @@ -350,13 +354,17 @@ public function itReadsFromString(OpenAPI $expected, string $openApi): void #[Test] #[DataProviderExternal(ProvidesPetstoreApi::class, 'provideOperations')] + //#[DataProviderExternal(ProvidesTrainTravelApi::class, 'provideOperations')] public function itReadsRealExamples( string $filepath, string $path, Method $method, - Valid\V30\Operation $expected + Valid\V31\Operation|Valid\V30\Operation $expected ): void { - $sut = new MembraneReader([OpenAPIVersion::Version_3_0]); + $sut = new MembraneReader([ + OpenAPIVersion::Version_3_0, + OpenAPIVersion::Version_3_1, + ]); $api = $sut->readFromAbsoluteFilePath($filepath); @@ -365,7 +373,6 @@ public function itReadsRealExamples( self::assertEquals($expected, $actual); } - public static function provideInvalidFormatting(): Generator { yield 'Empty string to be interpreted as json' => ['', FileFormat::Json]; @@ -388,7 +395,7 @@ public static function provideInvalidOpenAPIs(): Generator ]; })(); - $openAPI = ['openapi' => '3.0.0', 'info' => ['title' => '', 'version' => '1.0.0'], 'paths' => []]; + $openAPI = ['openapi' => '3.0.0', 'info' => ['title' => 'test', 'version' => '1.0.0'], 'paths' => []]; $openAPIPath = fn($operationId) => [ 'operationId' => $operationId, 'responses' => [200 => ['description' => ' Successful Response']], @@ -406,8 +413,8 @@ public static function provideInvalidOpenAPIs(): Generator return json_encode($openAPIArray); })(), InvalidOpenAPI::equivalentTemplates( - new Identifier('(1.0.0)', '/path/{param1}'), - new Identifier('(1.0.0)', '/path/{param2}'), + new Identifier('test(1.0.0)', '/path/{param1}'), + new Identifier('test(1.0.0)', '/path/{param2}'), ), ]; @@ -443,9 +450,12 @@ public static function provideInvalidOpenAPIs(): Generator $openAPIArray['paths'] = ['/firstpath' => ['get' => $path]]; return json_encode($openAPIArray); })(), - InvalidOpenAPI::mustHaveSchemaXorContent( - new Identifier('(1.0.0)', '/firstpath', 'get-first-path(get)', 'param(query)') - ), + InvalidOpenAPI::mustHaveSchemaXorContent(new Identifier( + 'test(1.0.0)', + '/firstpath', + 'get-first-path(get)', + 'param(query)', + )), ]; yield 'path with parameter both schema and content' => [ @@ -462,9 +472,11 @@ public static function provideInvalidOpenAPIs(): Generator ],]; return json_encode($openAPIArray); })(), - InvalidOpenAPI::mustHaveSchemaXorContent( - new Identifier('(1.0.0)', '/firstpath', 'param(query)') - ), + InvalidOpenAPI::mustHaveSchemaXorContent(new Identifier( + 'test(1.0.0)', + '/firstpath', + 'param(query)', + )), ]; yield 'path with operation missing both schema and content' => [ @@ -476,9 +488,11 @@ public static function provideInvalidOpenAPIs(): Generator ]]; return json_encode($openAPIArray); })(), - InvalidOpenAPI::mustHaveSchemaXorContent( - new Identifier('(1.0.0)', '/firstpath', 'param(query)') - ), + InvalidOpenAPI::mustHaveSchemaXorContent(new Identifier( + 'test(1.0.0)', + '/firstpath', + 'param(query)', + )), ]; yield 'path with operation both schema and content' => [ @@ -494,9 +508,12 @@ public static function provideInvalidOpenAPIs(): Generator $openAPIArray['paths'] = ['/firstpath' => ['get' => $path],]; return json_encode($openAPIArray); })(), - InvalidOpenAPI::mustHaveSchemaXorContent( - new Identifier('(1.0.0)', '/firstpath', 'get-first-path(get)', 'param(query)') - ), + InvalidOpenAPI::mustHaveSchemaXorContent(new Identifier( + 'test(1.0.0)', + '/firstpath', + 'get-first-path(get)', + 'param(query)', + )), ]; } @@ -699,14 +716,24 @@ public static function provideConflictingParameters(): Generator public static function provideOpenAPIToRead(): Generator { - yield 'minimal OpenAPI' => [ + yield 'minimal V30' => [ OpenAPIProvider::minimalV30MembraneObject(), OpenAPIProvider::minimalV30String(), ]; - yield 'detailed OpenAPI' => [ + yield 'detailed V30' => [ OpenAPIProvider::detailedV30MembraneObject(), OpenAPIProvider::detailedV30String(), ]; + + yield 'minimal V31' => [ + V31OpenAPIProvider::minimalV31MembraneObject(), + V31OpenAPIProvider::minimalV31String(), + ]; + + yield 'detailed V31' => [ + V31OpenAPIProvider::detailedV31MembraneObject(), + V31OpenAPIProvider::detailedV31String(), + ]; } } diff --git a/tests/fixtures/Helper/V31OpenAPIProvider.php b/tests/fixtures/Helper/V31OpenAPIProvider.php new file mode 100644 index 0000000..6a3a782 --- /dev/null +++ b/tests/fixtures/Helper/V31OpenAPIProvider.php @@ -0,0 +1,305 @@ + '3.1.0', + 'info' => ['title' => 'My Minimal OpenAPI', 'version' => '1.0.0'], + 'paths' => [] + ]); + + assert(is_string($string)); + + return $string; + } + + /** + * This will return a "minimal" Membrane OpenAPI object + * Functions prefixed with "minimalV31" return equivalent OpenAPI + */ + public static function minimalV31MembraneObject(): OpenAPI + { + return OpenAPI::fromPartial(new Partial\OpenAPI( + openAPI: '3.1.0', + title: 'My Minimal OpenAPI', + version: '1.0.0', + paths: [] + )); + } + + /** + * This will return a "detailed" JSON string OpenAPI + * Functions prefixed with "detailedV31" return equivalent OpenAPI + */ + public static function detailedV31String(): string + { + $string = json_encode([ + 'openapi' => '3.1.0', + 'info' => ['title' => 'My Detailed OpenAPI', 'version' => '1.0.1'], + 'servers' => [ + [ + 'url' => 'https://server.net/{version}', + 'variables' => [ + 'version' => [ + 'default' => '2.1', + 'enum' => ['2.0', '2.1', '2.2',] + ] + ] + ] + ], + 'paths' => [ + '/first' => [ + 'parameters' => [ + [ + 'name' => 'limit', + 'in' => 'query', + 'required' => false, + 'schema' => [ + 'type' => ['integer', 'string'], + 'minimum' => 5, + 'exclusiveMinimum' => 6, + 'maximum' => 8.3, + 'exclusiveMaximum' => 8.21, + 'maxLength' => 2, + 'minLength' => 1, + 'pattern' => '.*', + 'maxItems' => 5, + 'minItems' => 3, + 'uniqueItems' => true, + 'maxContains' => 5, + 'minContains' => 2, + 'maxProperties' => 6, + 'minProperties' => 2, + 'required' => ['property1', 'property2'], + 'dependentRequired' => ['property2' => ['property3']], + 'not' => ['type' => 'integer'], + ] + ] + ], + 'get' => [ + 'operationId' => 'first-get', + 'parameters' => [ + [ + 'name' => 'pet', + 'in' => 'header', + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'allOf' => [ + ['type' => 'integer'], + ['type' => 'number'], + ] + ] + ] + ] + ] + ], + 'responses' => [ + '200' => [ + 'description' => 'Successful Response' + ] + ] + ] + ], + '/second' => [ + 'servers' => [ + ['url' => 'https://second-server.co.uk'] + ], + 'parameters' => [ + [ + 'name' => 'limit', + 'in' => 'query', + 'required' => false, + 'schema' => ['type' => 'integer'] + ] + ], + 'get' => [ + 'operationId' => 'second-get', + 'parameters' => [ + [ + 'name' => 'pet', + 'in' => 'header', + 'required' => true, + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'allOf' => [ + ['type' => 'integer'], + ['type' => 'number'] + ] + ] + ] + ] + ] + ], + 'responses' => [ + '200' => [ + 'description' => 'Successful Response' + ] + ] + ], + 'put' => [ + 'operationId' => 'second-put', + 'servers' => [ + ['url' => 'https://second-put.com'] + ], + 'parameters' => [[ + 'name' => 'user', + 'in' => 'cookie', + 'schema' => ['type' => 'object'], + ]], + 'responses' => [ + '200' => [ + 'description' => 'Successful Response' + ] + ] + ] + ] + ], + ]); + + assert(is_string($string)); + + return $string; + } + + /** + * This will return a "detailed" Membrane\OpenAPIReader\ValueObject\Valid\V31\OpenAPI object + * Functions prefixed with "detailedV31" return equivalent OpenAPI + */ + public static function detailedV31MembraneObject(): OpenAPI + { + return OpenAPI::fromPartial(new Partial\OpenAPI( + openAPI: '3.1.0', + title: 'My Detailed OpenAPI', + version: '1.0.1', + servers: [new Partial\Server( + url: 'https://server.net/{version}', + variables: [new Partial\ServerVariable( + name: 'version', + default: '2.1', + enum: ['2.0', '2.1', '2.2'], + )] + )], + paths: [ + new Partial\PathItem( + path: '/first', + parameters: [new Partial\Parameter( + name: 'limit', + in: 'query', + required: false, + schema: new Partial\Schema( + type: ['integer', 'string'], + exclusiveMaximum: 8.21, + exclusiveMinimum: 6, + maximum: 8.3, + minimum: 5, + maxLength: 2, + minLength: 1, + pattern: '.*', + maxItems: 5, + minItems: 3, + uniqueItems: true, + maxContains: 5, + minContains: 2, + maxProperties: 6, + minProperties: 2, + required: ['property1', 'property2'], + dependentRequired: ['property2' => ['property3']], + not: new Partial\Schema(type: 'integer'), + ) + )], + get: new Partial\Operation( + operationId: 'first-get', + parameters: [new Partial\Parameter( + name: 'pet', + in: 'header', + required: true, + schema: null, + content: [new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema( + allOf: [ + new Partial\Schema(type: 'integer'), + new Partial\Schema(type: 'number') + ] + ) + )] + )], + responses: [ + '200' => new Partial\Response( + description: 'Successful Response', + ) + ], + ) + ), + new Partial\PathItem( + path: '/second', + servers: [new Partial\Server( + url: 'https://second-server.co.uk' + )], + parameters: [new Partial\Parameter( + name: 'limit', + in: 'query', + required: false, + schema: new Partial\Schema(type: 'integer') + )], + get: new Partial\Operation( + operationId: 'second-get', + parameters: [new Partial\Parameter( + name: 'pet', + in: 'header', + required: true, + schema: null, + content: [new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema( + allOf: [ + new Partial\Schema(type: 'integer'), + new Partial\Schema(type: 'number'), + ] + ) + )], + )], + responses: [ + '200' => new Partial\Response( + description: 'Successful Response', + ) + ], + ), + put: new Partial\Operation( + operationId: 'second-put', + servers: [new Partial\Server( + url: 'https://second-put.com' + )], + parameters: [new Partial\Parameter( + name: 'user', + in: 'cookie', + required: false, + schema: new Partial\Schema(type: 'object'), + )], + responses: [ + '200' => new Partial\Response( + description: 'Successful Response', + ) + ], + ) + ) + ] + )); + } +} From 634df317e7621d75be6e13b0cb4714ee89dde5ce Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 18:42:05 +0000 Subject: [PATCH 16/21] Fix type comparisons --- src/ValueObject/Valid/Enum/Type.php | 11 +++++++++++ src/ValueObject/Valid/V30/Keywords.php | 25 ++++++++++++++----------- src/ValueObject/Valid/V31/Keywords.php | 26 ++++++++++++++------------ 3 files changed, 39 insertions(+), 23 deletions(-) diff --git a/src/ValueObject/Valid/Enum/Type.php b/src/ValueObject/Valid/Enum/Type.php index 8f98c3a..19287ec 100644 --- a/src/ValueObject/Valid/Enum/Type.php +++ b/src/ValueObject/Valid/Enum/Type.php @@ -7,6 +7,7 @@ use Membrane\OpenAPIReader\Exception\InvalidOpenAPI; use Membrane\OpenAPIReader\OpenAPIVersion; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; +use Membrane\OpenAPIReader\ValueObject\Value; enum Type: string { @@ -78,4 +79,14 @@ public function isPrimitive(): bool default => true, }; } + + public function doesValueMatchType(Value $value): bool + { + $type = $value->getType(); + + return match ($type) { + Type::Integer => $this === Type::Integer || $this === Type::Number, + default => $type === $this, + }; + } } diff --git a/src/ValueObject/Valid/V30/Keywords.php b/src/ValueObject/Valid/V30/Keywords.php index ee56641..e5a43e0 100644 --- a/src/ValueObject/Valid/V30/Keywords.php +++ b/src/ValueObject/Valid/V30/Keywords.php @@ -237,13 +237,15 @@ private function reviewEnum( $enumContainsValidValue = false; foreach ($enum as $value) { - if (in_array($value->getType(), $types)) { - $enumContainsValidValue = true; - } else { - $this->addWarning( - "$value does not match allowed types", - Warning::MISLEADING, - ); + foreach ($types as $type) { + if ($type->doesValueMatchType($value)) { + $enumContainsValidValue = true; + } else { + $this->addWarning( + "$value does not match allowed types", + Warning::MISLEADING, + ); + } } } @@ -266,11 +268,12 @@ private function validateDefault(array $types, Value|null $default): Value|null return null; } - if (! in_array($default->getType(), $types)) { - throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); + foreach ($types as $type) { + if ($type->doesValueMatchType($default)) { + return $default; + } } - - return $default; + throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); } private function validateMultipleOf(float|int|null $value): float|int|null diff --git a/src/ValueObject/Valid/V31/Keywords.php b/src/ValueObject/Valid/V31/Keywords.php index d00be9c..5946e2a 100644 --- a/src/ValueObject/Valid/V31/Keywords.php +++ b/src/ValueObject/Valid/V31/Keywords.php @@ -12,7 +12,6 @@ use Membrane\OpenAPIReader\ValueObject\Valid\Enum\Type; use Membrane\OpenAPIReader\ValueObject\Valid\Exception\SchemaShouldBeBoolean; use Membrane\OpenAPIReader\ValueObject\Valid\Identifier; -use Membrane\OpenAPIReader\ValueObject\Valid\V30\Schema; use Membrane\OpenAPIReader\ValueObject\Valid\Validated; use Membrane\OpenAPIReader\ValueObject\Valid\Warning; use Membrane\OpenAPIReader\ValueObject\Value; @@ -228,13 +227,15 @@ private function reviewEnum( $enumContainsValidValue = false; foreach ($enum as $value) { - if (in_array($value->getType(), $types)) { - $enumContainsValidValue = true; - } else { - $this->addWarning( - "$value does not match allowed types", - Warning::MISLEADING, - ); + foreach ($types as $type) { + if ($type->doesValueMatchType($value)) { + $enumContainsValidValue = true; + } else { + $this->addWarning( + "$value does not match allowed types", + Warning::MISLEADING, + ); + } } } @@ -257,11 +258,12 @@ private function validateDefault(array $types, Value|null $default): Value|null return null; } - if (! in_array($default->getType(), $types)) { - throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); + foreach ($types as $type) { + if ($type->doesValueMatchType($default)) { + return $default; + } } - - return $default; + throw InvalidOpenAPI::defaultMustConformToType($this->getIdentifier()); } private function validateMultipleOf(float|int|null $value): float|int|null From 56790c3c27b52022c06537c2ce1aaa4586db38f0 Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 18:43:40 +0000 Subject: [PATCH 17/21] Rename constructor parameter for clarity Accidentally refactored it to $header previously. This commit changes it back to $parameter --- src/ValueObject/Valid/V31/Parameter.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/ValueObject/Valid/V31/Parameter.php b/src/ValueObject/Valid/V31/Parameter.php index 53562b7..29fff28 100644 --- a/src/ValueObject/Valid/V31/Parameter.php +++ b/src/ValueObject/Valid/V31/Parameter.php @@ -47,14 +47,14 @@ final class Parameter extends Validated public function __construct( Identifier $parentIdentifier, - Partial\Parameter $header + Partial\Parameter $parameter ) { - $this->name = $header->name ?? + $this->name = $parameter->name ?? throw InvalidOpenAPI::parameterMissingName($parentIdentifier); $this->in = $this->validateIn( $parentIdentifier, - $header->in, + $parameter->in, ); $identifier = $parentIdentifier->append($this->name, $this->in->value); @@ -63,24 +63,24 @@ public function __construct( $this->required = $this->validateRequired( $identifier, $this->in, - $header->required + $parameter->required ); - isset($header->schema) === empty($header->content) ?: + isset($parameter->schema) === empty($parameter->content) ?: throw InvalidOpenAPI::mustHaveSchemaXorContent($identifier); - if (isset($header->schema)) { + if (isset($parameter->schema)) { $this->content = []; $this->schema = new Schema( $this->appendedIdentifier('schema'), - $header->schema + $parameter->schema ); } else { $this->schema = null; $this->content = $this->validateContent( $this->getIdentifier(), - $header->content + $parameter->content ); } @@ -88,10 +88,10 @@ public function __construct( $identifier, $this->getSchema(), $this->in, - $header->style, + $parameter->style, ); - $this->explode = $header->explode ?? $this->style->defaultExplode(); + $this->explode = $parameter->explode ?? $this->style->defaultExplode(); } public function getSchema(): Schema From f99cb3c9a677fcf627e6e10770eb1389ebecbefa Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 18:53:20 +0000 Subject: [PATCH 18/21] Fix V31 Factory's exclusiveMinMax --- src/Factory/V31/FromCebe.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Factory/V31/FromCebe.php b/src/Factory/V31/FromCebe.php index 28d2c13..0dd8e35 100644 --- a/src/Factory/V31/FromCebe.php +++ b/src/Factory/V31/FromCebe.php @@ -154,8 +154,12 @@ enum: isset($schema->enum) ? default: isset($schema->default) ? new Value($schema->default) : null, nullable: $schema->nullable ?? false, multipleOf: $schema->multipleOf ?? null, - exclusiveMaximum: $schema->exclusiveMaximum ?? null, - exclusiveMinimum: $schema->exclusiveMinimum ?? null, + exclusiveMaximum: isset($schema->exclusiveMaximum) && $schema->exclusiveMaximum !== false ? + $schema->exclusiveMaximum : + null, + exclusiveMinimum: isset($schema->exclusiveMinimum) && $schema->exclusiveMinimum !== false ? + $schema->exclusiveMinimum : + null, maximum: $schema->maximum ?? null, minimum: $schema->minimum ?? null, maxLength: $schema->maxLength ?? null, From 46fd0f362a3759957a03a98d273dcca25f7c70a7 Mon Sep 17 00:00:00 2001 From: John Charman Date: Mon, 16 Dec 2024 18:55:22 +0000 Subject: [PATCH 19/21] Fix tests --- tests/MembraneReaderTest.php | 2 +- tests/fixtures/ProvidesTrainTravelApi.php | 1057 ++++++++++++++++++ tests/fixtures/train-travel-api.yaml | 1188 +++++++++++++++++++++ 3 files changed, 2246 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/ProvidesTrainTravelApi.php create mode 100644 tests/fixtures/train-travel-api.yaml diff --git a/tests/MembraneReaderTest.php b/tests/MembraneReaderTest.php index 371978a..434da0d 100644 --- a/tests/MembraneReaderTest.php +++ b/tests/MembraneReaderTest.php @@ -354,7 +354,7 @@ public function itReadsFromString( #[Test] #[DataProviderExternal(ProvidesPetstoreApi::class, 'provideOperations')] - //#[DataProviderExternal(ProvidesTrainTravelApi::class, 'provideOperations')] + #[DataProviderExternal(ProvidesTrainTravelApi::class, 'provideOperations')] public function itReadsRealExamples( string $filepath, string $path, diff --git a/tests/fixtures/ProvidesTrainTravelApi.php b/tests/fixtures/ProvidesTrainTravelApi.php new file mode 100644 index 0000000..328c19c --- /dev/null +++ b/tests/fixtures/ProvidesTrainTravelApi.php @@ -0,0 +1,1057 @@ + + */ + public static function provideOperations(): Generator + { + yield 'get-stations' => [ + self::API, + '/stations', + Method::GET, + self::getStations(), + ]; + + yield 'get-trips' => [ + self::API, + '/trips', + Method::GET, + self::getTrips(), + ]; + + yield 'get-bookings' => [ + self::API, + '/bookings', + Method::GET, + self::getBookings(), + ]; + + yield 'create-booking' => [ + self::API, + '/bookings', + Method::POST, + self::createBooking(), + ]; + + yield 'get-booking' => [ + self::API, + '/bookings/{bookingId}', + Method::GET, + self::getBooking(), + ]; + + yield 'delete-booking' => [ + self::API, + '/bookings/{bookingId}', + Method::DELETE, + self::deleteBooking(), + ]; + + yield 'create-booking-payment' => [ + self::API, + '/bookings/{bookingId}/payment', + Method::POST, + self::createBookingPayment(), + ]; + } + + private static function getStations(): V31\Operation + { + return V31\Operation::fromPartial( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/stations'), + pathServers: [new V31\Server( + new Identifier('Train Travel API(1.0.0)'), + new Partial\Server(url: 'https://api.example.com'), + )], + pathParameters: [], + method: Method::GET, + operation: new Partial\Operation( + operationId: 'get-stations', + servers: [], + parameters: [ + new Partial\Parameter( + name: 'page', + in: 'query', + schema: new Partial\Schema(type: 'integer', minimum: 1, default: new Value(1)), + ), + new Partial\Parameter( + name: 'coordinates', + in: 'query', + schema: new Partial\Schema(type: 'string'), + ), + new Partial\Parameter( + name: 'search', + in: 'query', + schema: new Partial\Schema(type: 'string'), + ), + ], + responses: [ + 200 => new Partial\Response( + description: 'OK', + headers: [ + 'Cache-Control' => self::refCacheControl(), + 'RateLimit' => self::refRateLimit(), + ], + content: [ + 'application/json' => new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema( + type: 'object', + properties: ['data' => new Partial\Schema( + type: 'array', + items: self::refStation(), + )] + ) + ), + 'application/xml' => new Partial\MediaType( + contentType: 'application/xml', + schema: new Partial\Schema(allOf: [ + self::refWrapperCollection(), + new Partial\Schema(properties: [ + 'data' => new Partial\Schema( + type: 'array', + items: self::refStation(), + ), + ]), + new Partial\Schema(properties: [ + 'links' => new Partial\Schema(allOf: [ + self::refLinksSelf(), + self::refLinksPagination(), + ]), + ]), + ]) + ) + ] + ), + '400' => self::refBadRequest(), + '401' => self::refUnauthorized(), + '403' => self::refForbidden(), + '429' => self::refTooManyRequests(), + '500' => self::refInternalServerError(), + ] + ), + ); + } + + private static function getTrips(): V31\Operation + { + return V31\Operation::fromPartial( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/trips'), + pathServers: [new V31\Server( + new Identifier('Train Travel API(1.0.0)'), + new Partial\Server(url: 'https://api.example.com'), + )], + pathParameters: [], + method: Method::GET, + operation: new Partial\Operation( + operationId: 'get-trips', + servers: [], + parameters: [ + new Partial\Parameter( + name: 'origin', + in: 'query', + required: true, + schema: new Partial\Schema(type: 'string', format: 'uuid'), + ), + new Partial\Parameter( + name: 'destination', + in: 'query', + required: true, + schema: new Partial\Schema(type: 'string', format: 'uuid'), + ), + new Partial\Parameter( + name: 'date', + in: 'query', + required: true, + schema: new Partial\Schema(type: 'string', format: 'date-time'), + ), + new Partial\Parameter( + name: 'bicycles', + in: 'query', + schema: new Partial\Schema(type: 'boolean', default: new Value(false)), + ), + new Partial\Parameter( + name: 'dogs', + in: 'query', + schema: new Partial\Schema(type: 'boolean', default: new Value(false)), + ), + new Partial\Parameter( + name: 'page', + in: 'query', + schema: new Partial\Schema(type: 'number', default: new Value(1)), + ), + ], + responses: [ + 200 => new Partial\Response( + description: 'A list of available train trips', + headers: [ + 'Cache-Control' => self::refCacheControl(), + 'RateLimit' => self::refRateLimit(), + ], + content: [ + 'application/json' => new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema(allOf: [ + self::refWrapperCollection(), + new Partial\Schema(properties: [ + 'data' => new Partial\Schema( + type: 'array', + items: self::refTrip(), + ), + ]), + new Partial\Schema(properties: [ + 'links' => new Partial\Schema(allOf: [ + self::refLinksSelf(), + self::refLinksPagination(), + ]), + ]), + ]) + ), + 'application/xml' => new Partial\MediaType( + contentType: 'application/xml', + schema: new Partial\Schema(allOf: [ + self::refWrapperCollection(), + new Partial\Schema(properties: [ + 'data' => new Partial\Schema( + type: 'array', + items: self::refTrip(), + ), + ]), + new Partial\Schema(properties: [ + 'links' => new Partial\Schema(allOf: [ + self::refLinksSelf(), + self::refLinksPagination(), + ]), + ]), + ]) + ) + ], + ), + '400' => self::refBadRequest(), + '401' => self::refUnauthorized(), + '403' => self::refForbidden(), + '429' => self::refTooManyRequests(), + '500' => self::refInternalServerError(), + ] + ), + ); + } + + private static function getBookings(): V31\Operation + { + return V31\Operation::fromPartial( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/bookings'), + pathServers: [new V31\Server( + new Identifier('Train Travel API(1.0.0)'), + new Partial\Server(url: 'https://api.example.com'), + )], + pathParameters: [], + method: Method::GET, + operation: new Partial\Operation( + operationId: 'get-bookings', + servers: [], + parameters: [ + new Partial\Parameter( + name: 'page', + in: 'query', + schema: new Partial\Schema( + type: 'integer', + minimum: 1, + default: new Value(1), + ), + ), + ], + responses: [ + 200 => new Partial\Response( + description: 'A list of bookings', + headers: [ + 'Cache-Control' => self::refCacheControl(), + 'RateLimit' => self::refRateLimit(), + ], + content: [ + 'application/json' => new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema(allOf: [ + self::refWrapperCollection(), + new Partial\Schema(properties: [ + 'data' => new Partial\Schema( + type: 'array', + items: self::refBooking(), + ), + ]), + new Partial\Schema(properties: [ + 'links' => new Partial\Schema(allOf: [ + self::refLinksSelf(), + self::refLinksPagination(), + ]), + ]), + ]) + ), + 'application/xml' => new Partial\MediaType( + contentType: 'application/xml', + schema: new Partial\Schema(allOf: [ + self::refWrapperCollection(), + new Partial\Schema(properties: [ + 'data' => new Partial\Schema( + type: 'array', + items: self::refBooking(), + ), + ]), + new Partial\Schema(properties: [ + 'links' => new Partial\Schema(allOf: [ + self::refLinksSelf(), + self::refLinksPagination(), + ]), + ]), + ]) + ) + ] + ), + '400' => self::refBadRequest(), + '401' => self::refUnauthorized(), + '403' => self::refForbidden(), + '429' => self::refTooManyRequests(), + '500' => self::refInternalServerError(), + ], + ), + ); + } + + private static function createBooking(): V31\Operation + { + return V31\Operation::fromPartial( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/bookings'), + pathServers: [new V31\Server( + new Identifier('Train Travel API(1.0.0)'), + new Partial\Server(url: 'https://api.example.com'), + )], + pathParameters: [], + method: Method::POST, + operation: new Partial\Operation( + operationId: 'create-booking', + servers: [], + parameters: [], + requestBody: new Partial\RequestBody( + content: [ + new Partial\MediaType( + contentType: 'application/json', + schema: self::refBooking(), + ), + new Partial\MediaType( + contentType: 'application/xml', + schema: self::refBooking(), + ), + ], + required: true, + ), + responses: [ + 201 => new Partial\Response( + description: 'Booking successful', + content: [ + 'application/json' => new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema(allOf: [ + self::refBooking(), + new Partial\Schema(properties: [ + 'links' => self::refLinksSelf(), + ]), + ]), + ), + 'application/xml' => new Partial\MediaType( + contentType: 'application/xml', + schema: new Partial\Schema(allOf: [ + self::refBooking(), + new Partial\Schema(properties: [ + 'links' => self::refLinksSelf(), + ]), + ]), + ) + ] + ), + '400' => self::refBadRequest(), + '401' => self::refUnauthorized(), + '404' => self::refNotFound(), + '409' => self::refConflict(), + '429' => self::refTooManyRequests(), + '500' => self::refInternalServerError(), + ], + ) + ); + } + + private static function getBooking(): V31\Operation + { + return V31\Operation::fromPartial( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/bookings/{bookingId}'), + pathServers: [new V31\Server( + new Identifier('Train Travel API(1.0.0)'), + new Partial\Server(url: 'https://api.example.com'), + )], + pathParameters: [new V31\Parameter( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/bookings/{bookingId}'), + parameter:new Partial\Parameter( + name: 'bookingId', + in: 'path', + required: true, + schema: new Partial\Schema(type: 'string', format: 'uuid'), + ) + )], + method: Method::GET, + operation: new Partial\Operation( + operationId: 'get-booking', + servers: [], + parameters: [], + responses: [ + 200 => new Partial\Response( + description: 'The booking details', + headers: [ + 'Cache-Control' => self::refCacheControl(), + 'RateLimit' => self::refRateLimit(), + ], + content: [ + 'application/json' => new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema(allOf: [ + self::refBooking(), + new Partial\Schema(properties: [ + 'links' => self::refLinksSelf(), + ]), + ]) + ), + 'application/xml' => new Partial\MediaType( + contentType: 'application/xml', + schema: new Partial\Schema(allOf: [ + self::refBooking(), + new Partial\Schema(properties: [ + 'links' => self::refLinksSelf(), + ]), + ]), + ) + ], + ), + '400' => self::refBadRequest(), + '401' => self::refUnauthorized(), + '403' => self::refForbidden(), + '404' => self::refNotFound(), + '429' => self::refTooManyRequests(), + '500' => self::refInternalServerError(), + ], + ) + ); + } + + private static function deleteBooking(): V31\Operation + { + return V31\Operation::fromPartial( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/bookings/{bookingId}'), + pathServers: [new V31\Server( + new Identifier('Train Travel API(1.0.0)'), + new Partial\Server(url: 'https://api.example.com'), + )], + pathParameters: [new V31\Parameter( + parentIdentifier: new Identifier('Train Travel API(1.0.0)', '/bookings/{bookingId}'), + parameter:new Partial\Parameter( + name: 'bookingId', + in: 'path', + required: true, + schema: new Partial\Schema(type: 'string', format: 'uuid'), + ) + )], + method: Method::DELETE, + operation: new Partial\Operation( + operationId: 'delete-booking', + servers: [], + parameters: [], + responses: [ + 204 => new Partial\Response( + description: 'Booking deleted', + ), + '400' => self::refBadRequest(), + '401' => self::refUnauthorized(), + '403' => self::refForbidden(), + '404' => self::refNotFound(), + '429' => self::refTooManyRequests(), + '500' => self::refInternalServerError(), + ], + ) + ); + } + + private static function createBookingPayment(): V31\Operation + { + return V31\Operation::fromPartial( + parentIdentifier: new Identifier( + 'Train Travel API(1.0.0)', + '/bookings/{bookingId}/payment' + ), + pathServers: [new V31\Server( + new Identifier('Train Travel API(1.0.0)'), + new Partial\Server(url: 'https://api.example.com'), + )], + pathParameters: [new V31\Parameter( + parentIdentifier: new Identifier( + 'Train Travel API(1.0.0)', + '/bookings/{bookingId}/payment' + ), + parameter:new Partial\Parameter( + name: 'bookingId', + in: 'path', + required: true, + schema: new Partial\Schema(type: 'string', format: 'uuid'), + ) + )], + method: Method::POST, + operation: new Partial\Operation( + operationId: 'create-booking-payment', + servers: [], + parameters: [], + requestBody: new Partial\RequestBody( + content: [ + new Partial\MediaType( + contentType: 'application/json', + schema: self::refBookingPayment(), + ), + ], + required: true, + ), + responses: [ + 200 => new Partial\Response( + description: 'Payment successful', + headers: [ + 'Cache-Control' => self::refCacheControl(), + 'RateLimit' => self::refRateLimit(), + ], + content: [ + 'application/json' => new Partial\MediaType( + contentType: 'application/json', + schema: new Partial\Schema(allOf: [ + self::refBookingPayment(), + new Partial\Schema( + properties: [ + 'links' => self::refLinksBooking() + ], + ) + ]), + ) + ] + ), + '400' => self::refBadRequest(), + '401' => self::refUnauthorized(), + '403' => self::refForbidden(), + '429' => self::refTooManyRequests(), + '500' => self::refInternalServerError(), + ], + ) + ); + } + + + private static function refBadRequest(): Partial\Response + { + return new Partial\Response( + description: 'Bad Request', + headers: ['RateLimit' => self::refRateLimit()], + content: [ + 'application/problem+json' => new Partial\MediaType( + contentType: 'application/problem+json', + schema: self::refProblem(), + ), + 'application/problem+xml' => new Partial\MediaType( + contentType: 'application/problem+xml', + schema: self::refProblem(), + ), + ] + ); + } + + private static function refBooking(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'id' => new Partial\Schema( + type: 'string', + format: 'uuid', + description: 'Unique identifier for the booking', + ), + 'trip_id' => new Partial\Schema( + type: 'string', + format: 'uuid', + description: 'Identifier of the booked trip', + ), + 'passenger_name' => new Partial\Schema( + type: 'string', + description: 'Name of the passenger', + ), + 'has_bicycle' => new Partial\Schema( + type: 'boolean', + description: 'Indicates whether the passenger has a bicycle.', + ), + 'has_dog' => new Partial\Schema( + type: 'boolean', + description: 'Indicates whether the passenger has a dog.', + ), + ], + ); + } + + private static function refBookingPayment(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'id' => new Partial\Schema( + type: 'string', + format: 'uuid', + description: 'Unique identifier for the payment. This will be a unique identifier for the payment, and is used to reference the payment in other objects.', + ), + 'amount' => new Partial\Schema( + type: 'number', + exclusiveMinimum: 0, + description: 'Amount intended to be collected by this payment. A positive decimal figure describing the amount to be collected.', + ), + 'currency' => new Partial\Schema( + type: 'string', + enum: [ + new Value('bam'), + new Value('bgn'), + new Value('chf'), + new Value('eur'), + new Value('gbp'), + new Value('nok'), + new Value('sek'), + new Value('try'), + ], + description: 'Three-letter [ISO currency code](https://www.iso.org/iso-4217-currency-codes.html), in lowercase.', + ), + 'source' => new Partial\Schema( + oneOf: [ + new Partial\Schema( + type: 'object', + required: [ + 'name', + 'number', + 'cvc', + 'exp_month', + 'exp_year', + 'address_country', + ], + properties:[ + 'object' => new Partial\Schema( + type: 'string', + ), + 'name' => new Partial\Schema( + type: 'string', + description: 'Cardholder\'s full name as it appears on the card.', + ), + 'number' => new Partial\Schema( + type: 'string', + description: 'The card number, as a string without any separators. On read all but the last four digits will be masked for security.', + ), + 'cvc' => new Partial\Schema( + type: 'integer', + maxLength: 4, + minLength: 3, + description: 'Card security code, 3 or 4 digits usually found on the back of the card.', + ), + 'exp_month' => new Partial\Schema( + type: 'integer', + format: 'int64', + description: 'Two-digit number representing the card\'s expiration month.', + ), + 'exp_year' => new Partial\Schema( + type: 'integer', + format: 'int64', + description: 'Four-digit number representing the card\'s expiration year.' + ), + 'address_line1' => new Partial\Schema(type: 'string'), + 'address_line2' => new Partial\Schema(type: 'string'), + 'address_city' => new Partial\Schema(type: 'string'), + 'address_country' => new Partial\Schema(type: 'string'), + 'address_post_code' => new Partial\Schema(type: 'string'), + ], + title: 'Card', + description: 'A card (debit or credit) to take payment from.', + ), + new Partial\Schema( + type: ['object'], + required: [ + 'name', + 'number', + 'account_type', + 'bank_name', + 'country', + ], + properties: [ + 'object' => new Partial\Schema(type: 'string'), + 'name' => new Partial\Schema(type: 'string'), + 'number' => new Partial\Schema( + type: 'string', + description: 'The account number for the bank account, in string form. Must be a current account.', + ), + 'sort_code' => new Partial\Schema( + type: 'string', + description: 'The sort code for the bank account, in string form. Must be a six-digit number.', + ), + 'account_type' => new Partial\Schema( + type: 'string', + enum: [new Value('individual'), new Value('company')], + description: 'The type of entity that holds the account. This can be either `individual` or `company`.', + ), + 'bank_name' => new Partial\Schema( + type: 'string', + description: 'The name of the bank associated with the routing number.', + ), + 'country' => new Partial\Schema( + type: 'string', + description: 'Two-letter country code (ISO 3166-1 alpha-2).', + ), + ], + title: 'Bank Account', + description: 'A bank account to take payment from. ' + . 'Must be able to make payments in the currency specified in the payment.', + ), + ], + description: 'The payment source to take the payment from. ' + . 'This can be a card or a bank account. ' + . 'Some of these properties will be hidden on read to protect PII leaking.', + ), + 'status' => new Partial\Schema( + type: 'string', + enum: [ + new Value('pending'), + new Value('succeeded'), + new Value('failed'), + ], + description: 'The status of the payment, ' + . 'one of `pending`, `succeeded`, or `failed`.', + ), + ], + ); + } + + private static function refCacheControl(): Partial\Header + { + return new Partial\Header( + schema: new Partial\Schema( + type: 'string', + description: 'A comma-separated list of directives as defined in [RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html).', + ), + ); + } + + private static function refConflict(): Partial\Response + { + return new Partial\Response( + description: 'Conflict', + headers: ['RateLimit' => self::refRateLimit()], + content: [ + 'application/problem+json' => new Partial\MediaType( + contentType: 'application/problem+json', + schema: self::refProblem(), + ), + 'application/problem+xml' => new Partial\MediaType( + contentType: 'application/problem+xml', + schema: self::refProblem(), + ), + ] + ); + } + + private static function refForbidden(): Partial\Response + { + return new Partial\Response( + description: 'Forbidden', + headers: ['RateLimit' => self::refRateLimit()], + content: [ + 'application/problem+json' => new Partial\MediaType( + contentType: 'application/problem+json', + schema: self::refProblem(), + ), + 'application/problem+xml' => new Partial\MediaType( + contentType: 'application/problem+xml', + schema: self::refProblem(), + ), + ] + ); + } + + private static function refInternalServerError(): Partial\Response + { + return new Partial\Response( + description: 'Internal Server Error', + headers: ['RateLimit' => self::refRateLimit()], + content: [ + 'application/problem+json' => new Partial\MediaType( + contentType: 'application/problem+json', + schema: self::refProblem(), + ), + 'application/problem+xml' => new Partial\MediaType( + contentType: 'application/problem+xml', + schema: self::refProblem(), + ), + ] + ); + } + + private static function refLinksBooking(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'booking' => new Partial\Schema(type: 'string', format: 'uri'), + ], + ); + } + + private static function refLinksPagination(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'next' => new Partial\Schema(type: 'string', format: 'uri'), + 'prev' => new Partial\Schema(type: 'string', format: 'uri'), + ], + ); + } + + private static function refLinksSelf(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'self' => new Partial\Schema(type: 'string', format: 'uri'), + ], + ); + } + + private static function refNotFound(): Partial\Response + { + return new Partial\Response( + description: 'Not Found', + headers: ['RateLimit' => self::refRateLimit()], + content: [ + 'application/problem+json' => new Partial\MediaType( + contentType: 'application/problem+json', + schema: self::refProblem(), + ), + 'application/problem+xml' => new Partial\MediaType( + contentType: 'application/problem+xml', + schema: self::refProblem(), + ), + ] + ); + } + + private static function refProblem(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'type' => new Partial\Schema( + type: 'string', + description: 'A URI reference that identifies the problem type', + ), + 'title' => new Partial\Schema( + type: 'string', + description: 'A short, human-readable summary of the problem type', + ), + 'detail' => new Partial\Schema( + type: 'string', + description: 'A human-readable explanation specific to this occurrence of the problem', + ), + 'instance' => new Partial\Schema( + type: 'string', + description: 'A URI reference that identifies the specific occurrence of the problem', + ), + 'status' => new Partial\Schema( + type: 'integer', + description: 'The HTTP status code', + ), + ], + ); + } + + private static function refRateLimit(): Partial\Header + { + return new Partial\Header( + description: << new Partial\Schema( + type: 'string', + format: 'uuid', + description: 'Unique identifier for the station.', + ), + 'name' => new Partial\Schema( + type: 'string', + description: 'The name of the station', + ), + 'address' => new Partial\Schema( + type: 'string', + description: 'The address of the station.', + ), + 'country_code' => new Partial\Schema( + type: 'string', + format: 'iso-country-code', + description: 'The country code of the station.', + ), + 'timezone' => new Partial\Schema( + type: 'string', + description: 'The timezone of the station in the [IANA Time Zone Database format](https://www.iana.org/time-zones).', + ), + ], + ); + } + + private static function refTrip(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'id' => new Partial\Schema( + type: 'string', + format: 'uuid', + description: 'Unique identifier for the trip', + ), + 'origin' => new Partial\Schema( + type: 'string', + description: 'The starting station of the trip', + ), + 'destination' => new Partial\Schema( + type: 'string', + description: 'The destination station of the trip', + ), + 'departure_time' => new Partial\Schema( + type: 'string', + format: 'date-time', + description: 'The date and time when the trip departs', + ), + 'arrival_time' => new Partial\Schema( + type: 'string', + format: 'date-time', + description: 'The date and time when the trip arrives', + ), + 'operator' => new Partial\Schema( + type: 'string', + description: 'The name of the operator of the trip', + ), + 'price' => new Partial\Schema( + type: 'number', + description: 'The cost of the trip', + ), + 'bicycles_allowed' => new Partial\Schema( + type: 'boolean', + description: 'Indicates whether bicycles are allowed on the trip', + ), + 'dogs_allowed' => new Partial\Schema( + type: 'boolean', + description: 'Indicates whether dogs are allowed on the trip', + ), + ], + ); + } + + private static function refTooManyRequests(): Partial\Response + { + return new Partial\Response( + description: 'Too Many Requests', + headers: [ + 'RateLimit' => self::refRateLimit(), + 'Retry-After' => self::refRetryAfter(), + ], + content: [ + 'application/problem+json' => new Partial\MediaType( + contentType: 'application/problem+json', + schema: self::refProblem(), + ), + 'application/problem+xml' => new Partial\MediaType( + contentType: 'application/problem+xml', + schema: self::refProblem(), + ), + ] + ); + } + + private static function refUnauthorized(): Partial\Response + { + return new Partial\Response( + description: 'Unauthorized', + headers: ['RateLimit' => self::refRateLimit()], + content: [ + 'application/problem+json' => new Partial\MediaType( + contentType: 'application/problem+json', + schema: self::refProblem(), + ), + 'application/problem+xml' => new Partial\MediaType( + contentType: 'application/problem+xml', + schema: self::refProblem(), + ), + ] + ); + } + + private static function refWrapperCollection(): Partial\Schema + { + return new Partial\Schema( + type: 'object', + properties: [ + 'data' => new Partial\Schema( + type: 'array', + items: new Partial\Schema(type: 'object'), + description: 'The wrapper for a collection is an array of objects.', + ), + 'links' => new Partial\Schema( + type: 'object', + description: 'A set of hypermedia links which serve as controls for the client.', + ), + ], + description: 'This is a generic request/response wrapper which contains both data and links which serve as hypermedia controls (HATEOAS).', + ); + } +} diff --git a/tests/fixtures/train-travel-api.yaml b/tests/fixtures/train-travel-api.yaml new file mode 100644 index 0000000..49b86c3 --- /dev/null +++ b/tests/fixtures/train-travel-api.yaml @@ -0,0 +1,1188 @@ +openapi: 3.1.0 +info: + title: Train Travel API + description: | + API for finding and booking train trips across Europe. + + ## Run in Postman + + Experiment with this API in Postman, using our Postman Collection. + + [Run In Postman](https://app.getpostman.com/run-collection/9265903-7a75a0d0-b108-4436-ba54-c6139698dc08?action=collection%2Ffork&source=rip_markdown&collection-url=entityId%3D9265903-7a75a0d0-b108-4436-ba54-c6139698dc08%26entityType%3Dcollection%26workspaceId%3Df507f69d-9564-419c-89a2-cb8e4c8c7b8f) + version: 1.0.0 + contact: + name: Train Support + url: https://example.com/support + email: support@example.com + license: + name: Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International + identifier: CC-BY-NC-SA-4.0 + x-feedbackLink: + label: Submit Feedback + url: https://github.com/bump-sh-examples/train-travel-api/issues/new + +servers: + - url: https://api.example.com + description: Production + +security: + - OAuth2: + - read + +x-topics: + - title: Getting started + content: + $ref: ./docs/getting-started.md + +tags: + - name: Stations + description: | + Find and filter train stations across Europe, including their location + and local timezone. + - name: Trips + description: | + Timetables and routes for train trips between stations, including pricing + and availability. + - name: Bookings + description: | + Create and manage bookings for train trips, including passenger details + and optional extras. + - name: Payments + description: | + Pay for bookings using a card or bank account, and view payment + status and history. + + > warn + > Bookings usually expire within 1 hour so you'll need to make your payment + > before the expiry date + +paths: + /stations: + get: + summary: Get a list of train stations + description: Returns a paginated and searchable list of all train stations. + operationId: get-stations + tags: + - Stations + parameters: + - name: page + in: query + description: The page number to return + required: false + schema: + type: integer + minimum: 1 + default: 1 + example: 1 + - name: coordinates + in: query + description: > + The latitude and longitude of the user's location, to narrow down + the search results to sites within a proximity of this location. + required: false + schema: + type: string + example: 52.5200,13.4050 + - name: search + in: query + description: > + A search term to filter the list of stations by name or address. + required: false + schema: + type: string + examples: + - Milano Centrale + - Paris + responses: + '200': + description: OK + headers: + Cache-Control: + $ref: '#/components/headers/Cache-Control' + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/Station' + example: + data: + - id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + name: Berlin Hauptbahnhof + address: Invalidenstraße 10557 Berlin, Germany + country_code: DE + timezone: Europe/Berlin + - id: b2e783e1-c824-4d63-b37a-d8d698862f1d + name: Paris Gare du Nord + address: 18 Rue de Dunkerque 75010 Paris, France + country_code: FR + timezone: Europe/Paris + links: + self: https://api.example.com/stations&page=2 + next: https://api.example.com/stations?page=3 + prev: https://api.example.com/stations?page=1 + application/xml: + schema: + allOf: + - $ref: '#/components/schemas/Wrapper-Collection' + - properties: + data: + type: array + xml: + name: stations + wrapped: true + items: + $ref: '#/components/schemas/Station' + - properties: + links: + allOf: + - $ref: '#/components/schemas/Links-Self' + - $ref: '#/components/schemas/Links-Pagination' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + /trips: + get: + summary: Get available train trips + description: > + Returns a list of available train trips between the specified origin and + destination stations on the given date, and allows for filtering by + bicycle and dog allowances. + operationId: get-trips + tags: + - Trips + parameters: + - name: origin + in: query + description: The ID of the origin station + required: true + schema: + type: string + format: uuid + example: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + - name: destination + in: query + description: The ID of the destination station + required: true + schema: + type: string + format: uuid + example: b2e783e1-c824-4d63-b37a-d8d698862f1d + - name: date + in: query + description: The date and time of the trip in ISO 8601 format in origin station's timezone. + required: true + schema: + type: string + format: date-time + example: '2024-02-01T09:00:00Z' + - name: bicycles + in: query + description: Only return trips where bicycles are known to be allowed + required: false + schema: + type: boolean + default: false + - name: dogs + in: query + description: Only return trips where dogs are known to be allowed + required: false + schema: + type: boolean + default: false + - name: page + in: query + description: The page of results to be returned + required: false + schema: + type: number + default: 1 + responses: + '200': + description: A list of available train trips + headers: + Cache-Control: + $ref: '#/components/headers/Cache-Control' + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Wrapper-Collection' + - properties: + data: + type: array + items: + $ref: '#/components/schemas/Trip' + - properties: + links: + allOf: + - $ref: '#/components/schemas/Links-Self' + - $ref: '#/components/schemas/Links-Pagination' + example: + data: + - id: ea399ba1-6d95-433f-92d1-83f67b775594 + origin: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + destination: b2e783e1-c824-4d63-b37a-d8d698862f1d + departure_time: '2024-02-01T10:00:00Z' + arrival_time: '2024-02-01T16:00:00Z' + price: 50 + operator: Deutsche Bahn + bicycles_allowed: true + dogs_allowed: true + - id: 4d67459c-af07-40bb-bb12-178dbb88e09f + origin: b2e783e1-c824-4d63-b37a-d8d698862f1d + destination: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + departure_time: '2024-02-01T12:00:00Z' + arrival_time: '2024-02-01T18:00:00Z' + price: 50 + operator: SNCF + bicycles_allowed: true + dogs_allowed: true + links: + self: https://api.example.com/trips?origin=efdbb9d1-02c2-4bc3-afb7-6788d8782b1e&destination=b2e783e1-c824-4d63-b37a-d8d698862f1d&date=2024-02-01 + next: https://api.example.com/trips?origin=efdbb9d1-02c2-4bc3-afb7-6788d8782b1e&destination=b2e783e1-c824-4d63-b37a-d8d698862f1d&date=2024-02-01&page=2 + application/xml: + schema: + allOf: + - $ref: '#/components/schemas/Wrapper-Collection' + - properties: + data: + type: array + xml: + name: trips + wrapped: true + items: + $ref: '#/components/schemas/Trip' + - properties: + links: + allOf: + - $ref: '#/components/schemas/Links-Self' + - $ref: '#/components/schemas/Links-Pagination' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + /bookings: + get: + operationId: get-bookings + summary: List existing bookings + description: Returns a list of all trip bookings by the authenticated user. + tags: + - Bookings + parameters: + - name: page + in: query + description: The page number to return + required: false + schema: + type: integer + minimum: 1 + default: 1 + example: 1 + responses: + '200': + description: A list of bookings + headers: + Cache-Control: + $ref: '#/components/headers/Cache-Control' + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Wrapper-Collection' + - properties: + data: + type: array + items: + $ref: '#/components/schemas/Booking' + - properties: + links: + allOf: + - $ref: '#/components/schemas/Links-Self' + - $ref: '#/components/schemas/Links-Pagination' + example: + data: + - id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + trip_id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + passenger_name: John Doe + has_bicycle: true + has_dog: true + - id: b2e783e1-c824-4d63-b37a-d8d698862f1d + trip_id: b2e783e1-c824-4d63-b37a-d8d698862f1d + passenger_name: Jane Smith + has_bicycle: false + has_dog: false + links: + self: https://api.example.com/bookings + next: https://api.example.com/bookings?page=2 + application/xml: + schema: + allOf: + - $ref: '#/components/schemas/Wrapper-Collection' + - properties: + data: + type: array + xml: + name: bookings + wrapped: true + items: + $ref: '#/components/schemas/Booking' + - properties: + links: + allOf: + - $ref: '#/components/schemas/Links-Self' + - $ref: '#/components/schemas/Links-Pagination' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + post: + operationId: create-booking + summary: Create a booking + description: A booking is a temporary hold on a trip. It is not confirmed until the payment is processed. + tags: + - Bookings + security: + - OAuth2: + - write + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Booking' + application/xml: + schema: + $ref: '#/components/schemas/Booking' + responses: + '201': + description: Booking successful + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Booking' + - properties: + links: + $ref: '#/components/schemas/Links-Self' + + example: + id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + trip_id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + passenger_name: John Doe + has_bicycle: true + has_dog: true + links: + self: https://api.example.com/bookings/efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + application/xml: + schema: + allOf: + - $ref: '#/components/schemas/Booking' + - properties: + links: + $ref: '#/components/schemas/Links-Self' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/Conflict' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + + /bookings/{bookingId}: + parameters: + - name: bookingId + in: path + required: true + description: The ID of the booking to retrieve. + schema: + type: string + format: uuid + example: 1725ff48-ab45-4bb5-9d02-88745177dedb + get: + summary: Get a booking + description: Returns the details of a specific booking. + operationId: get-booking + tags: + - Bookings + responses: + '200': + description: The booking details + headers: + Cache-Control: + $ref: '#/components/headers/Cache-Control' + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Booking' + - properties: + links: + $ref: '#/components/schemas/Links-Self' + example: + id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + trip_id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + passenger_name: John Doe + has_bicycle: true + has_dog: true + links: + self: https://api.example.com/bookings/1725ff48-ab45-4bb5-9d02-88745177dedb + application/xml: + schema: + allOf: + - $ref: '#/components/schemas/Booking' + - properties: + links: + $ref: '#/components/schemas/Links-Self' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + delete: + summary: Delete a booking + description: Deletes a booking, cancelling the hold on the trip. + operationId: delete-booking + security: + - OAuth2: + - write + tags: + - Bookings + responses: + '204': + description: Booking deleted + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' + + /bookings/{bookingId}/payment: + parameters: + - name: bookingId + in: path + required: true + description: The ID of the booking to pay for. + schema: + type: string + format: uuid + example: 1725ff48-ab45-4bb5-9d02-88745177dedb + post: + summary: Pay for a Booking + description: A payment is an attempt to pay for the booking, which will confirm the booking for the user and enable them to get their tickets. + operationId: create-booking-payment + tags: + - Payments + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BookingPayment' + examples: + Card: + summary: Card Payment + value: + amount: 49.99 + currency: gbp + source: + object: card + name: J. Doe + number: '4242424242424242' + cvc: 123 + exp_month: 12 + exp_year: 2025 + address_line1: 123 Fake Street + address_line2: 4th Floor + address_city: London + address_country: gb + address_post_code: N12 9XX + Bank: + summary: Bank Account Payment + value: + amount: 100.5 + currency: gbp + source: + object: bank_account + name: J. Doe + number: '00012345' + sort_code: '000123' + account_type: individual + bank_name: Starling Bank + country: gb + responses: + '200': + description: Payment successful + headers: + Cache-Control: + $ref: '#/components/headers/Cache-Control' + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/BookingPayment' + - properties: + links: + $ref: '#/components/schemas/Links-Booking' + examples: + Card: + summary: Card Payment + value: + id: 2e3b4f5a-6b7c-8d9e-0f1a-2b3c4d5e6f7a + amount: 49.99 + currency: gbp + source: + object: card + name: J. Doe + number: '************4242' + cvc: 123 + exp_month: 12 + exp_year: 2025 + address_country: gb + address_post_code: N12 9XX + status: succeeded + links: + booking: https://api.example.com/bookings/1725ff48-ab45-4bb5-9d02-88745177dedb/payment + Bank: + summary: Bank Account Payment + value: + id: 2e3b4f5a-6b7c-8d9e-0f1a-2b3c4d5e6f7a + amount: 100.5 + currency: gbp + source: + object: bank_account + name: J. Doe + account_type: individual + number: '*********2345' + sort_code: '000123' + bank_name: Starling Bank + country: gb + status: succeeded + links: + booking: https://api.example.com/bookings/1725ff48-ab45-4bb5-9d02-88745177dedb + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '429': + $ref: '#/components/responses/TooManyRequests' + '500': + $ref: '#/components/responses/InternalServerError' +webhooks: + newBooking: + post: + operationId: new-booking + summary: New Booking + description: | + Subscribe to new bookings being created, to update integrations for your users. Related data is available via the links provided in the request. + tags: + - Bookings + requestBody: + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/Booking' + - properties: + links: + allOf: + - $ref: '#/components/schemas/Links-Self' + - $ref: '#/components/schemas/Links-Pagination' + example: + id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + trip_id: efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + passenger_name: John Doe + has_bicycle: true + has_dog: true + links: + self: https://api.example.com/bookings/1725ff48-ab45-4bb5-9d02-88745177dedb + responses: + '200': + description: Return a 200 status to indicate that the data was received successfully. + +components: + securitySchemes: + OAuth2: + type: oauth2 + description: OAuth 2.0 authorization code following RFC8725 best practices. + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Read access + write: Write access + schemas: + Station: + type: object + xml: + name: station + required: + - id + - name + - address + - country_code + properties: + id: + type: string + format: uuid + description: Unique identifier for the station. + examples: + - efdbb9d1-02c2-4bc3-afb7-6788d8782b1e + - b2e783e1-c824-4d63-b37a-d8d698862f1d + name: + type: string + description: The name of the station + examples: + - Berlin Hauptbahnhof + - Paris Gare du Nord + address: + type: string + description: The address of the station. + examples: + - Invalidenstraße 10557 Berlin, Germany + - 18 Rue de Dunkerque 75010 Paris, France + country_code: + type: string + description: The country code of the station. + format: iso-country-code + examples: + - DE + - FR + timezone: + type: string + description: The timezone of the station in the [IANA Time Zone Database format](https://www.iana.org/time-zones). + examples: + - Europe/Berlin + - Europe/Paris + Links-Self: + type: object + properties: + self: + type: string + format: uri + Links-Pagination: + type: object + properties: + next: + type: string + format: uri + prev: + type: string + format: uri + Problem: + type: object + xml: + name: problem + namespace: urn:ietf:rfc:7807 + properties: + type: + type: string + description: A URI reference that identifies the problem type + example: https://example.com/probs/out-of-credit + title: + type: string + description: A short, human-readable summary of the problem type + example: You do not have enough credit. + detail: + type: string + description: A human-readable explanation specific to this occurrence of the problem + example: Your current balance is 30, but that costs 50. + instance: + type: string + description: A URI reference that identifies the specific occurrence of the problem + example: /account/12345/msgs/abc + status: + type: integer + description: The HTTP status code + example: 400 + Trip: + type: object + xml: + name: trip + properties: + id: + type: string + format: uuid + description: Unique identifier for the trip + examples: + - 4f4e4e1-c824-4d63-b37a-d8d698862f1d + origin: + type: string + description: The starting station of the trip + examples: + - Berlin Hauptbahnhof + - Paris Gare du Nord + destination: + type: string + description: The destination station of the trip + examples: + - Paris Gare du Nord + - Berlin Hauptbahnhof + departure_time: + type: string + format: date-time + description: The date and time when the trip departs + examples: + - '2024-02-01T10:00:00Z' + arrival_time: + type: string + format: date-time + description: The date and time when the trip arrives + examples: + - '2024-02-01T16:00:00Z' + operator: + type: string + description: The name of the operator of the trip + examples: + - Deutsche Bahn + - SNCF + price: + type: number + description: The cost of the trip + examples: + - 50 + bicycles_allowed: + type: boolean + description: Indicates whether bicycles are allowed on the trip + dogs_allowed: + type: boolean + description: Indicates whether dogs are allowed on the trip + Booking: + type: object + xml: + name: booking + properties: + id: + type: string + format: uuid + description: Unique identifier for the booking + readOnly: true + examples: + - 3f3e3e1-c824-4d63-b37a-d8d698862f1d + trip_id: + type: string + format: uuid + description: Identifier of the booked trip + examples: + - 4f4e4e1-c824-4d63-b37a-d8d698862f1d + passenger_name: + type: string + description: Name of the passenger + examples: + - John Doe + has_bicycle: + type: boolean + description: Indicates whether the passenger has a bicycle. + has_dog: + type: boolean + description: Indicates whether the passenger has a dog. + Wrapper-Collection: + description: This is a generic request/response wrapper which contains both data and links which serve as hypermedia controls (HATEOAS). + type: object + properties: + data: + description: The wrapper for a collection is an array of objects. + type: array + items: + type: object + links: + description: A set of hypermedia links which serve as controls for the client. + type: object + readOnly: true + xml: + name: data + BookingPayment: + type: object + properties: + id: + description: Unique identifier for the payment. This will be a unique identifier for the payment, and is used to reference the payment in other objects. + type: string + format: uuid + readOnly: true + amount: + description: Amount intended to be collected by this payment. A positive decimal figure describing the amount to be collected. + type: number + exclusiveMinimum: 0 + examples: + - 49.99 + currency: + description: Three-letter [ISO currency code](https://www.iso.org/iso-4217-currency-codes.html), in lowercase. + type: string + enum: + - bam + - bgn + - chf + - eur + - gbp + - nok + - sek + - try + source: + unevaluatedProperties: false + description: The payment source to take the payment from. This can be a card or a bank account. Some of these properties will be hidden on read to protect PII leaking. + oneOf: + - title: Card + description: A card (debit or credit) to take payment from. + type: object + properties: + object: + type: string + const: card + name: + type: string + description: Cardholder's full name as it appears on the card. + examples: + - Francis Bourgeois + number: + type: string + description: The card number, as a string without any separators. On read all but the last four digits will be masked for security. + examples: + - '4242424242424242' + cvc: + type: integer + description: Card security code, 3 or 4 digits usually found on the back of the card. + minLength: 3 + maxLength: 4 + writeOnly: true + + example: 123 + exp_month: + type: integer + format: int64 + description: Two-digit number representing the card's expiration month. + examples: + - 12 + exp_year: + type: integer + format: int64 + description: Four-digit number representing the card's expiration year. + examples: + - 2025 + address_line1: + type: string + writeOnly: true + address_line2: + type: string + writeOnly: true + address_city: + type: string + address_country: + type: string + address_post_code: + type: string + required: + - name + - number + - cvc + - exp_month + - exp_year + - address_country + - title: Bank Account + description: A bank account to take payment from. Must be able to make payments in the currency specified in the payment. + type: object + properties: + object: + const: bank_account + type: string + name: + type: string + number: + type: string + description: The account number for the bank account, in string form. Must be a current account. + sort_code: + type: string + description: The sort code for the bank account, in string form. Must be a six-digit number. + account_type: + enum: + - individual + - company + type: string + description: The type of entity that holds the account. This can be either `individual` or `company`. + bank_name: + type: string + description: The name of the bank associated with the routing number. + examples: + - Starling Bank + country: + type: string + description: Two-letter country code (ISO 3166-1 alpha-2). + required: + - name + - number + - account_type + - bank_name + - country + status: + description: The status of the payment, one of `pending`, `succeeded`, or `failed`. + type: string + enum: + - pending + - succeeded + - failed + readOnly: true + Links-Booking: + type: object + properties: + booking: + type: string + format: uri + examples: + - https://api.example.com/bookings/1725ff48-ab45-4bb5-9d02-88745177dedb + headers: + Cache-Control: + description: | + The Cache-Control header communicates directives for caching mechanisms in both requests and responses. + It is used to specify the caching directives in responses to prevent caches from storing sensitive information. + schema: + type: string + description: A comma-separated list of directives as defined in [RFC 9111](https://www.rfc-editor.org/rfc/rfc9111.html). + examples: + - max-age=3600 + - max-age=604800, public + - no-store + - no-cache + - private + + RateLimit: + description: | + The RateLimit header communicates quota policies. It contains a `limit` to + convey the expiring limit, `remaining` to convey the remaining quota units, + and `reset` to convey the time window reset time. + schema: + type: string + examples: + - limit=10, remaining=0, reset=10 + + Retry-After: + description: | + The Retry-After header indicates how long the user agent should wait before making a follow-up request. + The value is in seconds and can be an integer or a date in the future. + If the value is an integer, it indicates the number of seconds to wait. + If the value is a date, it indicates the time at which the user agent should make a follow-up request. + schema: + type: string + examples: + integer: + value: '120' + summary: Retry after 120 seconds + date: + value: 'Fri, 31 Dec 2021 23:59:59 GMT' + summary: Retry after the specified date + + responses: + BadRequest: + description: Bad Request + headers: + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/bad-request + title: Bad Request + status: 400 + detail: The request is invalid or missing required parameters. + application/problem+xml: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/bad-request + title: Bad Request + status: 400 + detail: The request is invalid or missing required parameters. + + Conflict: + description: Conflict + headers: + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/conflict + title: Conflict + status: 409 + detail: There is a conflict with an existing resource. + application/problem+xml: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/conflict + title: Conflict + status: 409 + detail: There is a conflict with an existing resource. + + Forbidden: + description: Forbidden + headers: + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/forbidden + title: Forbidden + status: 403 + detail: Access is forbidden with the provided credentials. + application/problem+xml: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/forbidden + title: Forbidden + status: 403 + detail: Access is forbidden with the provided credentials. + + InternalServerError: + description: Internal Server Error + headers: + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/internal-server-error + title: Internal Server Error + status: 500 + detail: An unexpected error occurred. + application/problem+xml: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/internal-server-error + title: Internal Server Error + status: 500 + detail: An unexpected error occurred. + + NotFound: + description: Not Found + headers: + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/not-found + title: Not Found + status: 404 + detail: The requested resource was not found. + application/problem+xml: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/not-found + title: Not Found + status: 404 + detail: The requested resource was not found. + + TooManyRequests: + description: Too Many Requests + headers: + RateLimit: + $ref: '#/components/headers/RateLimit' + Retry-After: + $ref: '#/components/headers/Retry-After' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/too-many-requests + title: Too Many Requests + status: 429 + detail: You have exceeded the rate limit. + application/problem+xml: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/too-many-requests + title: Too Many Requests + status: 429 + detail: You have exceeded the rate limit. + + Unauthorized: + description: Unauthorized + headers: + RateLimit: + $ref: '#/components/headers/RateLimit' + content: + application/problem+json: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/unauthorized + title: Unauthorized + status: 401 + detail: You do not have the necessary permissions. + application/problem+xml: + schema: + $ref: '#/components/schemas/Problem' + example: + type: https://example.com/errors/unauthorized + title: Unauthorized + status: 401 + detail: You do not have the necessary permissions. From 8551cbbb86c8c22294a2050253f4c2e357682260 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 18 Dec 2024 12:30:22 +0000 Subject: [PATCH 20/21] Require symfony/yaml Limits versions allowed by devizzent/cebe-php-openapi --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index fe7d4c8..72805ed 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,7 @@ }, "require": { "php": "^8.1.0", + "symfony/yaml": "^4 || ^5 || ^6 || ^7", "devizzent/cebe-php-openapi": "^1.1.2" }, "require-dev": { From 710d06249c2a80d4a660e945315bf32eb872d585 Mon Sep 17 00:00:00 2001 From: John Charman Date: Wed, 18 Dec 2024 15:07:05 +0000 Subject: [PATCH 21/21] Remove unnecessary typehint AdditionalProperties is always a Schema. It could never be set as bool. This commit does not change behaviour --- src/ValueObject/Valid/V30/Keywords.php | 2 +- src/ValueObject/Valid/V31/Keywords.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ValueObject/Valid/V30/Keywords.php b/src/ValueObject/Valid/V30/Keywords.php index e5a43e0..32bedd4 100644 --- a/src/ValueObject/Valid/V30/Keywords.php +++ b/src/ValueObject/Valid/V30/Keywords.php @@ -44,7 +44,7 @@ final class Keywords extends Validated implements Valid\Schema public readonly array $required; /** @var array */ public readonly array $properties; - public readonly bool|Schema $additionalProperties; + public readonly Schema $additionalProperties; /** @var list */ public readonly array $allOf; diff --git a/src/ValueObject/Valid/V31/Keywords.php b/src/ValueObject/Valid/V31/Keywords.php index 5946e2a..2d55bc1 100644 --- a/src/ValueObject/Valid/V31/Keywords.php +++ b/src/ValueObject/Valid/V31/Keywords.php @@ -44,7 +44,7 @@ final class Keywords extends Validated implements Valid\Schema public readonly array $required; /** @var array */ public readonly array $properties; - public readonly bool|Schema $additionalProperties; + public readonly Schema $additionalProperties; /** @var list */ public readonly array $allOf;