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 @@ + + * 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; +} 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..91707d8 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 list|null */ - public readonly ?array $enum; + public readonly array|null $enum; public function __construct( Identifier $identifier, 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/Service/Url/ValidatesTemplateTest.php b/tests/Service/Url/ValidatesTemplateTest.php new file mode 100644 index 0000000..d1d0f35 --- /dev/null +++ b/tests/Service/Url/ValidatesTemplateTest.php @@ -0,0 +1,104 @@ + */ + 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", + ]; + } +} 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' => []], + ); + } +}